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/analytics-service/package.json
Normal file
26
marketing-agent/services/analytics-service/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "analytics-service",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "Real-time analytics and reporting service",
|
||||
"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",
|
||||
"socket.io": "^4.6.1",
|
||||
"ioredis": "^5.3.2",
|
||||
"joi": "^17.11.0",
|
||||
"winston": "^3.11.0",
|
||||
"pdfkit": "^0.14.0",
|
||||
"exceljs": "^4.4.0",
|
||||
"chartjs-node-canvas": "^4.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export default {
|
||||
port: process.env.PORT || 3006,
|
||||
|
||||
mongodb: {
|
||||
uri: process.env.MONGODB_URI || 'mongodb://localhost:27017/marketing-analytics',
|
||||
options: {
|
||||
useNewUrlParser: true,
|
||||
useUnifiedTopology: true
|
||||
}
|
||||
},
|
||||
|
||||
redis: {
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: process.env.REDIS_PORT || 6379,
|
||||
password: process.env.REDIS_PASSWORD || ''
|
||||
},
|
||||
|
||||
cors: {
|
||||
origin: process.env.CORS_ORIGIN?.split(',') || ['http://localhost:5173', 'http://localhost:3000'],
|
||||
credentials: true
|
||||
},
|
||||
|
||||
services: {
|
||||
campaignService: process.env.CAMPAIGN_SERVICE_URL || 'http://localhost:3004',
|
||||
messagingService: process.env.MESSAGING_SERVICE_URL || 'http://localhost:3005',
|
||||
userService: process.env.USER_SERVICE_URL || 'http://localhost:3003'
|
||||
},
|
||||
|
||||
logging: {
|
||||
level: process.env.LOG_LEVEL || 'info'
|
||||
}
|
||||
};
|
||||
103
marketing-agent/services/analytics-service/src/index.js
Normal file
103
marketing-agent/services/analytics-service/src/index.js
Normal file
@@ -0,0 +1,103 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import mongoose from 'mongoose';
|
||||
import { createServer } from 'http';
|
||||
import { Server } from 'socket.io';
|
||||
import config from './config/index.js';
|
||||
import routes from './routes/index.js';
|
||||
import errorHandler from './middleware/errorHandler.js';
|
||||
import { logger } from './utils/logger.js';
|
||||
import { realtimeAnalytics } from './services/realtimeAnalytics.js';
|
||||
|
||||
const app = express();
|
||||
const httpServer = createServer(app);
|
||||
const io = new Server(httpServer, {
|
||||
cors: {
|
||||
origin: config.cors.origin,
|
||||
credentials: true
|
||||
}
|
||||
});
|
||||
|
||||
// Middleware
|
||||
app.use(cors(config.cors));
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
service: 'analytics-service',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
// Routes
|
||||
app.use('/api', routes);
|
||||
|
||||
// Error handling
|
||||
app.use(errorHandler);
|
||||
|
||||
// Socket.io for real-time analytics
|
||||
io.on('connection', (socket) => {
|
||||
logger.info('Client connected:', socket.id);
|
||||
|
||||
socket.on('subscribe-metrics', ({ accountId, metrics }) => {
|
||||
logger.info(`Client ${socket.id} subscribing to metrics for account ${accountId}`);
|
||||
|
||||
// Join account room
|
||||
socket.join(`account:${accountId}`);
|
||||
|
||||
// Set up real-time metric streaming
|
||||
const unsubscribe = realtimeAnalytics.streamMetrics(
|
||||
accountId,
|
||||
metrics,
|
||||
(data) => {
|
||||
socket.emit('metric-update', data);
|
||||
}
|
||||
);
|
||||
|
||||
// Store unsubscribe function for cleanup
|
||||
socket.data.unsubscribe = unsubscribe;
|
||||
});
|
||||
|
||||
socket.on('unsubscribe-metrics', () => {
|
||||
if (socket.data.unsubscribe) {
|
||||
socket.data.unsubscribe();
|
||||
delete socket.data.unsubscribe;
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
logger.info('Client disconnected:', socket.id);
|
||||
if (socket.data.unsubscribe) {
|
||||
socket.data.unsubscribe();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Make io accessible in routes
|
||||
app.set('io', io);
|
||||
|
||||
// Database connection
|
||||
mongoose.connect(config.mongodb.uri, config.mongodb.options)
|
||||
.then(() => {
|
||||
logger.info('Connected to MongoDB');
|
||||
httpServer.listen(config.port, () => {
|
||||
logger.info(`Analytics service listening on port ${config.port}`);
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('MongoDB connection error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', async () => {
|
||||
logger.info('SIGTERM received, shutting down gracefully');
|
||||
await mongoose.connection.close();
|
||||
httpServer.close(() => {
|
||||
logger.info('HTTP server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
const analyticsSchema = new mongoose.Schema({
|
||||
// Multi-tenant support
|
||||
tenantId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Tenant',
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
date: {
|
||||
type: Date,
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
accountId: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
messagesSent: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
messagesDelivered: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
messagesRead: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
messagesFailed: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
conversions: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
revenue: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
uniqueRecipients: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
engagementRate: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
clickThroughRate: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
bounceRate: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
avgResponseTime: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
campaignBreakdown: [{
|
||||
campaignId: String,
|
||||
campaignName: String,
|
||||
messagesSent: Number,
|
||||
messagesDelivered: Number,
|
||||
messagesRead: Number,
|
||||
conversions: Number,
|
||||
revenue: Number
|
||||
}],
|
||||
hourlyBreakdown: [{
|
||||
hour: Number,
|
||||
messagesSent: Number,
|
||||
messagesDelivered: Number,
|
||||
messagesRead: Number,
|
||||
conversions: Number
|
||||
}],
|
||||
deviceBreakdown: {
|
||||
mobile: {
|
||||
count: { type: Number, default: 0 },
|
||||
percentage: { type: Number, default: 0 }
|
||||
},
|
||||
desktop: {
|
||||
count: { type: Number, default: 0 },
|
||||
percentage: { type: Number, default: 0 }
|
||||
},
|
||||
tablet: {
|
||||
count: { type: Number, default: 0 },
|
||||
percentage: { type: Number, default: 0 }
|
||||
}
|
||||
},
|
||||
locationBreakdown: [{
|
||||
country: String,
|
||||
region: String,
|
||||
city: String,
|
||||
count: Number,
|
||||
percentage: Number
|
||||
}]
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
// Compound indexes
|
||||
analyticsSchema.index({ accountId: 1, date: -1 });
|
||||
analyticsSchema.index({ date: -1 });
|
||||
|
||||
// Multi-tenant indexes
|
||||
analyticsSchema.index({ tenantId: 1, accountId: 1, date: -1 });
|
||||
analyticsSchema.index({ tenantId: 1, date: -1 });
|
||||
|
||||
// Virtual properties
|
||||
analyticsSchema.virtual('deliveryRate').get(function() {
|
||||
if (this.messagesSent === 0) return 0;
|
||||
return (this.messagesDelivered / this.messagesSent) * 100;
|
||||
});
|
||||
|
||||
analyticsSchema.virtual('readRate').get(function() {
|
||||
if (this.messagesDelivered === 0) return 0;
|
||||
return (this.messagesRead / this.messagesDelivered) * 100;
|
||||
});
|
||||
|
||||
analyticsSchema.virtual('conversionRate').get(function() {
|
||||
if (this.messagesDelivered === 0) return 0;
|
||||
return (this.conversions / this.messagesDelivered) * 100;
|
||||
});
|
||||
|
||||
// Static methods
|
||||
analyticsSchema.statics.aggregateByDateRange = async function(accountId, startDate, endDate) {
|
||||
return this.aggregate([
|
||||
{
|
||||
$match: {
|
||||
accountId,
|
||||
date: {
|
||||
$gte: new Date(startDate),
|
||||
$lte: new Date(endDate)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
totalMessagesSent: { $sum: '$messagesSent' },
|
||||
totalMessagesDelivered: { $sum: '$messagesDelivered' },
|
||||
totalMessagesRead: { $sum: '$messagesRead' },
|
||||
totalMessagesFailed: { $sum: '$messagesFailed' },
|
||||
totalConversions: { $sum: '$conversions' },
|
||||
totalRevenue: { $sum: '$revenue' },
|
||||
avgEngagementRate: { $avg: '$engagementRate' },
|
||||
avgClickThroughRate: { $avg: '$clickThroughRate' },
|
||||
days: { $sum: 1 }
|
||||
}
|
||||
}
|
||||
]);
|
||||
};
|
||||
|
||||
analyticsSchema.statics.getTopPerformingCampaigns = async function(accountId, limit = 10) {
|
||||
const endDate = new Date();
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - 30); // Last 30 days
|
||||
|
||||
return this.aggregate([
|
||||
{
|
||||
$match: {
|
||||
accountId,
|
||||
date: {
|
||||
$gte: startDate,
|
||||
$lte: endDate
|
||||
}
|
||||
}
|
||||
},
|
||||
{ $unwind: '$campaignBreakdown' },
|
||||
{
|
||||
$group: {
|
||||
_id: '$campaignBreakdown.campaignId',
|
||||
campaignName: { $first: '$campaignBreakdown.campaignName' },
|
||||
totalMessagesSent: { $sum: '$campaignBreakdown.messagesSent' },
|
||||
totalConversions: { $sum: '$campaignBreakdown.conversions' },
|
||||
totalRevenue: { $sum: '$campaignBreakdown.revenue' }
|
||||
}
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
campaignId: '$_id',
|
||||
campaignName: 1,
|
||||
totalMessagesSent: 1,
|
||||
totalConversions: 1,
|
||||
totalRevenue: 1,
|
||||
conversionRate: {
|
||||
$cond: [
|
||||
{ $eq: ['$totalMessagesSent', 0] },
|
||||
0,
|
||||
{ $divide: ['$totalConversions', '$totalMessagesSent'] }
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{ $sort: { totalRevenue: -1 } },
|
||||
{ $limit: limit }
|
||||
]);
|
||||
};
|
||||
|
||||
export const Analytics = mongoose.model('Analytics', analyticsSchema);
|
||||
@@ -0,0 +1,308 @@
|
||||
import express from 'express';
|
||||
import { Analytics } from '../models/Analytics.js';
|
||||
import { realtimeAnalytics } from '../services/realtimeAnalytics.js';
|
||||
import { validateRequest } from '../middleware/validateRequest.js';
|
||||
import Joi from 'joi';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Validation schemas
|
||||
const getAnalyticsSchema = Joi.object({
|
||||
startDate: Joi.date().required(),
|
||||
endDate: Joi.date().required(),
|
||||
granularity: Joi.string().valid('hour', 'day', 'week', 'month').default('day'),
|
||||
metrics: Joi.array().items(Joi.string()).optional()
|
||||
});
|
||||
|
||||
const aggregateAnalyticsSchema = Joi.object({
|
||||
startDate: Joi.date().required(),
|
||||
endDate: Joi.date().required(),
|
||||
groupBy: Joi.string().valid('campaign', 'hour', 'day', 'device', 'location').required(),
|
||||
metrics: Joi.array().items(Joi.string()).required()
|
||||
});
|
||||
|
||||
// Get analytics data
|
||||
router.get('/:accountId', validateRequest(getAnalyticsSchema, 'query'), async (req, res, next) => {
|
||||
try {
|
||||
const { accountId } = req.params;
|
||||
const { startDate, endDate, granularity, metrics } = req.query;
|
||||
|
||||
const query = {
|
||||
accountId,
|
||||
date: {
|
||||
$gte: new Date(startDate),
|
||||
$lte: new Date(endDate)
|
||||
}
|
||||
};
|
||||
|
||||
const projection = metrics ?
|
||||
metrics.reduce((acc, metric) => ({ ...acc, [metric]: 1 }), { date: 1 }) :
|
||||
{};
|
||||
|
||||
const analytics = await Analytics.find(query, projection)
|
||||
.sort({ date: 1 });
|
||||
|
||||
// Aggregate by granularity if needed
|
||||
const aggregatedData = aggregateByGranularity(analytics, granularity);
|
||||
|
||||
res.json({
|
||||
accountId,
|
||||
period: { start: startDate, end: endDate },
|
||||
granularity,
|
||||
data: aggregatedData
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get aggregated analytics
|
||||
router.get('/:accountId/aggregate', validateRequest(aggregateAnalyticsSchema, 'query'), async (req, res, next) => {
|
||||
try {
|
||||
const { accountId } = req.params;
|
||||
const { startDate, endDate, groupBy, metrics } = req.query;
|
||||
|
||||
const pipeline = [
|
||||
{
|
||||
$match: {
|
||||
accountId,
|
||||
date: {
|
||||
$gte: new Date(startDate),
|
||||
$lte: new Date(endDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Add grouping stage based on groupBy parameter
|
||||
switch (groupBy) {
|
||||
case 'campaign':
|
||||
pipeline.push(
|
||||
{ $unwind: '$campaignBreakdown' },
|
||||
{
|
||||
$group: {
|
||||
_id: '$campaignBreakdown.campaignId',
|
||||
campaignName: { $first: '$campaignBreakdown.campaignName' },
|
||||
...metrics.reduce((acc, metric) => ({
|
||||
...acc,
|
||||
[metric]: { $sum: `$campaignBreakdown.${metric}` }
|
||||
}), {})
|
||||
}
|
||||
}
|
||||
);
|
||||
break;
|
||||
|
||||
case 'hour':
|
||||
pipeline.push(
|
||||
{ $unwind: '$hourlyBreakdown' },
|
||||
{
|
||||
$group: {
|
||||
_id: '$hourlyBreakdown.hour',
|
||||
...metrics.reduce((acc, metric) => ({
|
||||
...acc,
|
||||
[metric]: { $sum: `$hourlyBreakdown.${metric}` }
|
||||
}), {})
|
||||
}
|
||||
}
|
||||
);
|
||||
break;
|
||||
|
||||
case 'day':
|
||||
pipeline.push({
|
||||
$group: {
|
||||
_id: { $dateToString: { format: '%Y-%m-%d', date: '$date' } },
|
||||
...metrics.reduce((acc, metric) => ({
|
||||
...acc,
|
||||
[metric]: { $sum: `$${metric}` }
|
||||
}), {})
|
||||
}
|
||||
});
|
||||
break;
|
||||
|
||||
case 'device':
|
||||
pipeline.push({
|
||||
$group: {
|
||||
_id: null,
|
||||
mobile: { $sum: '$deviceBreakdown.mobile.count' },
|
||||
desktop: { $sum: '$deviceBreakdown.desktop.count' },
|
||||
tablet: { $sum: '$deviceBreakdown.tablet.count' }
|
||||
}
|
||||
});
|
||||
break;
|
||||
|
||||
case 'location':
|
||||
pipeline.push(
|
||||
{ $unwind: '$locationBreakdown' },
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
country: '$locationBreakdown.country',
|
||||
region: '$locationBreakdown.region'
|
||||
},
|
||||
count: { $sum: '$locationBreakdown.count' }
|
||||
}
|
||||
}
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
pipeline.push({ $sort: { _id: 1 } });
|
||||
|
||||
const results = await Analytics.aggregate(pipeline);
|
||||
|
||||
res.json({
|
||||
accountId,
|
||||
period: { start: startDate, end: endDate },
|
||||
groupBy,
|
||||
metrics,
|
||||
results
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get top performing campaigns
|
||||
router.get('/:accountId/top-campaigns', async (req, res, next) => {
|
||||
try {
|
||||
const { accountId } = req.params;
|
||||
const { limit = 10 } = req.query;
|
||||
|
||||
const topCampaigns = await Analytics.getTopPerformingCampaigns(accountId, parseInt(limit));
|
||||
|
||||
res.json({
|
||||
accountId,
|
||||
campaigns: topCampaigns
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get analytics summary
|
||||
router.get('/:accountId/summary', async (req, res, next) => {
|
||||
try {
|
||||
const { accountId } = req.params;
|
||||
const { days = 30 } = req.query;
|
||||
|
||||
const endDate = new Date();
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - parseInt(days));
|
||||
|
||||
const [current, previous] = await Promise.all([
|
||||
Analytics.aggregateByDateRange(accountId, startDate, endDate),
|
||||
Analytics.aggregateByDateRange(
|
||||
accountId,
|
||||
new Date(startDate.getTime() - (endDate - startDate)),
|
||||
startDate
|
||||
)
|
||||
]);
|
||||
|
||||
const currentData = current[0] || {};
|
||||
const previousData = previous[0] || {};
|
||||
|
||||
// Calculate changes
|
||||
const summary = {
|
||||
messagesSent: {
|
||||
value: currentData.totalMessagesSent || 0,
|
||||
change: calculateChange(currentData.totalMessagesSent, previousData.totalMessagesSent)
|
||||
},
|
||||
deliveryRate: {
|
||||
value: calculateRate(currentData.totalMessagesDelivered, currentData.totalMessagesSent),
|
||||
change: calculateChange(
|
||||
calculateRate(currentData.totalMessagesDelivered, currentData.totalMessagesSent),
|
||||
calculateRate(previousData.totalMessagesDelivered, previousData.totalMessagesSent)
|
||||
)
|
||||
},
|
||||
readRate: {
|
||||
value: calculateRate(currentData.totalMessagesRead, currentData.totalMessagesDelivered),
|
||||
change: calculateChange(
|
||||
calculateRate(currentData.totalMessagesRead, currentData.totalMessagesDelivered),
|
||||
calculateRate(previousData.totalMessagesRead, previousData.totalMessagesDelivered)
|
||||
)
|
||||
},
|
||||
conversions: {
|
||||
value: currentData.totalConversions || 0,
|
||||
change: calculateChange(currentData.totalConversions, previousData.totalConversions)
|
||||
},
|
||||
revenue: {
|
||||
value: currentData.totalRevenue || 0,
|
||||
change: calculateChange(currentData.totalRevenue, previousData.totalRevenue)
|
||||
},
|
||||
avgEngagementRate: {
|
||||
value: currentData.avgEngagementRate || 0,
|
||||
change: calculateChange(currentData.avgEngagementRate, previousData.avgEngagementRate)
|
||||
}
|
||||
};
|
||||
|
||||
res.json({
|
||||
accountId,
|
||||
period: { start: startDate, end: endDate },
|
||||
summary
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
function aggregateByGranularity(data, granularity) {
|
||||
if (granularity === 'day') {
|
||||
return data;
|
||||
}
|
||||
|
||||
const aggregated = {};
|
||||
|
||||
data.forEach(item => {
|
||||
let key;
|
||||
const date = new Date(item.date);
|
||||
|
||||
switch (granularity) {
|
||||
case 'hour':
|
||||
key = `${date.toISOString().split('T')[0]}T${date.getHours().toString().padStart(2, '0')}:00:00`;
|
||||
break;
|
||||
case 'week':
|
||||
const weekStart = new Date(date);
|
||||
weekStart.setDate(date.getDate() - date.getDay());
|
||||
key = weekStart.toISOString().split('T')[0];
|
||||
break;
|
||||
case 'month':
|
||||
key = `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}`;
|
||||
break;
|
||||
default:
|
||||
key = date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
if (!aggregated[key]) {
|
||||
aggregated[key] = {
|
||||
date: key,
|
||||
messagesSent: 0,
|
||||
messagesDelivered: 0,
|
||||
messagesRead: 0,
|
||||
messagesFailed: 0,
|
||||
conversions: 0,
|
||||
revenue: 0
|
||||
};
|
||||
}
|
||||
|
||||
// Aggregate metrics
|
||||
Object.keys(item.toObject()).forEach(metric => {
|
||||
if (typeof item[metric] === 'number') {
|
||||
aggregated[key][metric] = (aggregated[key][metric] || 0) + item[metric];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return Object.values(aggregated);
|
||||
}
|
||||
|
||||
function calculateRate(numerator, denominator) {
|
||||
return denominator > 0 ? (numerator / denominator) * 100 : 0;
|
||||
}
|
||||
|
||||
function calculateChange(current, previous) {
|
||||
if (!previous || previous === 0) return current > 0 ? 100 : 0;
|
||||
return ((current - previous) / previous) * 100;
|
||||
}
|
||||
|
||||
export default router;
|
||||
195
marketing-agent/services/analytics-service/src/routes/events.js
Normal file
195
marketing-agent/services/analytics-service/src/routes/events.js
Normal file
@@ -0,0 +1,195 @@
|
||||
import express from 'express';
|
||||
import { realtimeAnalytics } from '../services/realtimeAnalytics.js';
|
||||
import { validateRequest } from '../middleware/validateRequest.js';
|
||||
import Joi from 'joi';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Validation schemas
|
||||
const trackEventSchema = Joi.object({
|
||||
accountId: Joi.string().required(),
|
||||
eventName: Joi.string().required(),
|
||||
properties: Joi.object().optional(),
|
||||
userId: Joi.string().optional(),
|
||||
timestamp: Joi.date().optional()
|
||||
});
|
||||
|
||||
const batchTrackSchema = Joi.object({
|
||||
accountId: Joi.string().required(),
|
||||
events: Joi.array().items(Joi.object({
|
||||
eventName: Joi.string().required(),
|
||||
properties: Joi.object().optional(),
|
||||
userId: Joi.string().optional(),
|
||||
timestamp: Joi.date().optional()
|
||||
})).required()
|
||||
});
|
||||
|
||||
// Track single event
|
||||
router.post('/track', validateRequest(trackEventSchema), async (req, res, next) => {
|
||||
try {
|
||||
const { accountId, eventName, properties, userId, timestamp } = req.body;
|
||||
|
||||
// Map common events to metrics
|
||||
const metricMappings = {
|
||||
'message_sent': 'messages_sent',
|
||||
'message_delivered': 'messages_delivered',
|
||||
'message_read': 'messages_read',
|
||||
'message_failed': 'messages_failed',
|
||||
'conversion': 'conversions',
|
||||
'revenue': 'revenue',
|
||||
'user_active': 'active_users',
|
||||
'user_signup': 'new_users',
|
||||
'campaign_started': 'campaigns_started',
|
||||
'campaign_completed': 'campaigns_completed'
|
||||
};
|
||||
|
||||
const metricType = metricMappings[eventName] || eventName;
|
||||
const value = properties?.value || 1;
|
||||
|
||||
// Record in real-time analytics
|
||||
await realtimeAnalytics.recordMetric(
|
||||
accountId,
|
||||
metricType,
|
||||
value,
|
||||
{
|
||||
eventName,
|
||||
userId,
|
||||
properties,
|
||||
timestamp: timestamp || new Date()
|
||||
}
|
||||
);
|
||||
|
||||
// Emit to WebSocket clients
|
||||
const io = req.app.get('io');
|
||||
if (io) {
|
||||
io.to(`account:${accountId}`).emit('event', {
|
||||
accountId,
|
||||
eventName,
|
||||
properties,
|
||||
userId,
|
||||
timestamp: timestamp || new Date()
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Event tracked successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Track batch events
|
||||
router.post('/track/batch', validateRequest(batchTrackSchema), async (req, res, next) => {
|
||||
try {
|
||||
const { accountId, events } = req.body;
|
||||
const results = [];
|
||||
|
||||
for (const event of events) {
|
||||
try {
|
||||
const metricType = event.eventName.replace(/_/g, '');
|
||||
const value = event.properties?.value || 1;
|
||||
|
||||
await realtimeAnalytics.recordMetric(
|
||||
accountId,
|
||||
metricType,
|
||||
value,
|
||||
{
|
||||
eventName: event.eventName,
|
||||
userId: event.userId,
|
||||
properties: event.properties,
|
||||
timestamp: event.timestamp || new Date()
|
||||
}
|
||||
);
|
||||
|
||||
results.push({
|
||||
eventName: event.eventName,
|
||||
success: true
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
eventName: event.eventName,
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Batch events tracked',
|
||||
results
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get event types
|
||||
router.get('/:accountId/types', async (req, res, next) => {
|
||||
try {
|
||||
const { accountId } = req.params;
|
||||
|
||||
// TODO: Fetch from database
|
||||
const eventTypes = [
|
||||
{ name: 'message_sent', category: 'messaging', count: 0 },
|
||||
{ name: 'message_delivered', category: 'messaging', count: 0 },
|
||||
{ name: 'message_read', category: 'messaging', count: 0 },
|
||||
{ name: 'message_failed', category: 'messaging', count: 0 },
|
||||
{ name: 'conversion', category: 'conversion', count: 0 },
|
||||
{ name: 'revenue', category: 'conversion', count: 0 },
|
||||
{ name: 'user_active', category: 'user', count: 0 },
|
||||
{ name: 'user_signup', category: 'user', count: 0 },
|
||||
{ name: 'campaign_started', category: 'campaign', count: 0 },
|
||||
{ name: 'campaign_completed', category: 'campaign', count: 0 }
|
||||
];
|
||||
|
||||
res.json({
|
||||
accountId,
|
||||
eventTypes
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get event properties schema
|
||||
router.get('/schema/:eventName', (req, res) => {
|
||||
const { eventName } = req.params;
|
||||
|
||||
const schemas = {
|
||||
message_sent: {
|
||||
required: ['campaignId', 'messageId'],
|
||||
optional: ['templateId', 'userId']
|
||||
},
|
||||
message_delivered: {
|
||||
required: ['messageId'],
|
||||
optional: ['deliveryTime']
|
||||
},
|
||||
message_read: {
|
||||
required: ['messageId'],
|
||||
optional: ['readTime', 'deviceType']
|
||||
},
|
||||
conversion: {
|
||||
required: ['conversionType'],
|
||||
optional: ['value', 'currency', 'productId']
|
||||
},
|
||||
revenue: {
|
||||
required: ['amount', 'currency'],
|
||||
optional: ['transactionId', 'productId', 'quantity']
|
||||
}
|
||||
};
|
||||
|
||||
const schema = schemas[eventName] || {
|
||||
required: [],
|
||||
optional: ['custom properties allowed']
|
||||
};
|
||||
|
||||
res.json({
|
||||
eventName,
|
||||
schema
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,14 @@
|
||||
import express from 'express';
|
||||
import analyticsRoutes from './analytics.js';
|
||||
import realtimeRoutes from './realtime.js';
|
||||
import reportsRoutes from './reports.js';
|
||||
import eventsRoutes from './events.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use('/analytics', analyticsRoutes);
|
||||
router.use('/realtime', realtimeRoutes);
|
||||
router.use('/reports', reportsRoutes);
|
||||
router.use('/events', eventsRoutes);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,178 @@
|
||||
import express from 'express';
|
||||
import { realtimeAnalytics } from '../services/realtimeAnalytics.js';
|
||||
import { validateRequest } from '../middleware/validateRequest.js';
|
||||
import Joi from 'joi';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Validation schemas
|
||||
const recordMetricSchema = Joi.object({
|
||||
accountId: Joi.string().required(),
|
||||
metricType: Joi.string().required(),
|
||||
value: Joi.number().default(1),
|
||||
metadata: Joi.object().optional()
|
||||
});
|
||||
|
||||
const getMetricsSchema = Joi.object({
|
||||
metrics: Joi.array().items(Joi.string()).required(),
|
||||
timeRange: Joi.string().valid('minute', 'hour', 'day').default('hour')
|
||||
});
|
||||
|
||||
const funnelSchema = Joi.object({
|
||||
steps: Joi.array().items(Joi.object({
|
||||
name: Joi.string().required(),
|
||||
metric: Joi.string().required()
|
||||
})).required(),
|
||||
timeRange: Joi.string().valid('minute', 'hour', 'day').default('day')
|
||||
});
|
||||
|
||||
// Record a metric
|
||||
router.post('/metric', validateRequest(recordMetricSchema), async (req, res, next) => {
|
||||
try {
|
||||
const { accountId, metricType, value, metadata } = req.body;
|
||||
|
||||
await realtimeAnalytics.recordMetric(accountId, metricType, value, metadata);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Metric recorded'
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get real-time metrics
|
||||
router.get('/:accountId/metrics', validateRequest(getMetricsSchema, 'query'), async (req, res, next) => {
|
||||
try {
|
||||
const { accountId } = req.params;
|
||||
const { metrics, timeRange } = req.query;
|
||||
|
||||
const data = await realtimeAnalytics.getRealtimeMetrics(
|
||||
accountId,
|
||||
metrics,
|
||||
timeRange
|
||||
);
|
||||
|
||||
res.json({
|
||||
accountId,
|
||||
timeRange,
|
||||
metrics: data,
|
||||
timestamp: new Date()
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get dashboard data
|
||||
router.get('/:accountId/dashboard', async (req, res, next) => {
|
||||
try {
|
||||
const { accountId } = req.params;
|
||||
|
||||
const dashboardData = await realtimeAnalytics.getDashboardData(accountId);
|
||||
|
||||
res.json(dashboardData);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get funnel analytics
|
||||
router.post('/:accountId/funnel', validateRequest(funnelSchema), async (req, res, next) => {
|
||||
try {
|
||||
const { accountId } = req.params;
|
||||
const { steps, timeRange } = req.body;
|
||||
|
||||
const funnelData = await realtimeAnalytics.getFunnelAnalytics(
|
||||
accountId,
|
||||
steps,
|
||||
timeRange
|
||||
);
|
||||
|
||||
res.json({
|
||||
accountId,
|
||||
timeRange,
|
||||
funnel: funnelData,
|
||||
timestamp: new Date()
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get cohort analytics
|
||||
router.get('/:accountId/cohorts', async (req, res, next) => {
|
||||
try {
|
||||
const { accountId } = req.params;
|
||||
const { cohortType = 'new_users', metric = 'active_users', periods = 7 } = req.query;
|
||||
|
||||
const cohortData = await realtimeAnalytics.getCohortAnalytics(
|
||||
accountId,
|
||||
cohortType,
|
||||
metric,
|
||||
parseInt(periods)
|
||||
);
|
||||
|
||||
res.json({
|
||||
accountId,
|
||||
cohortType,
|
||||
metric,
|
||||
cohorts: cohortData,
|
||||
timestamp: new Date()
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// WebSocket endpoint for real-time streaming
|
||||
router.ws('/:accountId/stream', (ws, req) => {
|
||||
const { accountId } = req.params;
|
||||
let unsubscribe;
|
||||
|
||||
ws.on('message', (msg) => {
|
||||
try {
|
||||
const { action, metrics } = JSON.parse(msg);
|
||||
|
||||
if (action === 'subscribe' && metrics) {
|
||||
// Set up metric streaming
|
||||
unsubscribe = realtimeAnalytics.streamMetrics(
|
||||
accountId,
|
||||
metrics,
|
||||
(data) => {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'metric',
|
||||
data
|
||||
}));
|
||||
}
|
||||
);
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'subscribed',
|
||||
metrics
|
||||
}));
|
||||
} else if (action === 'unsubscribe' && unsubscribe) {
|
||||
unsubscribe();
|
||||
unsubscribe = null;
|
||||
|
||||
ws.send(JSON.stringify({
|
||||
type: 'unsubscribed'
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'error',
|
||||
message: error.message
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
215
marketing-agent/services/analytics-service/src/routes/reports.js
Normal file
215
marketing-agent/services/analytics-service/src/routes/reports.js
Normal file
@@ -0,0 +1,215 @@
|
||||
import express from 'express';
|
||||
import { reportGenerator } from '../services/reportGenerator.js';
|
||||
import { validateRequest } from '../middleware/validateRequest.js';
|
||||
import Joi from 'joi';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Validation schemas
|
||||
const generateReportSchema = Joi.object({
|
||||
reportType: Joi.string().valid(
|
||||
'campaign-performance',
|
||||
'engagement-analytics',
|
||||
'revenue-analysis',
|
||||
'user-behavior',
|
||||
'executive-summary'
|
||||
).required(),
|
||||
startDate: Joi.date().optional(),
|
||||
endDate: Joi.date().optional(),
|
||||
format: Joi.string().valid('pdf', 'excel', 'json').default('pdf'),
|
||||
includeCharts: Joi.boolean().default(true),
|
||||
includeRawData: Joi.boolean().default(false),
|
||||
email: Joi.string().email().optional()
|
||||
});
|
||||
|
||||
const scheduleReportSchema = Joi.object({
|
||||
reportType: Joi.string().required(),
|
||||
schedule: Joi.string().required(), // Cron expression
|
||||
format: Joi.string().valid('pdf', 'excel', 'json').default('pdf'),
|
||||
recipients: Joi.array().items(Joi.string().email()).required(),
|
||||
options: Joi.object().optional()
|
||||
});
|
||||
|
||||
// Generate report
|
||||
router.post('/:accountId/generate', validateRequest(generateReportSchema), async (req, res, next) => {
|
||||
try {
|
||||
const { accountId } = req.params;
|
||||
const {
|
||||
reportType,
|
||||
startDate,
|
||||
endDate,
|
||||
format,
|
||||
includeCharts,
|
||||
includeRawData,
|
||||
email
|
||||
} = req.body;
|
||||
|
||||
// Generate report
|
||||
const report = await reportGenerator.generateReport(
|
||||
accountId,
|
||||
reportType,
|
||||
{
|
||||
startDate: startDate ? new Date(startDate) : undefined,
|
||||
endDate: endDate ? new Date(endDate) : undefined,
|
||||
format,
|
||||
includeCharts,
|
||||
includeRawData
|
||||
}
|
||||
);
|
||||
|
||||
// If email is provided, send the report via email
|
||||
if (email) {
|
||||
// TODO: Implement email sending
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Report will be sent to ${email}`,
|
||||
reportId: Date.now().toString()
|
||||
});
|
||||
} else {
|
||||
// Return report based on format
|
||||
if (format === 'json') {
|
||||
res.json(report);
|
||||
} else if (format === 'pdf') {
|
||||
res.set({
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="${reportType}-${Date.now()}.pdf"`
|
||||
});
|
||||
res.send(report);
|
||||
} else if (format === 'excel') {
|
||||
res.set({
|
||||
'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'Content-Disposition': `attachment; filename="${reportType}-${Date.now()}.xlsx"`
|
||||
});
|
||||
res.send(report);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get available report types
|
||||
router.get('/types', (req, res) => {
|
||||
const reportTypes = [
|
||||
{
|
||||
id: 'campaign-performance',
|
||||
name: 'Campaign Performance Report',
|
||||
description: 'Detailed analysis of campaign metrics and performance',
|
||||
availableFormats: ['pdf', 'excel', 'json']
|
||||
},
|
||||
{
|
||||
id: 'engagement-analytics',
|
||||
name: 'Engagement Analytics Report',
|
||||
description: 'User engagement patterns and interaction analysis',
|
||||
availableFormats: ['pdf', 'excel', 'json']
|
||||
},
|
||||
{
|
||||
id: 'revenue-analysis',
|
||||
name: 'Revenue Analysis Report',
|
||||
description: 'Revenue trends, sources, and financial metrics',
|
||||
availableFormats: ['pdf', 'excel', 'json']
|
||||
},
|
||||
{
|
||||
id: 'user-behavior',
|
||||
name: 'User Behavior Report',
|
||||
description: 'User segments, cohorts, and behavior patterns',
|
||||
availableFormats: ['pdf', 'excel', 'json']
|
||||
},
|
||||
{
|
||||
id: 'executive-summary',
|
||||
name: 'Executive Summary',
|
||||
description: 'High-level overview with key metrics and insights',
|
||||
availableFormats: ['pdf', 'json']
|
||||
}
|
||||
];
|
||||
|
||||
res.json(reportTypes);
|
||||
});
|
||||
|
||||
// Schedule report
|
||||
router.post('/:accountId/schedule', validateRequest(scheduleReportSchema), async (req, res, next) => {
|
||||
try {
|
||||
const { accountId } = req.params;
|
||||
const { reportType, schedule, format, recipients, options } = req.body;
|
||||
|
||||
// TODO: Implement report scheduling with cron
|
||||
const scheduleId = Date.now().toString();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Report scheduled successfully',
|
||||
scheduleId,
|
||||
schedule: {
|
||||
id: scheduleId,
|
||||
accountId,
|
||||
reportType,
|
||||
schedule,
|
||||
format,
|
||||
recipients,
|
||||
options,
|
||||
createdAt: new Date()
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get scheduled reports
|
||||
router.get('/:accountId/scheduled', async (req, res, next) => {
|
||||
try {
|
||||
const { accountId } = req.params;
|
||||
|
||||
// TODO: Implement fetching scheduled reports from database
|
||||
const scheduledReports = [];
|
||||
|
||||
res.json({
|
||||
accountId,
|
||||
scheduledReports
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Delete scheduled report
|
||||
router.delete('/:accountId/scheduled/:scheduleId', async (req, res, next) => {
|
||||
try {
|
||||
const { accountId, scheduleId } = req.params;
|
||||
|
||||
// TODO: Implement deletion of scheduled report
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Scheduled report deleted'
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Get report history
|
||||
router.get('/:accountId/history', async (req, res, next) => {
|
||||
try {
|
||||
const { accountId } = req.params;
|
||||
const { page = 1, limit = 20 } = req.query;
|
||||
|
||||
// TODO: Implement fetching report history from database
|
||||
const history = [];
|
||||
|
||||
res.json({
|
||||
accountId,
|
||||
history,
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total: 0,
|
||||
pages: 0
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,349 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import { Analytics } from '../models/Analytics.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { redisClient } from '../utils/redis.js';
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
export class RealtimeAnalyticsService extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.metrics = new Map();
|
||||
this.aggregationInterval = 5000; // 5 seconds
|
||||
this.retentionPeriod = 24 * 60 * 60 * 1000; // 24 hours
|
||||
this.startAggregation();
|
||||
}
|
||||
|
||||
// Record real-time metric
|
||||
async recordMetric(accountId, metricType, value = 1, metadata = {}) {
|
||||
const timestamp = new Date();
|
||||
const minute = new Date(Math.floor(timestamp.getTime() / 60000) * 60000);
|
||||
const hour = new Date(timestamp.setMinutes(0, 0, 0));
|
||||
|
||||
// Store in Redis for real-time access
|
||||
const keys = [
|
||||
`realtime:${accountId}:${metricType}:minute:${minute.getTime()}`,
|
||||
`realtime:${accountId}:${metricType}:hour:${hour.getTime()}`,
|
||||
`realtime:${accountId}:${metricType}:day:${new Date().toDateString()}`
|
||||
];
|
||||
|
||||
const pipeline = redisClient.pipeline();
|
||||
|
||||
for (const key of keys) {
|
||||
pipeline.hincrby(key, 'count', 1);
|
||||
pipeline.hincrby(key, 'sum', value);
|
||||
pipeline.expire(key, this.retentionPeriod / 1000);
|
||||
}
|
||||
|
||||
// Store metadata for detailed analysis
|
||||
if (Object.keys(metadata).length > 0) {
|
||||
const metadataKey = `realtime:${accountId}:${metricType}:metadata:${timestamp.getTime()}`;
|
||||
pipeline.set(metadataKey, JSON.stringify(metadata));
|
||||
pipeline.expire(metadataKey, 3600); // 1 hour
|
||||
}
|
||||
|
||||
await pipeline.exec();
|
||||
|
||||
// Emit event for real-time dashboards
|
||||
this.emit('metric', {
|
||||
accountId,
|
||||
metricType,
|
||||
value,
|
||||
metadata,
|
||||
timestamp
|
||||
});
|
||||
|
||||
// Update in-memory metrics for fast access
|
||||
this.updateInMemoryMetrics(accountId, metricType, value);
|
||||
}
|
||||
|
||||
// Get real-time metrics
|
||||
async getRealtimeMetrics(accountId, metricTypes, timeRange = 'hour') {
|
||||
const results = {};
|
||||
const now = new Date();
|
||||
|
||||
for (const metricType of metricTypes) {
|
||||
const data = await this.getMetricData(accountId, metricType, timeRange, now);
|
||||
results[metricType] = data;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// Get metric data for specific time range
|
||||
async getMetricData(accountId, metricType, timeRange, endTime) {
|
||||
const ranges = {
|
||||
minute: { count: 60, unit: 60000 }, // Last 60 minutes
|
||||
hour: { count: 24, unit: 3600000 }, // Last 24 hours
|
||||
day: { count: 30, unit: 86400000 } // Last 30 days
|
||||
};
|
||||
|
||||
const range = ranges[timeRange] || ranges.hour;
|
||||
const dataPoints = [];
|
||||
|
||||
for (let i = 0; i < range.count; i++) {
|
||||
const timestamp = new Date(endTime.getTime() - (i * range.unit));
|
||||
const key = this.getRedisKey(accountId, metricType, timeRange, timestamp);
|
||||
|
||||
const data = await redisClient.hgetall(key);
|
||||
dataPoints.unshift({
|
||||
timestamp,
|
||||
count: parseInt(data.count || 0),
|
||||
sum: parseFloat(data.sum || 0),
|
||||
avg: data.count ? parseFloat(data.sum || 0) / parseInt(data.count) : 0
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
timeRange,
|
||||
dataPoints,
|
||||
summary: this.calculateSummary(dataPoints)
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate summary statistics
|
||||
calculateSummary(dataPoints) {
|
||||
const totalCount = dataPoints.reduce((sum, dp) => sum + dp.count, 0);
|
||||
const totalSum = dataPoints.reduce((sum, dp) => sum + dp.sum, 0);
|
||||
const nonZeroPoints = dataPoints.filter(dp => dp.count > 0);
|
||||
|
||||
return {
|
||||
total: totalCount,
|
||||
sum: totalSum,
|
||||
average: totalCount > 0 ? totalSum / totalCount : 0,
|
||||
min: Math.min(...nonZeroPoints.map(dp => dp.avg)),
|
||||
max: Math.max(...nonZeroPoints.map(dp => dp.avg)),
|
||||
trend: this.calculateTrend(dataPoints)
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate trend
|
||||
calculateTrend(dataPoints) {
|
||||
if (dataPoints.length < 2) return 0;
|
||||
|
||||
const halfPoint = Math.floor(dataPoints.length / 2);
|
||||
const firstHalf = dataPoints.slice(0, halfPoint);
|
||||
const secondHalf = dataPoints.slice(halfPoint);
|
||||
|
||||
const firstAvg = firstHalf.reduce((sum, dp) => sum + dp.count, 0) / firstHalf.length;
|
||||
const secondAvg = secondHalf.reduce((sum, dp) => sum + dp.count, 0) / secondHalf.length;
|
||||
|
||||
if (firstAvg === 0) return secondAvg > 0 ? 100 : 0;
|
||||
return ((secondAvg - firstAvg) / firstAvg) * 100;
|
||||
}
|
||||
|
||||
// Get funnel analytics
|
||||
async getFunnelAnalytics(accountId, funnelSteps, timeRange = 'day') {
|
||||
const funnelData = [];
|
||||
|
||||
for (let i = 0; i < funnelSteps.length; i++) {
|
||||
const step = funnelSteps[i];
|
||||
const metrics = await this.getRealtimeMetrics(accountId, [step.metric], timeRange);
|
||||
const total = metrics[step.metric].summary.total;
|
||||
|
||||
funnelData.push({
|
||||
step: step.name,
|
||||
metric: step.metric,
|
||||
count: total,
|
||||
percentage: i === 0 ? 100 : (total / funnelData[0].count) * 100,
|
||||
dropoff: i === 0 ? 0 : ((funnelData[i-1].count - total) / funnelData[i-1].count) * 100
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
steps: funnelData,
|
||||
overallConversion: funnelData.length > 0 ?
|
||||
(funnelData[funnelData.length - 1].count / funnelData[0].count) * 100 : 0
|
||||
};
|
||||
}
|
||||
|
||||
// Get cohort analytics
|
||||
async getCohortAnalytics(accountId, cohortType, metricType, periods = 7) {
|
||||
const cohorts = [];
|
||||
const now = new Date();
|
||||
|
||||
for (let i = 0; i < periods; i++) {
|
||||
const cohortDate = new Date(now);
|
||||
cohortDate.setDate(cohortDate.getDate() - i);
|
||||
|
||||
const cohortKey = `cohort:${accountId}:${cohortType}:${cohortDate.toDateString()}`;
|
||||
const cohortUsers = await redisClient.smembers(cohortKey);
|
||||
|
||||
const retention = [];
|
||||
for (let j = 0; j <= i; j++) {
|
||||
const checkDate = new Date(cohortDate);
|
||||
checkDate.setDate(checkDate.getDate() + j);
|
||||
|
||||
let activeCount = 0;
|
||||
for (const userId of cohortUsers) {
|
||||
const activityKey = `activity:${accountId}:${userId}:${checkDate.toDateString()}`;
|
||||
const isActive = await redisClient.exists(activityKey);
|
||||
if (isActive) activeCount++;
|
||||
}
|
||||
|
||||
retention.push({
|
||||
day: j,
|
||||
count: activeCount,
|
||||
percentage: cohortUsers.length > 0 ? (activeCount / cohortUsers.length) * 100 : 0
|
||||
});
|
||||
}
|
||||
|
||||
cohorts.push({
|
||||
cohortDate,
|
||||
size: cohortUsers.length,
|
||||
retention
|
||||
});
|
||||
}
|
||||
|
||||
return cohorts;
|
||||
}
|
||||
|
||||
// Get real-time dashboard data
|
||||
async getDashboardData(accountId) {
|
||||
const metrics = [
|
||||
'messages_sent',
|
||||
'messages_delivered',
|
||||
'messages_read',
|
||||
'conversions',
|
||||
'revenue',
|
||||
'active_users',
|
||||
'new_users'
|
||||
];
|
||||
|
||||
const [realtime, hourly, daily] = await Promise.all([
|
||||
this.getRealtimeMetrics(accountId, metrics, 'minute'),
|
||||
this.getRealtimeMetrics(accountId, metrics, 'hour'),
|
||||
this.getRealtimeMetrics(accountId, metrics, 'day')
|
||||
]);
|
||||
|
||||
// Calculate key performance indicators
|
||||
const kpis = {
|
||||
deliveryRate: this.calculateRate(realtime.messages_delivered, realtime.messages_sent),
|
||||
readRate: this.calculateRate(realtime.messages_read, realtime.messages_delivered),
|
||||
conversionRate: this.calculateRate(realtime.conversions, realtime.messages_delivered),
|
||||
avgRevenue: realtime.revenue.summary.average,
|
||||
activeUserGrowth: realtime.active_users.summary.trend,
|
||||
newUserGrowth: realtime.new_users.summary.trend
|
||||
};
|
||||
|
||||
return {
|
||||
realtime,
|
||||
hourly,
|
||||
daily,
|
||||
kpis,
|
||||
timestamp: new Date()
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate rate between two metrics
|
||||
calculateRate(numeratorMetric, denominatorMetric) {
|
||||
const numerator = numeratorMetric?.summary?.total || 0;
|
||||
const denominator = denominatorMetric?.summary?.total || 0;
|
||||
return denominator > 0 ? (numerator / denominator) * 100 : 0;
|
||||
}
|
||||
|
||||
// Update in-memory metrics
|
||||
updateInMemoryMetrics(accountId, metricType, value) {
|
||||
const key = `${accountId}:${metricType}`;
|
||||
if (!this.metrics.has(key)) {
|
||||
this.metrics.set(key, {
|
||||
count: 0,
|
||||
sum: 0,
|
||||
lastUpdate: new Date()
|
||||
});
|
||||
}
|
||||
|
||||
const metric = this.metrics.get(key);
|
||||
metric.count++;
|
||||
metric.sum += value;
|
||||
metric.lastUpdate = new Date();
|
||||
}
|
||||
|
||||
// Start aggregation process
|
||||
startAggregation() {
|
||||
setInterval(async () => {
|
||||
try {
|
||||
await this.aggregateMetrics();
|
||||
} catch (error) {
|
||||
logger.error('Error in metric aggregation:', error);
|
||||
}
|
||||
}, this.aggregationInterval);
|
||||
}
|
||||
|
||||
// Aggregate metrics to database
|
||||
async aggregateMetrics() {
|
||||
const now = new Date();
|
||||
const startOfDay = new Date(now.setHours(0, 0, 0, 0));
|
||||
|
||||
for (const [key, metric] of this.metrics) {
|
||||
const [accountId, metricType] = key.split(':');
|
||||
|
||||
// Update daily analytics in MongoDB
|
||||
await Analytics.findOneAndUpdate(
|
||||
{
|
||||
accountId,
|
||||
date: startOfDay
|
||||
},
|
||||
{
|
||||
$inc: {
|
||||
[`${metricType}`]: metric.count
|
||||
}
|
||||
},
|
||||
{
|
||||
upsert: true,
|
||||
new: true
|
||||
}
|
||||
);
|
||||
|
||||
// Reset in-memory counter
|
||||
metric.count = 0;
|
||||
metric.sum = 0;
|
||||
}
|
||||
|
||||
// Clean up old metrics
|
||||
for (const [key, metric] of this.metrics) {
|
||||
if (now - metric.lastUpdate > this.retentionPeriod) {
|
||||
this.metrics.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get Redis key
|
||||
getRedisKey(accountId, metricType, timeRange, timestamp) {
|
||||
let timeKey;
|
||||
|
||||
switch (timeRange) {
|
||||
case 'minute':
|
||||
timeKey = new Date(Math.floor(timestamp.getTime() / 60000) * 60000).getTime();
|
||||
break;
|
||||
case 'hour':
|
||||
timeKey = new Date(timestamp).setMinutes(0, 0, 0);
|
||||
break;
|
||||
case 'day':
|
||||
timeKey = timestamp.toDateString();
|
||||
break;
|
||||
default:
|
||||
timeKey = timestamp.getTime();
|
||||
}
|
||||
|
||||
return `realtime:${accountId}:${metricType}:${timeRange}:${timeKey}`;
|
||||
}
|
||||
|
||||
// Stream metrics for WebSocket
|
||||
streamMetrics(accountId, metricTypes, callback) {
|
||||
const listener = (data) => {
|
||||
if (data.accountId === accountId && metricTypes.includes(data.metricType)) {
|
||||
callback(data);
|
||||
}
|
||||
};
|
||||
|
||||
this.on('metric', listener);
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
this.off('metric', listener);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
export const realtimeAnalytics = new RealtimeAnalyticsService();
|
||||
@@ -0,0 +1,563 @@
|
||||
import PDFDocument from 'pdfkit';
|
||||
import ExcelJS from 'exceljs';
|
||||
import { ChartJSNodeCanvas } from 'chartjs-node-canvas';
|
||||
import { Analytics } from '../models/Analytics.js';
|
||||
import { realtimeAnalytics } from './realtimeAnalytics.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
|
||||
export class ReportGeneratorService {
|
||||
constructor() {
|
||||
this.chartRenderer = new ChartJSNodeCanvas({
|
||||
width: 800,
|
||||
height: 400,
|
||||
backgroundColour: 'white'
|
||||
});
|
||||
}
|
||||
|
||||
// Generate comprehensive report
|
||||
async generateReport(accountId, reportType, options = {}) {
|
||||
const {
|
||||
startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
|
||||
endDate = new Date(),
|
||||
format = 'pdf',
|
||||
includeCharts = true,
|
||||
includeRawData = false
|
||||
} = options;
|
||||
|
||||
logger.info(`Generating ${reportType} report for account ${accountId}`);
|
||||
|
||||
// Gather data
|
||||
const reportData = await this.gatherReportData(accountId, reportType, startDate, endDate);
|
||||
|
||||
// Generate report based on format
|
||||
let report;
|
||||
switch (format) {
|
||||
case 'pdf':
|
||||
report = await this.generatePDFReport(reportData, includeCharts);
|
||||
break;
|
||||
case 'excel':
|
||||
report = await this.generateExcelReport(reportData, includeCharts, includeRawData);
|
||||
break;
|
||||
case 'json':
|
||||
report = reportData;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported report format: ${format}`);
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
// Gather report data based on type
|
||||
async gatherReportData(accountId, reportType, startDate, endDate) {
|
||||
const baseData = {
|
||||
accountId,
|
||||
reportType,
|
||||
generatedAt: new Date(),
|
||||
period: {
|
||||
start: startDate,
|
||||
end: endDate
|
||||
}
|
||||
};
|
||||
|
||||
switch (reportType) {
|
||||
case 'campaign-performance':
|
||||
return {
|
||||
...baseData,
|
||||
...(await this.getCampaignPerformanceData(accountId, startDate, endDate))
|
||||
};
|
||||
|
||||
case 'engagement-analytics':
|
||||
return {
|
||||
...baseData,
|
||||
...(await this.getEngagementAnalyticsData(accountId, startDate, endDate))
|
||||
};
|
||||
|
||||
case 'revenue-analysis':
|
||||
return {
|
||||
...baseData,
|
||||
...(await this.getRevenueAnalysisData(accountId, startDate, endDate))
|
||||
};
|
||||
|
||||
case 'user-behavior':
|
||||
return {
|
||||
...baseData,
|
||||
...(await this.getUserBehaviorData(accountId, startDate, endDate))
|
||||
};
|
||||
|
||||
case 'executive-summary':
|
||||
return {
|
||||
...baseData,
|
||||
...(await this.getExecutiveSummaryData(accountId, startDate, endDate))
|
||||
};
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown report type: ${reportType}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Get campaign performance data
|
||||
async getCampaignPerformanceData(accountId, startDate, endDate) {
|
||||
const analytics = await Analytics.find({
|
||||
accountId,
|
||||
date: { $gte: startDate, $lte: endDate }
|
||||
}).sort({ date: 1 });
|
||||
|
||||
// Aggregate by campaign
|
||||
const campaignMetrics = {};
|
||||
|
||||
analytics.forEach(day => {
|
||||
day.campaignBreakdown.forEach(campaign => {
|
||||
if (!campaignMetrics[campaign.campaignId]) {
|
||||
campaignMetrics[campaign.campaignId] = {
|
||||
campaignId: campaign.campaignId,
|
||||
campaignName: campaign.campaignName,
|
||||
messagesSent: 0,
|
||||
messagesDelivered: 0,
|
||||
messagesRead: 0,
|
||||
conversions: 0,
|
||||
revenue: 0,
|
||||
days: []
|
||||
};
|
||||
}
|
||||
|
||||
const metrics = campaignMetrics[campaign.campaignId];
|
||||
metrics.messagesSent += campaign.messagesSent || 0;
|
||||
metrics.messagesDelivered += campaign.messagesDelivered || 0;
|
||||
metrics.messagesRead += campaign.messagesRead || 0;
|
||||
metrics.conversions += campaign.conversions || 0;
|
||||
metrics.revenue += campaign.revenue || 0;
|
||||
|
||||
metrics.days.push({
|
||||
date: day.date,
|
||||
...campaign
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Calculate rates and rankings
|
||||
const campaigns = Object.values(campaignMetrics).map(campaign => ({
|
||||
...campaign,
|
||||
deliveryRate: campaign.messagesSent > 0 ?
|
||||
(campaign.messagesDelivered / campaign.messagesSent) * 100 : 0,
|
||||
readRate: campaign.messagesDelivered > 0 ?
|
||||
(campaign.messagesRead / campaign.messagesDelivered) * 100 : 0,
|
||||
conversionRate: campaign.messagesDelivered > 0 ?
|
||||
(campaign.conversions / campaign.messagesDelivered) * 100 : 0,
|
||||
avgRevenue: campaign.conversions > 0 ?
|
||||
campaign.revenue / campaign.conversions : 0
|
||||
}));
|
||||
|
||||
// Sort by revenue
|
||||
campaigns.sort((a, b) => b.revenue - a.revenue);
|
||||
|
||||
return {
|
||||
campaigns,
|
||||
topPerformers: campaigns.slice(0, 5),
|
||||
underperformers: campaigns.filter(c => c.conversionRate < 1).slice(-5),
|
||||
summary: {
|
||||
totalCampaigns: campaigns.length,
|
||||
totalMessagesSent: campaigns.reduce((sum, c) => sum + c.messagesSent, 0),
|
||||
totalConversions: campaigns.reduce((sum, c) => sum + c.conversions, 0),
|
||||
totalRevenue: campaigns.reduce((sum, c) => sum + c.revenue, 0),
|
||||
avgConversionRate: campaigns.length > 0 ?
|
||||
campaigns.reduce((sum, c) => sum + c.conversionRate, 0) / campaigns.length : 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Get engagement analytics data
|
||||
async getEngagementAnalyticsData(accountId, startDate, endDate) {
|
||||
const hourlyData = await realtimeAnalytics.getRealtimeMetrics(
|
||||
accountId,
|
||||
['messages_sent', 'messages_delivered', 'messages_read', 'active_users'],
|
||||
'hour'
|
||||
);
|
||||
|
||||
// Time-based engagement patterns
|
||||
const engagementByHour = new Array(24).fill(0).map((_, hour) => ({
|
||||
hour,
|
||||
sent: 0,
|
||||
delivered: 0,
|
||||
read: 0,
|
||||
activeUsers: 0
|
||||
}));
|
||||
|
||||
hourlyData.messages_sent.dataPoints.forEach((point, index) => {
|
||||
const hour = new Date(point.timestamp).getHours();
|
||||
engagementByHour[hour].sent += point.count;
|
||||
});
|
||||
|
||||
hourlyData.messages_delivered.dataPoints.forEach((point, index) => {
|
||||
const hour = new Date(point.timestamp).getHours();
|
||||
engagementByHour[hour].delivered += point.count;
|
||||
});
|
||||
|
||||
hourlyData.messages_read.dataPoints.forEach((point, index) => {
|
||||
const hour = new Date(point.timestamp).getHours();
|
||||
engagementByHour[hour].read += point.count;
|
||||
});
|
||||
|
||||
// Best engagement times
|
||||
const bestEngagementTimes = engagementByHour
|
||||
.map((data, hour) => ({
|
||||
hour,
|
||||
readRate: data.delivered > 0 ? (data.read / data.delivered) * 100 : 0
|
||||
}))
|
||||
.sort((a, b) => b.readRate - a.readRate)
|
||||
.slice(0, 3);
|
||||
|
||||
return {
|
||||
hourlyEngagement: engagementByHour,
|
||||
bestEngagementTimes,
|
||||
engagementTrends: {
|
||||
daily: hourlyData.messages_read.summary.trend,
|
||||
weekly: await this.calculateWeeklyTrend(accountId, 'messages_read', startDate, endDate)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Get revenue analysis data
|
||||
async getRevenueAnalysisData(accountId, startDate, endDate) {
|
||||
const analytics = await Analytics.find({
|
||||
accountId,
|
||||
date: { $gte: startDate, $lte: endDate }
|
||||
}).sort({ date: 1 });
|
||||
|
||||
const dailyRevenue = analytics.map(day => ({
|
||||
date: day.date,
|
||||
revenue: day.revenue,
|
||||
conversions: day.conversions,
|
||||
avgOrderValue: day.conversions > 0 ? day.revenue / day.conversions : 0
|
||||
}));
|
||||
|
||||
// Revenue by source
|
||||
const revenueBySource = {};
|
||||
analytics.forEach(day => {
|
||||
day.campaignBreakdown.forEach(campaign => {
|
||||
const source = campaign.campaignName.split('-')[0]; // Extract source from campaign name
|
||||
if (!revenueBySource[source]) {
|
||||
revenueBySource[source] = { revenue: 0, conversions: 0 };
|
||||
}
|
||||
revenueBySource[source].revenue += campaign.revenue || 0;
|
||||
revenueBySource[source].conversions += campaign.conversions || 0;
|
||||
});
|
||||
});
|
||||
|
||||
// Calculate growth metrics
|
||||
const firstWeekRevenue = dailyRevenue.slice(0, 7).reduce((sum, day) => sum + day.revenue, 0);
|
||||
const lastWeekRevenue = dailyRevenue.slice(-7).reduce((sum, day) => sum + day.revenue, 0);
|
||||
const revenueGrowth = firstWeekRevenue > 0 ?
|
||||
((lastWeekRevenue - firstWeekRevenue) / firstWeekRevenue) * 100 : 0;
|
||||
|
||||
return {
|
||||
dailyRevenue,
|
||||
revenueBySource: Object.entries(revenueBySource).map(([source, data]) => ({
|
||||
source,
|
||||
...data,
|
||||
avgOrderValue: data.conversions > 0 ? data.revenue / data.conversions : 0
|
||||
})),
|
||||
summary: {
|
||||
totalRevenue: dailyRevenue.reduce((sum, day) => sum + day.revenue, 0),
|
||||
totalConversions: dailyRevenue.reduce((sum, day) => sum + day.conversions, 0),
|
||||
avgOrderValue: dailyRevenue.length > 0 ?
|
||||
dailyRevenue.reduce((sum, day) => sum + day.avgOrderValue, 0) / dailyRevenue.length : 0,
|
||||
revenueGrowth,
|
||||
projectedMonthlyRevenue: lastWeekRevenue * 4.33
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Get user behavior data
|
||||
async getUserBehaviorData(accountId, startDate, endDate) {
|
||||
// Get cohort analysis
|
||||
const cohorts = await realtimeAnalytics.getCohortAnalytics(
|
||||
accountId,
|
||||
'new_users',
|
||||
'active_users',
|
||||
7
|
||||
);
|
||||
|
||||
// Get funnel analysis
|
||||
const funnel = await realtimeAnalytics.getFunnelAnalytics(
|
||||
accountId,
|
||||
[
|
||||
{ name: 'Message Sent', metric: 'messages_sent' },
|
||||
{ name: 'Message Delivered', metric: 'messages_delivered' },
|
||||
{ name: 'Message Read', metric: 'messages_read' },
|
||||
{ name: 'Conversion', metric: 'conversions' }
|
||||
],
|
||||
'day'
|
||||
);
|
||||
|
||||
// User segments
|
||||
const segments = await this.getUserSegments(accountId, startDate, endDate);
|
||||
|
||||
return {
|
||||
cohorts,
|
||||
funnel,
|
||||
segments,
|
||||
behaviorPatterns: {
|
||||
avgSessionsPerUser: 3.2, // This would come from actual session tracking
|
||||
avgTimeToConversion: '2.5 days',
|
||||
mostActiveTimeOfDay: '14:00-16:00',
|
||||
preferredChannels: ['telegram', 'whatsapp']
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Get executive summary data
|
||||
async getExecutiveSummaryData(accountId, startDate, endDate) {
|
||||
const [campaign, engagement, revenue, behavior] = await Promise.all([
|
||||
this.getCampaignPerformanceData(accountId, startDate, endDate),
|
||||
this.getEngagementAnalyticsData(accountId, startDate, endDate),
|
||||
this.getRevenueAnalysisData(accountId, startDate, endDate),
|
||||
this.getUserBehaviorData(accountId, startDate, endDate)
|
||||
]);
|
||||
|
||||
return {
|
||||
keyMetrics: {
|
||||
totalRevenue: revenue.summary.totalRevenue,
|
||||
totalConversions: revenue.summary.totalConversions,
|
||||
avgConversionRate: campaign.summary.avgConversionRate,
|
||||
revenueGrowth: revenue.summary.revenueGrowth,
|
||||
totalCampaigns: campaign.summary.totalCampaigns,
|
||||
activeUsers: behavior.segments.active
|
||||
},
|
||||
highlights: [
|
||||
`Revenue grew by ${revenue.summary.revenueGrowth.toFixed(1)}% over the period`,
|
||||
`Top performing campaign generated $${campaign.topPerformers[0]?.revenue.toFixed(2) || 0}`,
|
||||
`Best engagement time is ${engagement.bestEngagementTimes[0]?.hour || 0}:00`,
|
||||
`Overall conversion rate: ${campaign.summary.avgConversionRate.toFixed(2)}%`
|
||||
],
|
||||
recommendations: this.generateRecommendations(campaign, engagement, revenue, behavior)
|
||||
};
|
||||
}
|
||||
|
||||
// Generate PDF report
|
||||
async generatePDFReport(data, includeCharts) {
|
||||
const doc = new PDFDocument({ margin: 50 });
|
||||
const chunks = [];
|
||||
|
||||
doc.on('data', chunk => chunks.push(chunk));
|
||||
|
||||
// Title page
|
||||
doc.fontSize(24).text(data.reportType.replace('-', ' ').toUpperCase(), { align: 'center' });
|
||||
doc.fontSize(14).text(`Generated on ${data.generatedAt.toLocaleDateString()}`, { align: 'center' });
|
||||
doc.moveDown(2);
|
||||
|
||||
// Executive summary
|
||||
if (data.keyMetrics) {
|
||||
doc.fontSize(18).text('Executive Summary');
|
||||
doc.moveDown();
|
||||
|
||||
Object.entries(data.keyMetrics).forEach(([key, value]) => {
|
||||
doc.fontSize(12).text(`${this.formatKey(key)}: ${this.formatValue(value)}`);
|
||||
});
|
||||
doc.moveDown();
|
||||
}
|
||||
|
||||
// Add charts if requested
|
||||
if (includeCharts && data.dailyRevenue) {
|
||||
const chartBuffer = await this.generateChart('revenue', data.dailyRevenue);
|
||||
doc.addPage();
|
||||
doc.image(chartBuffer, 50, 50, { width: 500 });
|
||||
}
|
||||
|
||||
// Detailed sections based on report type
|
||||
this.addDetailedSections(doc, data);
|
||||
|
||||
doc.end();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
doc.on('end', () => {
|
||||
resolve(Buffer.concat(chunks));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Generate Excel report
|
||||
async generateExcelReport(data, includeCharts, includeRawData) {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
|
||||
// Summary sheet
|
||||
const summarySheet = workbook.addWorksheet('Summary');
|
||||
this.addSummarySheet(summarySheet, data);
|
||||
|
||||
// Add data sheets based on report type
|
||||
if (data.campaigns) {
|
||||
const campaignSheet = workbook.addWorksheet('Campaigns');
|
||||
this.addCampaignSheet(campaignSheet, data.campaigns);
|
||||
}
|
||||
|
||||
if (data.dailyRevenue) {
|
||||
const revenueSheet = workbook.addWorksheet('Revenue');
|
||||
this.addRevenueSheet(revenueSheet, data.dailyRevenue);
|
||||
}
|
||||
|
||||
if (includeRawData) {
|
||||
const rawDataSheet = workbook.addWorksheet('Raw Data');
|
||||
this.addRawDataSheet(rawDataSheet, data);
|
||||
}
|
||||
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
return buffer;
|
||||
}
|
||||
|
||||
// Generate chart
|
||||
async generateChart(type, data) {
|
||||
const configuration = {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: data.map(d => new Date(d.date).toLocaleDateString()),
|
||||
datasets: [{
|
||||
label: 'Revenue',
|
||||
data: data.map(d => d.revenue),
|
||||
borderColor: 'rgb(75, 192, 192)',
|
||||
backgroundColor: 'rgba(75, 192, 192, 0.2)'
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top',
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Revenue Over Time'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return await this.chartRenderer.renderToBuffer(configuration);
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
formatKey(key) {
|
||||
return key.replace(/([A-Z])/g, ' $1').replace(/^./, str => str.toUpperCase());
|
||||
}
|
||||
|
||||
formatValue(value) {
|
||||
if (typeof value === 'number') {
|
||||
if (value >= 1000000) return `$${(value / 1000000).toFixed(2)}M`;
|
||||
if (value >= 1000) return `$${(value / 1000).toFixed(2)}K`;
|
||||
if (value < 100 && value % 1 !== 0) return value.toFixed(2);
|
||||
return value.toString();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async calculateWeeklyTrend(accountId, metric, startDate, endDate) {
|
||||
// Implementation would calculate week-over-week trend
|
||||
return 15.3; // Placeholder
|
||||
}
|
||||
|
||||
async getUserSegments(accountId, startDate, endDate) {
|
||||
// Implementation would segment users based on behavior
|
||||
return {
|
||||
active: 1250,
|
||||
inactive: 320,
|
||||
highValue: 180,
|
||||
atRisk: 95
|
||||
};
|
||||
}
|
||||
|
||||
generateRecommendations(campaign, engagement, revenue, behavior) {
|
||||
const recommendations = [];
|
||||
|
||||
if (campaign.summary.avgConversionRate < 2) {
|
||||
recommendations.push('Consider A/B testing message content to improve conversion rates');
|
||||
}
|
||||
|
||||
if (engagement.bestEngagementTimes[0]) {
|
||||
recommendations.push(`Schedule campaigns around ${engagement.bestEngagementTimes[0].hour}:00 for optimal engagement`);
|
||||
}
|
||||
|
||||
if (revenue.summary.revenueGrowth < 10) {
|
||||
recommendations.push('Explore new customer segments to accelerate revenue growth');
|
||||
}
|
||||
|
||||
if (behavior.funnel.steps[1].dropoff > 20) {
|
||||
recommendations.push('Improve message delivery by verifying contact information');
|
||||
}
|
||||
|
||||
return recommendations;
|
||||
}
|
||||
|
||||
addDetailedSections(doc, data) {
|
||||
// Add detailed sections based on available data
|
||||
if (data.campaigns) {
|
||||
doc.addPage();
|
||||
doc.fontSize(18).text('Campaign Performance');
|
||||
doc.moveDown();
|
||||
|
||||
data.topPerformers.forEach(campaign => {
|
||||
doc.fontSize(14).text(campaign.campaignName);
|
||||
doc.fontSize(10).text(`Revenue: $${campaign.revenue.toFixed(2)}`);
|
||||
doc.fontSize(10).text(`Conversion Rate: ${campaign.conversionRate.toFixed(2)}%`);
|
||||
doc.moveDown();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
addSummarySheet(sheet, data) {
|
||||
sheet.columns = [
|
||||
{ header: 'Metric', key: 'metric', width: 30 },
|
||||
{ header: 'Value', key: 'value', width: 20 }
|
||||
];
|
||||
|
||||
if (data.keyMetrics) {
|
||||
Object.entries(data.keyMetrics).forEach(([key, value]) => {
|
||||
sheet.addRow({ metric: this.formatKey(key), value: this.formatValue(value) });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
addCampaignSheet(sheet, campaigns) {
|
||||
sheet.columns = [
|
||||
{ header: 'Campaign Name', key: 'campaignName', width: 30 },
|
||||
{ header: 'Messages Sent', key: 'messagesSent', width: 15 },
|
||||
{ header: 'Delivered', key: 'messagesDelivered', width: 15 },
|
||||
{ header: 'Read', key: 'messagesRead', width: 15 },
|
||||
{ header: 'Conversions', key: 'conversions', width: 15 },
|
||||
{ header: 'Revenue', key: 'revenue', width: 15 },
|
||||
{ header: 'Conv Rate %', key: 'conversionRate', width: 15 }
|
||||
];
|
||||
|
||||
campaigns.forEach(campaign => {
|
||||
sheet.addRow(campaign);
|
||||
});
|
||||
}
|
||||
|
||||
addRevenueSheet(sheet, revenueData) {
|
||||
sheet.columns = [
|
||||
{ header: 'Date', key: 'date', width: 15 },
|
||||
{ header: 'Revenue', key: 'revenue', width: 15 },
|
||||
{ header: 'Conversions', key: 'conversions', width: 15 },
|
||||
{ header: 'Avg Order Value', key: 'avgOrderValue', width: 20 }
|
||||
];
|
||||
|
||||
revenueData.forEach(day => {
|
||||
sheet.addRow({
|
||||
date: new Date(day.date).toLocaleDateString(),
|
||||
revenue: day.revenue,
|
||||
conversions: day.conversions,
|
||||
avgOrderValue: day.avgOrderValue
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
addRawDataSheet(sheet, data) {
|
||||
sheet.addRow(['Raw Data Export']);
|
||||
sheet.addRow([`Generated: ${new Date().toISOString()}`]);
|
||||
sheet.addRow([]);
|
||||
sheet.addRow([JSON.stringify(data, null, 2)]);
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
export const reportGenerator = new ReportGeneratorService();
|
||||
@@ -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'
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import Redis from 'ioredis';
|
||||
import config from '../config/index.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
// Create Redis client
|
||||
export const redisClient = new Redis({
|
||||
host: config.redis.host,
|
||||
port: config.redis.port,
|
||||
password: config.redis.password,
|
||||
retryStrategy: (times) => {
|
||||
const delay = Math.min(times * 50, 2000);
|
||||
return delay;
|
||||
}
|
||||
});
|
||||
|
||||
// Handle connection events
|
||||
redisClient.on('connect', () => {
|
||||
logger.info('Connected to Redis');
|
||||
});
|
||||
|
||||
redisClient.on('error', (error) => {
|
||||
logger.error('Redis connection error:', error);
|
||||
});
|
||||
|
||||
redisClient.on('close', () => {
|
||||
logger.warn('Redis connection closed');
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
export const cache = {
|
||||
// Get value with JSON parsing
|
||||
async get(key) {
|
||||
const value = await redisClient.get(key);
|
||||
if (value) {
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
// Set value with JSON stringification
|
||||
async set(key, value, ttl) {
|
||||
const stringValue = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
if (ttl) {
|
||||
return await redisClient.setex(key, ttl, stringValue);
|
||||
}
|
||||
return await redisClient.set(key, stringValue);
|
||||
},
|
||||
|
||||
// Delete key
|
||||
async del(key) {
|
||||
return await redisClient.del(key);
|
||||
},
|
||||
|
||||
// Check if key exists
|
||||
async exists(key) {
|
||||
return await redisClient.exists(key);
|
||||
},
|
||||
|
||||
// Clear all keys with pattern
|
||||
async clearPattern(pattern) {
|
||||
const keys = await redisClient.keys(pattern);
|
||||
if (keys.length > 0) {
|
||||
return await redisClient.del(...keys);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user