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

View File

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

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

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

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