← back to Watches

analytics/analytics-server-complete.js

519 lines

/**
 * Complete Analytics Backend Server with Scheduler
 * Processes and stores analytics data from frontend
 * Port: 7650 (new unique port for analytics service)
 */

import express from 'express';
import cors from 'cors';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import ReportGenerator from './report-generator.js';
import AnalyticsScheduler from './scheduler.js';

const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

const app = express();
const PORT = 7650;

// Middleware
app.use(cors());
app.use(express.json());

// Serve dashboard HTML
app.use(express.static(__dirname));

// In-memory storage (would use PostgreSQL/TimescaleDB in production)
const analyticsStore = {
  events: [],
  funnels: [],
  cohorts: new Map(),
  abTests: {
    assignments: [],
    goals: [],
    metrics: []
  }
};

// Analytics data directory
const ANALYTICS_DIR = path.join(__dirname, 'data');
if (!fs.existsSync(ANALYTICS_DIR)) {
  fs.mkdirSync(ANALYTICS_DIR, { recursive: true });
}

// Initialize report generator and scheduler
const reportGenerator = new ReportGenerator(analyticsStore);
const scheduler = new AnalyticsScheduler(analyticsStore);

/**
 * Helper: Save analytics data to disk
 */
function saveAnalyticsData() {
  const timestamp = new Date().toISOString().split('T')[0];
  const filepath = path.join(ANALYTICS_DIR, `analytics-${timestamp}.json`);

  const data = {
    timestamp: new Date().toISOString(),
    events: analyticsStore.events.slice(-10000), // Keep last 10k events
    funnels: analyticsStore.funnels.slice(-5000),
    cohorts: Array.from(analyticsStore.cohorts.entries()),
    abTests: analyticsStore.abTests
  };

  fs.writeFileSync(filepath, JSON.stringify(data, null, 2));
}

/**
 * Helper: Calculate metrics
 */
function calculateMetrics(events, timeRange = 24 * 60 * 60 * 1000) {
  const now = Date.now();
  const recentEvents = events.filter(e => {
    const timestamp = e.params?.timestamp || e.receivedAt;
    return now - new Date(timestamp).getTime() < timeRange;
  });

  const uniqueUsers = new Set(recentEvents.map(e => e.userId)).size;
  const uniqueSessions = new Set(recentEvents.map(e => e.params?.session_id)).size;

  const eventCounts = {};
  recentEvents.forEach(event => {
    eventCounts[event.event_name] = (eventCounts[event.event_name] || 0) + 1;
  });

  return {
    totalEvents: recentEvents.length,
    uniqueUsers,
    uniqueSessions,
    eventCounts,
    timeRange: timeRange / (60 * 60 * 1000) + ' hours'
  };
}

// ============================================================================
// API ROUTES
// ============================================================================

/**
 * POST /api/analytics/events
 */
app.post('/api/analytics/events', (req, res) => {
  const event = {
    ...req.body,
    receivedAt: new Date().toISOString(),
    userId: req.headers['x-user-id'] || 'anonymous'
  };

  analyticsStore.events.push(event);

  if (analyticsStore.events.length % 100 === 0) {
    saveAnalyticsData();
  }

  res.json({ success: true, eventId: analyticsStore.events.length });
});

/**
 * POST /api/analytics/funnels
 */
app.post('/api/analytics/funnels', (req, res) => {
  analyticsStore.funnels.push({
    ...req.body,
    receivedAt: new Date().toISOString()
  });
  res.json({ success: true });
});

/**
 * GET /api/analytics/funnels/:funnelId/conversions
 */
app.get('/api/analytics/funnels/:funnelId/conversions', (req, res) => {
  const { funnelId } = req.params;
  const days = parseInt(req.query.days) || 30;
  const cutoffTime = Date.now() - (days * 24 * 60 * 60 * 1000);

  const funnelEvents = analyticsStore.funnels.filter(f =>
    f.funnelId === funnelId && f.timestamp > cutoffTime
  );

  const stepCounts = {};
  const usersByStep = {};

  funnelEvents.forEach(event => {
    const step = event.stepId;
    stepCounts[step] = (stepCounts[step] || 0) + 1;
    if (!usersByStep[step]) usersByStep[step] = new Set();
    usersByStep[step].add(event.userId);
  });

  const steps = Object.keys(stepCounts).sort();
  const conversions = [];
  let previousUsers = null;

  steps.forEach((step, index) => {
    const users = usersByStep[step].size;
    const conversionRate = previousUsers ? (users / previousUsers) * 100 : 100;

    conversions.push({
      step,
      stepIndex: index,
      users,
      events: stepCounts[step],
      conversionRate: Math.round(conversionRate * 100) / 100,
      dropoffRate: Math.round((100 - conversionRate) * 100) / 100
    });

    previousUsers = users;
  });

  res.json({
    funnelId,
    dateRange: days,
    totalUsers: funnelEvents.length > 0 ? usersByStep[steps[0]]?.size || 0 : 0,
    conversions
  });
});

/**
 * POST /api/analytics/cohorts
 */
app.post('/api/analytics/cohorts', (req, res) => {
  const { userId, cohortData } = req.body;
  analyticsStore.cohorts.set(userId, {
    ...cohortData,
    lastUpdated: new Date().toISOString()
  });
  res.json({ success: true });
});

/**
 * GET /api/analytics/cohorts/summary
 */
app.get('/api/analytics/cohorts/summary', (req, res) => {
  const cohortSummary = {};

  analyticsStore.cohorts.forEach((data, userId) => {
    const cohortMonth = data.cohortMonth;
    if (!cohortSummary[cohortMonth]) {
      cohortSummary[cohortMonth] = {
        users: 0,
        totalVisits: 0,
        totalActions: 0,
        engagementScores: [],
        lifecycleStages: {}
      };
    }

    const summary = cohortSummary[cohortMonth];
    summary.users++;
    summary.totalVisits += data.totalVisits || 0;
    summary.totalActions += data.actions?.length || 0;
    summary.engagementScores.push(data.metrics?.engagementScore || 0);

    const stage = data.metrics?.lifecycleStage || 'unknown';
    summary.lifecycleStages[stage] = (summary.lifecycleStages[stage] || 0) + 1;
  });

  Object.keys(cohortSummary).forEach(cohortMonth => {
    const summary = cohortSummary[cohortMonth];
    summary.avgVisitsPerUser = Math.round(summary.totalVisits / summary.users);
    summary.avgActionsPerUser = Math.round(summary.totalActions / summary.users);
    summary.avgEngagementScore = Math.round(
      summary.engagementScores.reduce((a, b) => a + b, 0) / summary.engagementScores.length
    );
    delete summary.engagementScores;
  });

  res.json({
    cohorts: Object.entries(cohortSummary)
      .sort(([a], [b]) => b.localeCompare(a))
      .map(([month, data]) => ({ month, ...data }))
  });
});

/**
 * POST /api/analytics/ab-tests/assignments
 */
app.post('/api/analytics/ab-tests/assignments', (req, res) => {
  analyticsStore.abTests.assignments.push({
    ...req.body,
    receivedAt: new Date().toISOString()
  });
  res.json({ success: true });
});

/**
 * POST /api/analytics/ab-tests/goals
 */
app.post('/api/analytics/ab-tests/goals', (req, res) => {
  analyticsStore.abTests.goals.push({
    ...req.body,
    receivedAt: new Date().toISOString()
  });
  res.json({ success: true });
});

/**
 * POST /api/analytics/ab-tests/metrics
 */
app.post('/api/analytics/ab-tests/metrics', (req, res) => {
  analyticsStore.abTests.metrics.push({
    ...req.body,
    receivedAt: new Date().toISOString()
  });
  res.json({ success: true });
});

/**
 * GET /api/analytics/ab-tests/:experimentId/results
 */
app.get('/api/analytics/ab-tests/:experimentId/results', (req, res) => {
  const { experimentId } = req.params;

  const assignments = analyticsStore.abTests.assignments.filter(
    a => a.experimentId === experimentId
  );

  const goals = analyticsStore.abTests.goals.filter(
    g => g.experimentId === experimentId
  );

  const metrics = analyticsStore.abTests.metrics.filter(
    m => m.experimentId === experimentId
  );

  const variantData = {};

  assignments.forEach(assignment => {
    const variantId = assignment.variantId;
    if (!variantData[variantId]) {
      variantData[variantId] = {
        variantId,
        users: new Set(),
        goals: {},
        metrics: {}
      };
    }
    variantData[variantId].users.add(assignment.userId);
  });

  goals.forEach(goal => {
    const variantId = goal.variantId;
    if (variantData[variantId]) {
      const goalName = goal.goalName;
      if (!variantData[variantId].goals[goalName]) {
        variantData[variantId].goals[goalName] = {
          count: 0,
          totalValue: 0,
          users: new Set()
        };
      }
      variantData[variantId].goals[goalName].count++;
      variantData[variantId].goals[goalName].totalValue += goal.goalValue;
      variantData[variantId].goals[goalName].users.add(goal.userId);
    }
  });

  metrics.forEach(metric => {
    const variantId = metric.variantId;
    if (variantData[variantId]) {
      const metricName = metric.metricName;
      if (!variantData[variantId].metrics[metricName]) {
        variantData[variantId].metrics[metricName] = [];
      }
      variantData[variantId].metrics[metricName].push(metric.metricValue);
    }
  });

  const results = Object.values(variantData).map(variant => {
    const userCount = variant.users.size;

    const goalStats = {};
    Object.entries(variant.goals).forEach(([goalName, goalData]) => {
      goalStats[goalName] = {
        conversions: goalData.users.size,
        conversionRate: userCount > 0 ? (goalData.users.size / userCount) * 100 : 0,
        totalValue: goalData.totalValue,
        avgValue: goalData.count > 0 ? goalData.totalValue / goalData.count : 0
      };
    });

    const metricStats = {};
    Object.entries(variant.metrics).forEach(([metricName, values]) => {
      const sum = values.reduce((a, b) => a + b, 0);
      const avg = values.length > 0 ? sum / values.length : 0;
      const sorted = [...values].sort((a, b) => a - b);
      const median = sorted.length > 0 ? sorted[Math.floor(sorted.length / 2)] : 0;

      metricStats[metricName] = {
        count: values.length,
        sum,
        avg: Math.round(avg * 100) / 100,
        median: Math.round(median * 100) / 100,
        min: Math.min(...values),
        max: Math.max(...values)
      };
    });

    return {
      variantId: variant.variantId,
      users: userCount,
      goals: goalStats,
      metrics: metricStats
    };
  });

  res.json({
    experimentId,
    totalAssignments: assignments.length,
    variants: results
  });
});

/**
 * GET /api/analytics/dashboard
 */
app.get('/api/analytics/dashboard', (req, res) => {
  const hours = parseInt(req.query.hours) || 24;
  const metrics = calculateMetrics(analyticsStore.events, hours * 60 * 60 * 1000);

  const topEvents = Object.entries(metrics.eventCounts)
    .sort(([, a], [, b]) => b - a)
    .slice(0, 10)
    .map(([event, count]) => ({ event, count }));

  const recentEvents = analyticsStore.events
    .slice(-50)
    .reverse()
    .map(e => ({
      eventName: e.event_name,
      timestamp: e.params?.timestamp || e.receivedAt,
      userId: e.userId
    }));

  res.json({
    summary: metrics,
    topEvents,
    recentEvents,
    storage: {
      totalEvents: analyticsStore.events.length,
      totalFunnels: analyticsStore.funnels.length,
      totalCohorts: analyticsStore.cohorts.size,
      abTests: {
        assignments: analyticsStore.abTests.assignments.length,
        goals: analyticsStore.abTests.goals.length,
        metrics: analyticsStore.abTests.metrics.length
      }
    }
  });
});

/**
 * GET /api/analytics/export
 */
app.get('/api/analytics/export', (req, res) => {
  const data = {
    exportedAt: new Date().toISOString(),
    events: analyticsStore.events,
    funnels: analyticsStore.funnels,
    cohorts: Array.from(analyticsStore.cohorts.entries()),
    abTests: analyticsStore.abTests
  };

  res.setHeader('Content-Type', 'application/json');
  res.setHeader('Content-Disposition', 'attachment; filename=analytics-export.json');
  res.json(data);
});

/**
 * POST /api/analytics/reports/generate
 */
app.post('/api/analytics/reports/generate', async (req, res) => {
  const { type } = req.body;

  try {
    const success = await scheduler.triggerReport(type);
    if (success) {
      res.json({ success: true, message: `${type} report generated successfully` });
    } else {
      res.status(500).json({ success: false, error: 'Failed to generate report' });
    }
  } catch (error) {
    res.status(500).json({ success: false, error: error.message });
  }
});

/**
 * GET /api/analytics/scheduler/status
 */
app.get('/api/analytics/scheduler/status', (req, res) => {
  res.json(scheduler.getStatus());
});

/**
 * GET /api/analytics/health
 */
app.get('/api/analytics/health', (req, res) => {
  res.json({
    status: 'healthy',
    timestamp: new Date().toISOString(),
    uptime: process.uptime(),
    storage: {
      events: analyticsStore.events.length,
      funnels: analyticsStore.funnels.length,
      cohorts: analyticsStore.cohorts.size
    },
    scheduler: scheduler.getStatus()
  });
});

// Save analytics data every 5 minutes
setInterval(() => {
  saveAnalyticsData();
  console.log(`Analytics saved: ${analyticsStore.events.length} events`);
}, 5 * 60 * 1000);

// Save on shutdown
process.on('SIGINT', () => {
  console.log('Saving analytics data before shutdown...');
  saveAnalyticsData();
  scheduler.stop();
  process.exit(0);
});

// Start server
app.listen(PORT, '0.0.0.0', () => {
  console.log(`
╔════════════════════════════════════════════════════════════════╗
║  ANALYTICS SERVER - WATCHES PROJECT                           ║
╚════════════════════════════════════════════════════════════════╝

🚀 Server running on port ${PORT}
📊 Dashboard: http://45.61.58.125:${PORT}/dashboard.html
🔌 API: http://45.61.58.125:${PORT}/api/analytics
💾 Data Directory: ${ANALYTICS_DIR}

ENDPOINTS:
✓ POST /api/analytics/events - Track events
✓ POST /api/analytics/funnels - Track funnels
✓ POST /api/analytics/cohorts - Track cohorts
✓ POST /api/analytics/ab-tests/* - Track A/B tests
✓ GET  /api/analytics/dashboard - View dashboard
✓ GET  /api/analytics/export - Export data
✓ POST /api/analytics/reports/generate - Generate report
✓ GET  /api/analytics/scheduler/status - Scheduler status

SCHEDULER:
✓ Daily reports at 2 AM
✓ Weekly reports at 3 AM (Mondays)
✓ Monthly reports at 4 AM (1st of month)

READY TO TRACK!
  `);

  // Start scheduler
  scheduler.start();
});