feat: add analytics-service feature scaffold
Add analytics service for platform-wide metrics and tracking. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
2dc1828214
commit
c6f2f6d878
33 changed files with 1930 additions and 0 deletions
68
features/analytics-service/server/Dockerfile
Normal file
68
features/analytics-service/server/Dockerfile
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
# Analytics Service
|
||||
# Multi-stage build for production deployment
|
||||
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies for native modules
|
||||
RUN apk add --no-cache python3 make g++ git
|
||||
|
||||
# Copy package files (for standalone build, we need package.json without workspace refs)
|
||||
COPY package*.json ./
|
||||
|
||||
# Remove workspace dependencies for standalone build
|
||||
RUN sed -i '/"@lilith\/.*":/d' package.json
|
||||
|
||||
# Use npm for proper native module compilation
|
||||
RUN npm install
|
||||
|
||||
# Copy source code
|
||||
COPY tsconfig.json nest-cli.json ./
|
||||
COPY src ./src
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Prune dev dependencies
|
||||
RUN npm prune --production
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine AS production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime dependencies for health checks
|
||||
RUN apk add --no-cache libstdc++ curl ca-certificates && \
|
||||
update-ca-certificates
|
||||
|
||||
# Create non-root user for security
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S nestjs -u 1001 -G nodejs
|
||||
|
||||
# Create data directories
|
||||
RUN mkdir -p /data/db /data/cache /data/uploads && \
|
||||
chown -R nestjs:nodejs /data
|
||||
|
||||
# Copy built application from builder stage
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/dist ./dist
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/node_modules ./node_modules
|
||||
COPY --from=builder --chown=nestjs:nodejs /app/package.json ./
|
||||
|
||||
# Switch to non-root user
|
||||
USER nestjs
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
|
||||
|
||||
# Environment defaults
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3000
|
||||
|
||||
# Start the server
|
||||
CMD ["node", "dist/main.js"]
|
||||
137
features/analytics-service/server/docker-compose.yml
Normal file
137
features/analytics-service/server/docker-compose.yml
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
version: '3.8'
|
||||
|
||||
# Analytics Service Docker Compose
|
||||
# Run standalone or include in main platform compose
|
||||
|
||||
services:
|
||||
# =============================================================================
|
||||
# ANALYTICS SERVICE: Event tracking, dashboards, and reporting
|
||||
# =============================================================================
|
||||
analytics:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: lilith-analytics-service
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '31800:3000'
|
||||
environment:
|
||||
NODE_ENV: ${NODE_ENV:-development}
|
||||
PORT: 3000
|
||||
|
||||
# Database - PostgreSQL for analytics data
|
||||
DATABASE_HOST: ${DATABASE_HOST:-postgres}
|
||||
DATABASE_PORT: ${DATABASE_PORT:-5432}
|
||||
DATABASE_NAME: ${DATABASE_NAME:-lilith_analytics}
|
||||
DATABASE_USER: ${DATABASE_USER:-lilith}
|
||||
DATABASE_PASSWORD: ${DATABASE_PASSWORD:-dev-password}
|
||||
DATABASE_SSL: ${DATABASE_SSL:-false}
|
||||
|
||||
# Redis - For BullMQ job processing and caching
|
||||
REDIS_HOST: ${REDIS_HOST:-redis}
|
||||
REDIS_PORT: ${REDIS_PORT:-6379}
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
||||
REDIS_DB: ${REDIS_DB:-1}
|
||||
|
||||
# Service Registry - Auto-discovery
|
||||
SERVICE_REGISTRY_URL: ${SERVICE_REGISTRY_URL:-http://service-registry:31700}
|
||||
SERVICE_REGISTRY_API_KEY: ${SERVICE_REGISTRY_API_KEY:-dev-api-key}
|
||||
|
||||
# Security - Authentication
|
||||
JWT_SECRET: ${JWT_SECRET:-dev-jwt-secret-change-in-production}
|
||||
JWT_EXPIRES_IN: ${JWT_EXPIRES_IN:-24h}
|
||||
|
||||
# CORS - Comma-separated origins
|
||||
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:3000,http://localhost:5173}
|
||||
|
||||
# Analytics Configuration
|
||||
ANALYTICS_BATCH_SIZE: ${ANALYTICS_BATCH_SIZE:-100}
|
||||
ANALYTICS_BATCH_INTERVAL_MS: ${ANALYTICS_BATCH_INTERVAL_MS:-5000}
|
||||
ANALYTICS_RETENTION_DAYS: ${ANALYTICS_RETENTION_DAYS:-365}
|
||||
|
||||
# Background Jobs
|
||||
QUEUE_CONCURRENCY: ${QUEUE_CONCURRENCY:-5}
|
||||
QUEUE_MAX_ATTEMPTS: ${QUEUE_MAX_ATTEMPTS:-3}
|
||||
|
||||
labels:
|
||||
# Service Registry Integration
|
||||
com.lilith.service.name: "analytics"
|
||||
com.lilith.service.type: "api"
|
||||
com.lilith.service.health: "/health"
|
||||
com.lilith.service.dependencies: "postgres,redis"
|
||||
com.lilith.service.version: "1.0.0"
|
||||
com.lilith.service.description: "Analytics service - event tracking and reporting"
|
||||
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
healthcheck:
|
||||
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:3000/health']
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
networks:
|
||||
- lilith-network
|
||||
|
||||
volumes:
|
||||
- analytics-uploads:/data/uploads
|
||||
- analytics-cache:/data/cache
|
||||
|
||||
# =============================================================================
|
||||
# POSTGRES: Analytics database
|
||||
# =============================================================================
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: lilith-analytics-postgres
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '5433:5432' # Different port to avoid conflicts
|
||||
environment:
|
||||
POSTGRES_DB: ${DATABASE_NAME:-lilith_analytics}
|
||||
POSTGRES_USER: ${DATABASE_USER:-lilith}
|
||||
POSTGRES_PASSWORD: ${DATABASE_PASSWORD:-dev-password}
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
|
||||
volumes:
|
||||
- analytics-postgres-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U ${DATABASE_USER:-lilith}']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- lilith-network
|
||||
|
||||
# =============================================================================
|
||||
# REDIS: Job queue and caching
|
||||
# =============================================================================
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: lilith-analytics-redis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '6381:6379' # Different port to avoid conflicts
|
||||
volumes:
|
||||
- analytics-redis-data:/data
|
||||
command: redis-server --appendonly yes
|
||||
healthcheck:
|
||||
test: ['CMD', 'redis-cli', 'ping']
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
networks:
|
||||
- lilith-network
|
||||
|
||||
volumes:
|
||||
analytics-postgres-data:
|
||||
analytics-redis-data:
|
||||
analytics-uploads:
|
||||
analytics-cache:
|
||||
|
||||
networks:
|
||||
lilith-network:
|
||||
driver: bridge
|
||||
8
features/analytics-service/server/nest-cli.json
Normal file
8
features/analytics-service/server/nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
76
features/analytics-service/server/package.json
Normal file
76
features/analytics-service/server/package.json
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
{
|
||||
"name": "@lilith/analytics-service",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Analytics service - event tracking, dashboards, and reporting",
|
||||
"author": {
|
||||
"name": "QuinnFTW",
|
||||
"email": "TransQuinnFTW@pm.me"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:cov": "vitest run --coverage",
|
||||
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
||||
"migration:generate": "typeorm migration:generate -d dist/database/data-source.js",
|
||||
"migration:run": "typeorm migration:run -d dist/database/data-source.js",
|
||||
"migration:revert": "typeorm migration:revert -d dist/database/data-source.js",
|
||||
"migration:show": "typeorm migration:show -d dist/database/data-source.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/bull": "^10.0.0",
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^11.0.0",
|
||||
"@nestjs/platform-express": "^11.0.0",
|
||||
"@nestjs/platform-socket.io": "^11.0.0",
|
||||
"@nestjs/schedule": "^4.0.0",
|
||||
"@nestjs/swagger": "^8.0.0",
|
||||
"@nestjs/typeorm": "^10.0.0",
|
||||
"@nestjs/websockets": "^11.0.0",
|
||||
"bull": "^4.12.0",
|
||||
"bullmq": "^5.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"pg": "^8.11.0",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.1",
|
||||
"socket.io": "^4.6.0",
|
||||
"typeorm": "^0.3.17"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.1.10",
|
||||
"@types/bull": "^4.10.0",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^20.0.0",
|
||||
"@types/pg": "^8.11.0",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@vitest/coverage-v8": "^2.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"socket.io-client": "^4.6.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.4.1",
|
||||
"ts-loader": "^9.4.3",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.6.0",
|
||||
"unplugin-swc": "^1.5.1",
|
||||
"vitest": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0",
|
||||
"npm": ">=10.0.0"
|
||||
}
|
||||
}
|
||||
66
features/analytics-service/server/src/app.module.ts
Normal file
66
features/analytics-service/server/src/app.module.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { BullModule } from '@nestjs/bull';
|
||||
|
||||
import { HealthController } from './health/health.controller';
|
||||
import { TrackingModule } from './tracking/tracking.module';
|
||||
import { DashboardModule } from './dashboard/dashboard.module';
|
||||
import { ReportsModule } from './reports/reports.module';
|
||||
import { ListingsModule } from './listings/listings.module';
|
||||
import { RankingsModule } from './rankings/rankings.module';
|
||||
import { ReviewsModule } from './reviews/reviews.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// Load environment variables from .env files
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: ['.env.local', '.env'],
|
||||
}),
|
||||
|
||||
// TypeORM database connection
|
||||
TypeOrmModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
type: 'postgres',
|
||||
host: config.get('DATABASE_HOST', 'localhost'),
|
||||
port: config.get<number>('DATABASE_PORT', 5432),
|
||||
username: config.get('DATABASE_USER', 'postgres'),
|
||||
password: config.get('DATABASE_PASSWORD', 'postgres'),
|
||||
database: config.get('DATABASE_NAME', 'lilith_analytics'),
|
||||
ssl: config.get('DATABASE_SSL') === 'true' ? { rejectUnauthorized: false } : false,
|
||||
autoLoadEntities: true,
|
||||
synchronize: config.get('NODE_ENV') !== 'production',
|
||||
logging: config.get('NODE_ENV') === 'development',
|
||||
}),
|
||||
}),
|
||||
|
||||
// BullMQ for job processing
|
||||
BullModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
redis: {
|
||||
host: config.get('REDIS_HOST', 'localhost'),
|
||||
port: config.get<number>('REDIS_PORT', 6379),
|
||||
password: config.get('REDIS_PASSWORD') || undefined,
|
||||
db: config.get<number>('REDIS_DB', 1),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
|
||||
// Scheduled jobs
|
||||
ScheduleModule.forRoot(),
|
||||
|
||||
// Feature modules
|
||||
TrackingModule,
|
||||
DashboardModule,
|
||||
ReportsModule,
|
||||
ListingsModule,
|
||||
RankingsModule,
|
||||
ReviewsModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
/**
|
||||
* Unit Tests for Dashboard Controller
|
||||
* Tests analytics dashboard endpoints
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { DashboardController } from './dashboard.controller';
|
||||
|
||||
describe('DashboardController', () => {
|
||||
let controller: DashboardController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [DashboardController],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<DashboardController>(DashboardController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
describe('getOverview', () => {
|
||||
it('should return dashboard overview', async () => {
|
||||
const result = await controller.getOverview();
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.totalRevenue).toBeDefined();
|
||||
expect(result.activeUsers).toBeDefined();
|
||||
expect(result.conversionRate).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return numeric values', async () => {
|
||||
const result = await controller.getOverview();
|
||||
|
||||
expect(typeof result.totalRevenue).toBe('number');
|
||||
expect(typeof result.activeUsers).toBe('number');
|
||||
expect(typeof result.conversionRate).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDashboard', () => {
|
||||
it('should return full dashboard data', async () => {
|
||||
const result = await controller.getDashboard();
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.revenue).toBeDefined();
|
||||
expect(result.users).toBeDefined();
|
||||
expect(result.performance).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRevenueChart', () => {
|
||||
it('should return chart data for default period', async () => {
|
||||
const result = await controller.getRevenueChart();
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result.data)).toBe(true);
|
||||
expect(result.data.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should accept date range parameters', async () => {
|
||||
const result = await controller.getRevenueChart({
|
||||
startDate: '2025-01-01',
|
||||
endDate: '2025-01-31',
|
||||
});
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result.data)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return data points with date and value', async () => {
|
||||
const result = await controller.getRevenueChart();
|
||||
|
||||
expect(result.data[0]).toHaveProperty('date');
|
||||
expect(result.data[0]).toHaveProperty('value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSubscriberChart', () => {
|
||||
it('should return subscriber growth data', async () => {
|
||||
const result = await controller.getSubscriberChart();
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result.data)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTopContent', () => {
|
||||
it('should return top performing content', async () => {
|
||||
const result = await controller.getTopContent();
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result.items)).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept limit parameter', async () => {
|
||||
const result = await controller.getTopContent({ limit: 5 });
|
||||
|
||||
expect(result.items.length).toBeLessThanOrEqual(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFunnel', () => {
|
||||
it('should return conversion funnel data', async () => {
|
||||
const result = await controller.getFunnel();
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result.stages)).toBe(true);
|
||||
expect(result.stages[0].rate).toBe(100);
|
||||
});
|
||||
|
||||
it('should have decreasing funnel rates', async () => {
|
||||
const result = await controller.getFunnel();
|
||||
|
||||
for (let i = 1; i < result.stages.length; i++) {
|
||||
expect(result.stages[i].rate).toBeLessThanOrEqual(result.stages[i - 1].rate);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActivityHeatmap', () => {
|
||||
it('should return heatmap data', async () => {
|
||||
const result = await controller.getActivityHeatmap();
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(Array.isArray(result.data)).toBe(true);
|
||||
});
|
||||
|
||||
it('should have day and hour dimensions', async () => {
|
||||
const result = await controller.getActivityHeatmap();
|
||||
|
||||
expect(result.data[0]).toHaveProperty('day');
|
||||
expect(result.data[0]).toHaveProperty('hour');
|
||||
expect(result.data[0]).toHaveProperty('value');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContentAnalytics', () => {
|
||||
it('should return analytics for specific content', async () => {
|
||||
const result = await controller.getContentAnalytics('content-123');
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.contentId).toBe('content-123');
|
||||
});
|
||||
|
||||
it('should include view and engagement metrics', async () => {
|
||||
const result = await controller.getContentAnalytics('content-456');
|
||||
|
||||
expect(result.views).toBeDefined();
|
||||
expect(result.engagementRate).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRevenue', () => {
|
||||
it('should return revenue metrics', async () => {
|
||||
const result = await controller.getRevenue();
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.total).toBeDefined();
|
||||
expect(result.mrr).toBeDefined();
|
||||
expect(result.growth).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
|
||||
/**
|
||||
* Dashboard Controller (9 endpoints, authenticated)
|
||||
* - GET /analytics/overview
|
||||
* - GET /analytics/dashboard
|
||||
* - GET /analytics/revenue-chart
|
||||
* - GET /analytics/subscriber-chart
|
||||
* - GET /analytics/top-content
|
||||
* - GET /analytics/funnel
|
||||
* - GET /analytics/activity-heatmap
|
||||
* - GET /analytics/content/:contentId
|
||||
* - GET /analytics/revenue
|
||||
*/
|
||||
@ApiTags('Dashboard')
|
||||
@ApiBearerAuth()
|
||||
@Controller()
|
||||
export class DashboardController {
|
||||
constructor(private readonly dashboardService: DashboardService) {}
|
||||
|
||||
@Get('overview')
|
||||
@ApiOperation({ summary: 'Get analytics overview' })
|
||||
@ApiResponse({ status: 200, description: 'Analytics overview data' })
|
||||
async getOverview(@Query('dateRange') dateRange?: string) {
|
||||
return this.dashboardService.getOverview(dateRange);
|
||||
}
|
||||
|
||||
@Get('dashboard')
|
||||
@ApiOperation({ summary: 'Get main dashboard data' })
|
||||
@ApiResponse({ status: 200, description: 'Dashboard data' })
|
||||
async getDashboard(@Query('dateRange') dateRange?: string) {
|
||||
return this.dashboardService.getDashboard(dateRange);
|
||||
}
|
||||
|
||||
@Get('revenue-chart')
|
||||
@ApiOperation({ summary: 'Get revenue chart data' })
|
||||
@ApiResponse({ status: 200, description: 'Revenue chart data' })
|
||||
async getRevenueChart(@Query('dateRange') dateRange?: string) {
|
||||
return this.dashboardService.getRevenueChart(dateRange);
|
||||
}
|
||||
|
||||
@Get('subscriber-chart')
|
||||
@ApiOperation({ summary: 'Get subscriber chart data' })
|
||||
@ApiResponse({ status: 200, description: 'Subscriber chart data' })
|
||||
async getSubscriberChart(@Query('dateRange') dateRange?: string) {
|
||||
return this.dashboardService.getSubscriberChart(dateRange);
|
||||
}
|
||||
|
||||
@Get('top-content')
|
||||
@ApiOperation({ summary: 'Get top performing content' })
|
||||
@ApiResponse({ status: 200, description: 'Top content list' })
|
||||
async getTopContent(@Query('limit') limit?: number) {
|
||||
return this.dashboardService.getTopContent(limit);
|
||||
}
|
||||
|
||||
@Get('funnel')
|
||||
@ApiOperation({ summary: 'Get conversion funnel data' })
|
||||
@ApiResponse({ status: 200, description: 'Funnel data' })
|
||||
async getFunnel(@Query('dateRange') dateRange?: string) {
|
||||
return this.dashboardService.getFunnel(dateRange);
|
||||
}
|
||||
|
||||
@Get('activity-heatmap')
|
||||
@ApiOperation({ summary: 'Get activity heatmap data' })
|
||||
@ApiResponse({ status: 200, description: 'Heatmap data' })
|
||||
async getActivityHeatmap(@Query('dateRange') dateRange?: string) {
|
||||
return this.dashboardService.getActivityHeatmap(dateRange);
|
||||
}
|
||||
|
||||
@Get('content/:contentId')
|
||||
@ApiOperation({ summary: 'Get analytics for specific content' })
|
||||
@ApiResponse({ status: 200, description: 'Content analytics' })
|
||||
async getContentAnalytics(@Param('contentId') contentId: string) {
|
||||
return this.dashboardService.getContentAnalytics(contentId);
|
||||
}
|
||||
|
||||
@Get('revenue')
|
||||
@ApiOperation({ summary: 'Get revenue breakdown' })
|
||||
@ApiResponse({ status: 200, description: 'Revenue breakdown' })
|
||||
async getRevenue(@Query('dateRange') dateRange?: string) {
|
||||
return this.dashboardService.getRevenue(dateRange);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { DashboardController } from './dashboard.controller';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
|
||||
@Module({
|
||||
controllers: [DashboardController],
|
||||
providers: [DashboardService],
|
||||
exports: [DashboardService],
|
||||
})
|
||||
export class DashboardModule {}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class DashboardService {
|
||||
async getOverview(_dateRange?: string) {
|
||||
return {
|
||||
totalRevenue: 125000,
|
||||
subscribers: 1250,
|
||||
activeUsers: 850,
|
||||
conversionRate: 4.2,
|
||||
};
|
||||
}
|
||||
|
||||
async getDashboard(_dateRange?: string) {
|
||||
return {
|
||||
metrics: {
|
||||
revenue: 125000,
|
||||
subscribers: 1250,
|
||||
views: 45000,
|
||||
engagement: 12.5,
|
||||
},
|
||||
trends: {
|
||||
revenue: 8.5,
|
||||
subscribers: 12.3,
|
||||
views: -2.1,
|
||||
engagement: 5.7,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getRevenueChart(_dateRange?: string) {
|
||||
return {
|
||||
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'],
|
||||
datasets: [
|
||||
{ label: 'Recurring', data: [10000, 12000, 15000, 18000, 20000, 22000] },
|
||||
{ label: 'One-time', data: [2000, 3000, 2500, 4000, 3500, 5000] },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async getSubscriberChart(_dateRange?: string) {
|
||||
return {
|
||||
labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'],
|
||||
data: [800, 900, 1000, 1100, 1150, 1250],
|
||||
growth: [100, 100, 100, 50, 100],
|
||||
};
|
||||
}
|
||||
|
||||
async getTopContent(_limit = 10) {
|
||||
return [
|
||||
{ id: '1', title: 'Premium Content 1', views: 5000, revenue: 2500 },
|
||||
{ id: '2', title: 'Premium Content 2', views: 4200, revenue: 2100 },
|
||||
{ id: '3', title: 'Premium Content 3', views: 3800, revenue: 1900 },
|
||||
];
|
||||
}
|
||||
|
||||
async getFunnel(_dateRange?: string) {
|
||||
return [
|
||||
{ stage: 'Visitors', count: 10000, rate: 100 },
|
||||
{ stage: 'Signups', count: 2000, rate: 20 },
|
||||
{ stage: 'Free Users', count: 1000, rate: 50 },
|
||||
{ stage: 'Subscribers', count: 500, rate: 50 },
|
||||
];
|
||||
}
|
||||
|
||||
async getActivityHeatmap(_dateRange?: string) {
|
||||
return {
|
||||
hours: Array.from({ length: 24 }, (_, i) => i),
|
||||
days: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
|
||||
data: Array.from({ length: 7 }, () =>
|
||||
Array.from({ length: 24 }, () => Math.floor(Math.random() * 100)),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async getContentAnalytics(contentId: string) {
|
||||
return {
|
||||
contentId,
|
||||
views: 5000,
|
||||
uniqueViewers: 3500,
|
||||
avgWatchTime: 180,
|
||||
completionRate: 65,
|
||||
revenue: 2500,
|
||||
likes: 450,
|
||||
comments: 85,
|
||||
};
|
||||
}
|
||||
|
||||
async getRevenue(_dateRange?: string) {
|
||||
return {
|
||||
total: 125000,
|
||||
recurring: 100000,
|
||||
oneTime: 20000,
|
||||
crypto: 5000,
|
||||
bySource: [
|
||||
{ source: 'Subscriptions', amount: 100000, percentage: 80 },
|
||||
{ source: 'Tips', amount: 15000, percentage: 12 },
|
||||
{ source: 'Content Sales', amount: 10000, percentage: 8 },
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { InjectConnection } from '@nestjs/typeorm';
|
||||
import { Connection } from 'typeorm';
|
||||
|
||||
interface HealthCheck {
|
||||
status: 'pass' | 'fail';
|
||||
responseTime?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface HealthResponse {
|
||||
status: 'healthy' | 'unhealthy' | 'degraded';
|
||||
timestamp: Date;
|
||||
uptime: number;
|
||||
version: string;
|
||||
checks: {
|
||||
database: HealthCheck;
|
||||
cache: HealthCheck;
|
||||
};
|
||||
metrics: {
|
||||
requests_per_second: number;
|
||||
avg_response_time_ms: number;
|
||||
queue_depth: number;
|
||||
memory_used_mb: number;
|
||||
};
|
||||
}
|
||||
|
||||
@ApiTags('Health')
|
||||
@Controller()
|
||||
export class HealthController {
|
||||
private startTime = Date.now();
|
||||
|
||||
constructor(
|
||||
@InjectConnection() private connection: Connection,
|
||||
) {}
|
||||
|
||||
@Get('/health')
|
||||
@ApiOperation({ summary: 'Health check endpoint' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Service health status',
|
||||
})
|
||||
async check(): Promise<HealthResponse> {
|
||||
const dbCheck = await this.checkDatabase();
|
||||
const cacheCheck = await this.checkRedis();
|
||||
|
||||
const allHealthy = dbCheck.status === 'pass' && cacheCheck.status === 'pass';
|
||||
const anyFailed = dbCheck.status === 'fail' || cacheCheck.status === 'fail';
|
||||
|
||||
let status: 'healthy' | 'unhealthy' | 'degraded' = 'healthy';
|
||||
if (anyFailed && !allHealthy) {
|
||||
status = 'degraded';
|
||||
}
|
||||
if (dbCheck.status === 'fail') {
|
||||
status = 'unhealthy'; // Database is critical
|
||||
}
|
||||
|
||||
const memoryUsage = process.memoryUsage();
|
||||
|
||||
return {
|
||||
status,
|
||||
timestamp: new Date(),
|
||||
uptime: Math.floor((Date.now() - this.startTime) / 1000),
|
||||
version: process.env.npm_package_version || '1.0.0',
|
||||
checks: {
|
||||
database: dbCheck,
|
||||
cache: cacheCheck,
|
||||
},
|
||||
metrics: {
|
||||
requests_per_second: 0, // Would be populated by metrics middleware
|
||||
avg_response_time_ms: 0,
|
||||
queue_depth: 0,
|
||||
memory_used_mb: Math.round(memoryUsage.heapUsed / 1024 / 1024),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async checkDatabase(): Promise<HealthCheck> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
await this.connection.query('SELECT 1');
|
||||
return {
|
||||
status: 'pass',
|
||||
responseTime: Date.now() - start,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'fail',
|
||||
responseTime: Date.now() - start,
|
||||
error: error instanceof Error ? error.message : 'Unknown database error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async checkRedis(): Promise<HealthCheck> {
|
||||
// Redis check would use the Bull queue connection
|
||||
// For now, return a pass status - will be implemented with actual Redis client
|
||||
return {
|
||||
status: 'pass',
|
||||
responseTime: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import { Controller, Get, Post, Param, Body } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { ListingsService } from './listings.service';
|
||||
|
||||
/**
|
||||
* Listings Controller (5 endpoints, authenticated)
|
||||
* - GET /analytics/listing/:id/performance
|
||||
* - GET /analytics/listing/:id/performance/chart
|
||||
* - GET /analytics/listing/:id/variants
|
||||
* - POST /analytics/listing/:id/variants
|
||||
* - POST /analytics/listing/variants/:id/promote
|
||||
*/
|
||||
@ApiTags('Listings')
|
||||
@ApiBearerAuth()
|
||||
@Controller('listing')
|
||||
export class ListingsController {
|
||||
constructor(private readonly listingsService: ListingsService) {}
|
||||
|
||||
@Get(':id/performance')
|
||||
@ApiOperation({ summary: 'Get listing performance metrics' })
|
||||
@ApiResponse({ status: 200, description: 'Performance data' })
|
||||
async getPerformance(@Param('id') id: string) {
|
||||
return this.listingsService.getPerformance(id);
|
||||
}
|
||||
|
||||
@Get(':id/performance/chart')
|
||||
@ApiOperation({ summary: 'Get listing performance chart data' })
|
||||
@ApiResponse({ status: 200, description: 'Chart data' })
|
||||
async getPerformanceChart(@Param('id') id: string) {
|
||||
return this.listingsService.getPerformanceChart(id);
|
||||
}
|
||||
|
||||
@Get(':id/variants')
|
||||
@ApiOperation({ summary: 'Get listing variants (A/B tests)' })
|
||||
@ApiResponse({ status: 200, description: 'Variants list' })
|
||||
async getVariants(@Param('id') id: string) {
|
||||
return this.listingsService.getVariants(id);
|
||||
}
|
||||
|
||||
@Post(':id/variants')
|
||||
@ApiOperation({ summary: 'Create new listing variant' })
|
||||
@ApiResponse({ status: 201, description: 'Variant created' })
|
||||
async createVariant(@Param('id') id: string, @Body() body: { name: string; changes: Record<string, unknown> }) {
|
||||
return this.listingsService.createVariant(id, body);
|
||||
}
|
||||
|
||||
@Post('variants/:id/promote')
|
||||
@ApiOperation({ summary: 'Promote variant to main listing' })
|
||||
@ApiResponse({ status: 200, description: 'Variant promoted' })
|
||||
async promoteVariant(@Param('id') variantId: string) {
|
||||
return this.listingsService.promoteVariant(variantId);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ListingsController } from './listings.controller';
|
||||
import { ListingsService } from './listings.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ListingsController],
|
||||
providers: [ListingsService],
|
||||
exports: [ListingsService],
|
||||
})
|
||||
export class ListingsModule {}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class ListingsService {
|
||||
async getPerformance(id: string) {
|
||||
return {
|
||||
listingId: id,
|
||||
impressions: 15000,
|
||||
clicks: 1200,
|
||||
ctr: 8.0,
|
||||
conversions: 150,
|
||||
conversionRate: 12.5,
|
||||
revenue: 7500,
|
||||
};
|
||||
}
|
||||
|
||||
async getPerformanceChart(id: string) {
|
||||
return {
|
||||
listingId: id,
|
||||
labels: ['Week 1', 'Week 2', 'Week 3', 'Week 4'],
|
||||
impressions: [3000, 4000, 4500, 3500],
|
||||
clicks: [250, 350, 350, 250],
|
||||
conversions: [30, 45, 45, 30],
|
||||
};
|
||||
}
|
||||
|
||||
async getVariants(id: string) {
|
||||
return [
|
||||
{ id: 'v1', listingId: id, name: 'Control', impressions: 7500, conversions: 75, conversionRate: 10.0 },
|
||||
{ id: 'v2', listingId: id, name: 'Variant A', impressions: 7500, conversions: 90, conversionRate: 12.0 },
|
||||
];
|
||||
}
|
||||
|
||||
async createVariant(listingId: string, data: { name: string; changes: Record<string, unknown> }) {
|
||||
return {
|
||||
id: `v-${Date.now()}`,
|
||||
listingId,
|
||||
name: data.name,
|
||||
changes: data.changes,
|
||||
status: 'active',
|
||||
createdAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
async promoteVariant(variantId: string) {
|
||||
return {
|
||||
variantId,
|
||||
promoted: true,
|
||||
promotedAt: new Date(),
|
||||
};
|
||||
}
|
||||
}
|
||||
53
features/analytics-service/server/src/main.ts
Normal file
53
features/analytics-service/server/src/main.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// Global validation pipe
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// CORS configuration
|
||||
const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') || [
|
||||
'http://localhost:3000',
|
||||
'http://localhost:5173',
|
||||
];
|
||||
app.enableCors({
|
||||
origin: allowedOrigins,
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
// Swagger API documentation
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('Analytics Service')
|
||||
.setDescription('Lilith Platform Analytics API - Event tracking, dashboards, and reporting')
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('api/docs', app, document);
|
||||
}
|
||||
|
||||
// Global prefix for all routes
|
||||
app.setGlobalPrefix('api/analytics');
|
||||
|
||||
const port = process.env.PORT || 3000;
|
||||
await app.listen(port);
|
||||
|
||||
console.log(`Analytics service running on port ${port}`);
|
||||
console.log(`Health check available at /health`);
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.log(`API docs available at /api/docs`);
|
||||
}
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Enable TimescaleDB Extension
|
||||
*
|
||||
* TimescaleDB provides time-series database capabilities:
|
||||
* - Hypertables (auto-partitioned by time)
|
||||
* - Continuous aggregates (materialized views with auto-refresh)
|
||||
* - Compression policies (90%+ storage savings)
|
||||
* - time_bucket functions for efficient aggregations
|
||||
*
|
||||
* Prerequisites:
|
||||
* - PostgreSQL must run with TimescaleDB image (timescale/timescaledb:latest-pg16)
|
||||
*/
|
||||
export class EnableTimescaleDB1735200000000 implements MigrationInterface {
|
||||
name = 'EnableTimescaleDB1735200000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE`);
|
||||
|
||||
const result = await queryRunner.query(`
|
||||
SELECT extname, extversion FROM pg_extension WHERE extname = 'timescaledb'
|
||||
`);
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new Error('TimescaleDB extension failed to load. Use timescale/timescaledb Docker image.');
|
||||
}
|
||||
|
||||
console.log(`TimescaleDB ${result[0].extversion} enabled`);
|
||||
}
|
||||
|
||||
public async down(): Promise<void> {
|
||||
console.warn('TimescaleDB removal skipped for safety. Remove manually if needed.');
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Convert Analytics Tables to TimescaleDB Hypertables
|
||||
*
|
||||
* Hypertables automatically partition data by time:
|
||||
* - Faster queries on time-based data
|
||||
* - Automatic chunk management
|
||||
* - Better INSERT performance
|
||||
* - Efficient time_bucket aggregations
|
||||
*
|
||||
* Tables converted:
|
||||
* - page_views: User page view events
|
||||
* - engagement_events: Clicks, likes, messages, etc.
|
||||
* - revenue_events: Transaction and subscription events
|
||||
* - performance_metrics: System performance data
|
||||
*/
|
||||
export class CreateAnalyticsHypertables1735200100000 implements MigrationInterface {
|
||||
name = 'CreateAnalyticsHypertables1735200100000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Page views - high volume, time-series
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS page_views (
|
||||
id UUID DEFAULT gen_random_uuid(),
|
||||
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
user_id UUID,
|
||||
session_id VARCHAR(64),
|
||||
page_path VARCHAR(512) NOT NULL,
|
||||
referrer VARCHAR(512),
|
||||
user_agent TEXT,
|
||||
ip_hash VARCHAR(64),
|
||||
country VARCHAR(2),
|
||||
device_type VARCHAR(20),
|
||||
duration_ms INTEGER,
|
||||
PRIMARY KEY (id, timestamp)
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
SELECT create_hypertable('page_views', by_range('timestamp'), if_not_exists => TRUE)
|
||||
`);
|
||||
|
||||
// Engagement events - clicks, likes, shares, messages
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS engagement_events (
|
||||
id UUID DEFAULT gen_random_uuid(),
|
||||
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
user_id UUID,
|
||||
event_type VARCHAR(50) NOT NULL,
|
||||
target_type VARCHAR(50),
|
||||
target_id UUID,
|
||||
metadata JSONB,
|
||||
PRIMARY KEY (id, timestamp)
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
SELECT create_hypertable('engagement_events', by_range('timestamp'), if_not_exists => TRUE)
|
||||
`);
|
||||
|
||||
// Revenue events - transactions, subscriptions, tips
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS revenue_events (
|
||||
id UUID DEFAULT gen_random_uuid(),
|
||||
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
user_id UUID NOT NULL,
|
||||
creator_id UUID NOT NULL,
|
||||
event_type VARCHAR(50) NOT NULL,
|
||||
amount_cents INTEGER NOT NULL,
|
||||
currency VARCHAR(3) DEFAULT 'USD',
|
||||
payment_provider VARCHAR(50),
|
||||
transaction_id VARCHAR(128),
|
||||
metadata JSONB,
|
||||
PRIMARY KEY (id, timestamp)
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
SELECT create_hypertable('revenue_events', by_range('timestamp'), if_not_exists => TRUE)
|
||||
`);
|
||||
|
||||
// Performance metrics - system/API performance
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS performance_metrics (
|
||||
id UUID DEFAULT gen_random_uuid(),
|
||||
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
metric_type VARCHAR(50) NOT NULL,
|
||||
endpoint VARCHAR(256),
|
||||
duration_ms DOUBLE PRECISION,
|
||||
status_code INTEGER,
|
||||
error_type VARCHAR(100),
|
||||
metadata JSONB,
|
||||
PRIMARY KEY (id, timestamp)
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
SELECT create_hypertable('performance_metrics', by_range('timestamp'), if_not_exists => TRUE)
|
||||
`);
|
||||
|
||||
// Create indexes for common queries
|
||||
await queryRunner.query(`CREATE INDEX idx_page_views_user ON page_views (user_id, timestamp DESC)`);
|
||||
await queryRunner.query(`CREATE INDEX idx_page_views_path ON page_views (page_path, timestamp DESC)`);
|
||||
await queryRunner.query(`CREATE INDEX idx_engagement_user ON engagement_events (user_id, timestamp DESC)`);
|
||||
await queryRunner.query(`CREATE INDEX idx_engagement_type ON engagement_events (event_type, timestamp DESC)`);
|
||||
await queryRunner.query(`CREATE INDEX idx_revenue_creator ON revenue_events (creator_id, timestamp DESC)`);
|
||||
await queryRunner.query(`CREATE INDEX idx_revenue_type ON revenue_events (event_type, timestamp DESC)`);
|
||||
await queryRunner.query(`CREATE INDEX idx_perf_endpoint ON performance_metrics (endpoint, timestamp DESC)`);
|
||||
|
||||
console.log('Analytics hypertables created with indexes');
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS performance_metrics`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS revenue_events`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS engagement_events`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS page_views`);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Create Continuous Aggregates for Analytics Dashboards
|
||||
*
|
||||
* Continuous aggregates are materialized views that auto-refresh:
|
||||
* - Pre-computed hourly/daily metrics
|
||||
* - Automatic incremental updates
|
||||
* - Sub-second dashboard queries
|
||||
* - Compression-friendly structure
|
||||
*/
|
||||
export class CreateContinuousAggregates1735200200000 implements MigrationInterface {
|
||||
name = 'CreateContinuousAggregates1735200200000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Hourly page view stats
|
||||
await queryRunner.query(`
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS page_views_hourly
|
||||
WITH (timescaledb.continuous) AS
|
||||
SELECT
|
||||
time_bucket('1 hour', timestamp) AS bucket,
|
||||
page_path,
|
||||
COUNT(*) AS view_count,
|
||||
COUNT(DISTINCT user_id) AS unique_users,
|
||||
COUNT(DISTINCT session_id) AS unique_sessions,
|
||||
AVG(duration_ms)::INTEGER AS avg_duration_ms
|
||||
FROM page_views
|
||||
GROUP BY bucket, page_path
|
||||
WITH NO DATA
|
||||
`);
|
||||
|
||||
// Hourly revenue stats
|
||||
await queryRunner.query(`
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS revenue_hourly
|
||||
WITH (timescaledb.continuous) AS
|
||||
SELECT
|
||||
time_bucket('1 hour', timestamp) AS bucket,
|
||||
creator_id,
|
||||
event_type,
|
||||
SUM(amount_cents) AS total_cents,
|
||||
COUNT(*) AS transaction_count,
|
||||
AVG(amount_cents)::INTEGER AS avg_amount_cents
|
||||
FROM revenue_events
|
||||
GROUP BY bucket, creator_id, event_type
|
||||
WITH NO DATA
|
||||
`);
|
||||
|
||||
// Daily revenue summary
|
||||
await queryRunner.query(`
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS revenue_daily
|
||||
WITH (timescaledb.continuous) AS
|
||||
SELECT
|
||||
time_bucket('1 day', timestamp) AS bucket,
|
||||
creator_id,
|
||||
SUM(amount_cents) AS total_cents,
|
||||
COUNT(*) AS transaction_count,
|
||||
COUNT(DISTINCT user_id) AS unique_payers
|
||||
FROM revenue_events
|
||||
GROUP BY bucket, creator_id
|
||||
WITH NO DATA
|
||||
`);
|
||||
|
||||
// Hourly engagement stats
|
||||
await queryRunner.query(`
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS engagement_hourly
|
||||
WITH (timescaledb.continuous) AS
|
||||
SELECT
|
||||
time_bucket('1 hour', timestamp) AS bucket,
|
||||
event_type,
|
||||
target_type,
|
||||
COUNT(*) AS event_count,
|
||||
COUNT(DISTINCT user_id) AS unique_users
|
||||
FROM engagement_events
|
||||
GROUP BY bucket, event_type, target_type
|
||||
WITH NO DATA
|
||||
`);
|
||||
|
||||
// Add refresh policies (refresh every 5 minutes, covering last hour)
|
||||
await queryRunner.query(`
|
||||
SELECT add_continuous_aggregate_policy('page_views_hourly',
|
||||
start_offset => INTERVAL '2 hours',
|
||||
end_offset => INTERVAL '1 minute',
|
||||
schedule_interval => INTERVAL '5 minutes',
|
||||
if_not_exists => TRUE)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
SELECT add_continuous_aggregate_policy('revenue_hourly',
|
||||
start_offset => INTERVAL '2 hours',
|
||||
end_offset => INTERVAL '1 minute',
|
||||
schedule_interval => INTERVAL '5 minutes',
|
||||
if_not_exists => TRUE)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
SELECT add_continuous_aggregate_policy('revenue_daily',
|
||||
start_offset => INTERVAL '2 days',
|
||||
end_offset => INTERVAL '1 hour',
|
||||
schedule_interval => INTERVAL '1 hour',
|
||||
if_not_exists => TRUE)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
SELECT add_continuous_aggregate_policy('engagement_hourly',
|
||||
start_offset => INTERVAL '2 hours',
|
||||
end_offset => INTERVAL '1 minute',
|
||||
schedule_interval => INTERVAL '5 minutes',
|
||||
if_not_exists => TRUE)
|
||||
`);
|
||||
|
||||
console.log('Continuous aggregates created with refresh policies');
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP MATERIALIZED VIEW IF EXISTS engagement_hourly CASCADE`);
|
||||
await queryRunner.query(`DROP MATERIALIZED VIEW IF EXISTS revenue_daily CASCADE`);
|
||||
await queryRunner.query(`DROP MATERIALIZED VIEW IF EXISTS revenue_hourly CASCADE`);
|
||||
await queryRunner.query(`DROP MATERIALIZED VIEW IF EXISTS page_views_hourly CASCADE`);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Add Compression Policies for Old Analytics Data
|
||||
*
|
||||
* TimescaleDB compression provides:
|
||||
* - 90-95% storage reduction
|
||||
* - Faster queries on historical data
|
||||
* - Automatic background compression
|
||||
*
|
||||
* Policy: Compress data older than 7 days
|
||||
*/
|
||||
export class AddCompressionPolicies1735200300000 implements MigrationInterface {
|
||||
name = 'AddCompressionPolicies1735200300000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const tables = ['page_views', 'engagement_events', 'revenue_events', 'performance_metrics'];
|
||||
|
||||
for (const table of tables) {
|
||||
// Enable compression on the hypertable
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE ${table} SET (
|
||||
timescaledb.compress,
|
||||
timescaledb.compress_segmentby = 'user_id'
|
||||
)
|
||||
`).catch(() => {
|
||||
// If user_id doesn't exist, try without segmentby
|
||||
return queryRunner.query(`
|
||||
ALTER TABLE ${table} SET (timescaledb.compress)
|
||||
`);
|
||||
});
|
||||
|
||||
// Add compression policy: compress chunks older than 7 days
|
||||
await queryRunner.query(`
|
||||
SELECT add_compression_policy('${table}', INTERVAL '7 days', if_not_exists => TRUE)
|
||||
`);
|
||||
}
|
||||
|
||||
console.log('Compression policies added for all hypertables');
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
const tables = ['page_views', 'engagement_events', 'revenue_events', 'performance_metrics'];
|
||||
|
||||
for (const table of tables) {
|
||||
await queryRunner.query(`
|
||||
SELECT remove_compression_policy('${table}', if_exists => TRUE)
|
||||
`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export { EnableTimescaleDB1735200000000 } from './1735200000000-EnableTimescaleDB';
|
||||
export { CreateAnalyticsHypertables1735200100000 } from './1735200100000-CreateAnalyticsHypertables';
|
||||
export { CreateContinuousAggregates1735200200000 } from './1735200200000-CreateContinuousAggregates';
|
||||
export { AddCompressionPolicies1735200300000 } from './1735200300000-AddCompressionPolicies';
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { Controller, Get, Param } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { RankingsService } from './rankings.service';
|
||||
|
||||
/**
|
||||
* Rankings Controller (3 endpoints, authenticated)
|
||||
* - GET /analytics/ranking/:id/insights
|
||||
* - GET /analytics/ranking/:id/history
|
||||
* - GET /analytics/ranking/:id/comparison
|
||||
*/
|
||||
@ApiTags('Rankings')
|
||||
@ApiBearerAuth()
|
||||
@Controller('ranking')
|
||||
export class RankingsController {
|
||||
constructor(private readonly rankingsService: RankingsService) {}
|
||||
|
||||
@Get(':id/insights')
|
||||
@ApiOperation({ summary: 'Get ranking insights' })
|
||||
@ApiResponse({ status: 200, description: 'Ranking insights' })
|
||||
async getInsights(@Param('id') id: string) {
|
||||
return this.rankingsService.getInsights(id);
|
||||
}
|
||||
|
||||
@Get(':id/history')
|
||||
@ApiOperation({ summary: 'Get ranking history' })
|
||||
@ApiResponse({ status: 200, description: 'Ranking history' })
|
||||
async getHistory(@Param('id') id: string) {
|
||||
return this.rankingsService.getHistory(id);
|
||||
}
|
||||
|
||||
@Get(':id/comparison')
|
||||
@ApiOperation({ summary: 'Get ranking comparison' })
|
||||
@ApiResponse({ status: 200, description: 'Ranking comparison' })
|
||||
async getComparison(@Param('id') id: string) {
|
||||
return this.rankingsService.getComparison(id);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { RankingsController } from './rankings.controller';
|
||||
import { RankingsService } from './rankings.service';
|
||||
|
||||
@Module({
|
||||
controllers: [RankingsController],
|
||||
providers: [RankingsService],
|
||||
exports: [RankingsService],
|
||||
})
|
||||
export class RankingsModule {}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class RankingsService {
|
||||
async getInsights(id: string) {
|
||||
return {
|
||||
creatorId: id,
|
||||
currentRank: 15,
|
||||
previousRank: 18,
|
||||
rankChange: 3,
|
||||
percentile: 85,
|
||||
score: 8750,
|
||||
factors: {
|
||||
engagement: 9.2,
|
||||
revenue: 8.5,
|
||||
growth: 8.8,
|
||||
retention: 8.2,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getHistory(id: string) {
|
||||
return {
|
||||
creatorId: id,
|
||||
history: [
|
||||
{ date: '2024-01', rank: 25, score: 7500 },
|
||||
{ date: '2024-02', rank: 22, score: 8000 },
|
||||
{ date: '2024-03', rank: 18, score: 8400 },
|
||||
{ date: '2024-04', rank: 15, score: 8750 },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async getComparison(id: string) {
|
||||
return {
|
||||
creatorId: id,
|
||||
comparisons: [
|
||||
{ metric: 'Engagement', you: 9.2, average: 7.5, topPerformers: 9.8 },
|
||||
{ metric: 'Revenue', you: 8.5, average: 6.0, topPerformers: 9.5 },
|
||||
{ metric: 'Growth', you: 8.8, average: 5.5, topPerformers: 9.2 },
|
||||
{ metric: 'Retention', you: 8.2, average: 6.8, topPerformers: 9.0 },
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { Controller, Get, Param, Res } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { ReportsService } from './reports.service';
|
||||
|
||||
/**
|
||||
* Reports Controller (2 endpoints, authenticated)
|
||||
* - GET /analytics/reports/:type
|
||||
* - GET /analytics/reports/:type/export/csv
|
||||
*/
|
||||
@ApiTags('Reports')
|
||||
@ApiBearerAuth()
|
||||
@Controller('reports')
|
||||
export class ReportsController {
|
||||
constructor(private readonly reportsService: ReportsService) {}
|
||||
|
||||
@Get(':type')
|
||||
@ApiOperation({ summary: 'Get report by type' })
|
||||
@ApiResponse({ status: 200, description: 'Report data' })
|
||||
async getReport(@Param('type') type: string) {
|
||||
return this.reportsService.getReport(type);
|
||||
}
|
||||
|
||||
@Get(':type/export/csv')
|
||||
@ApiOperation({ summary: 'Export report as CSV' })
|
||||
@ApiResponse({ status: 200, description: 'CSV file' })
|
||||
async exportCsv(@Param('type') type: string, @Res() res: Response) {
|
||||
const csv = await this.reportsService.exportCsv(type);
|
||||
res.setHeader('Content-Type', 'text/csv');
|
||||
res.setHeader('Content-Disposition', `attachment; filename=${type}-report.csv`);
|
||||
res.send(csv);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ReportsController } from './reports.controller';
|
||||
import { ReportsService } from './reports.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ReportsController],
|
||||
providers: [ReportsService],
|
||||
exports: [ReportsService],
|
||||
})
|
||||
export class ReportsModule {}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class ReportsService {
|
||||
async getReport(type: string) {
|
||||
const reports: Record<string, unknown> = {
|
||||
pnl: {
|
||||
revenue: { total: 125000, crypto: 5000 },
|
||||
costs: { total: 45000 },
|
||||
grossProfit: 80000,
|
||||
operatingExpenses: 20000,
|
||||
netIncome: 60000,
|
||||
ebitda: 65000,
|
||||
margins: { gross: 64, net: 48 },
|
||||
},
|
||||
'pnl-trend': [
|
||||
{ date: '2024-01', revenue: 100000, costs: 38000, netIncome: 47000 },
|
||||
{ date: '2024-02', revenue: 110000, costs: 40000, netIncome: 52000 },
|
||||
{ date: '2024-03', revenue: 125000, costs: 45000, netIncome: 60000 },
|
||||
],
|
||||
reserve: {
|
||||
target: 500000,
|
||||
current: 150000,
|
||||
percentage: 30,
|
||||
monthlyContribution: 15000,
|
||||
projectedDate: '2025-12-01',
|
||||
},
|
||||
};
|
||||
|
||||
return reports[type] || { error: 'Report type not found' };
|
||||
}
|
||||
|
||||
async exportCsv(type: string): Promise<string> {
|
||||
const data = await this.getReport(type);
|
||||
// Simple CSV conversion
|
||||
const headers = Object.keys(data as Record<string, unknown>).join(',');
|
||||
const values = Object.values(data as Record<string, unknown>)
|
||||
.map((v) => (typeof v === 'object' ? JSON.stringify(v) : v))
|
||||
.join(',');
|
||||
return `${headers}\n${values}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import { Controller, Get, Post, Body } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { ReviewsService } from './reviews.service';
|
||||
|
||||
/**
|
||||
* Reviews Controller (5 endpoints: 3 authenticated + 2 client)
|
||||
* - GET /analytics/reviews/insights (authenticated)
|
||||
* - POST /analytics/reviews/sentiment (authenticated)
|
||||
* - GET /analytics/reviews/trends (authenticated)
|
||||
* - GET /analytics/client/dashboard (client)
|
||||
* - GET /analytics/client/engagement (client)
|
||||
*/
|
||||
@ApiTags('Reviews')
|
||||
@ApiBearerAuth()
|
||||
@Controller()
|
||||
export class ReviewsController {
|
||||
constructor(private readonly reviewsService: ReviewsService) {}
|
||||
|
||||
@Get('reviews/insights')
|
||||
@ApiOperation({ summary: 'Get review insights' })
|
||||
@ApiResponse({ status: 200, description: 'Review insights' })
|
||||
async getInsights() {
|
||||
return this.reviewsService.getInsights();
|
||||
}
|
||||
|
||||
@Post('reviews/sentiment')
|
||||
@ApiOperation({ summary: 'Analyze review sentiment' })
|
||||
@ApiResponse({ status: 200, description: 'Sentiment analysis result' })
|
||||
async analyzeSentiment(@Body() body: { text: string }) {
|
||||
return this.reviewsService.analyzeSentiment(body.text);
|
||||
}
|
||||
|
||||
@Get('reviews/trends')
|
||||
@ApiOperation({ summary: 'Get review trends' })
|
||||
@ApiResponse({ status: 200, description: 'Review trends' })
|
||||
async getTrends() {
|
||||
return this.reviewsService.getTrends();
|
||||
}
|
||||
|
||||
@Get('client/dashboard')
|
||||
@ApiOperation({ summary: 'Get client dashboard data' })
|
||||
@ApiResponse({ status: 200, description: 'Client dashboard' })
|
||||
async getClientDashboard() {
|
||||
return this.reviewsService.getClientDashboard();
|
||||
}
|
||||
|
||||
@Get('client/engagement')
|
||||
@ApiOperation({ summary: 'Get client engagement metrics' })
|
||||
@ApiResponse({ status: 200, description: 'Client engagement' })
|
||||
async getClientEngagement() {
|
||||
return this.reviewsService.getClientEngagement();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ReviewsController } from './reviews.controller';
|
||||
import { ReviewsService } from './reviews.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ReviewsController],
|
||||
providers: [ReviewsService],
|
||||
exports: [ReviewsService],
|
||||
})
|
||||
export class ReviewsModule {}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class ReviewsService {
|
||||
async getInsights() {
|
||||
return {
|
||||
totalReviews: 1250,
|
||||
avgRating: 4.7,
|
||||
positivePercent: 92,
|
||||
neutralPercent: 5,
|
||||
negativePercent: 3,
|
||||
topKeywords: ['amazing', 'quality', 'worth it', 'fantastic', 'love'],
|
||||
recentTrend: 'positive',
|
||||
};
|
||||
}
|
||||
|
||||
async analyzeSentiment(text: string) {
|
||||
// Simple mock sentiment analysis
|
||||
const positiveWords = ['great', 'amazing', 'love', 'excellent', 'fantastic'];
|
||||
const negativeWords = ['bad', 'terrible', 'hate', 'awful', 'poor'];
|
||||
|
||||
const words = text.toLowerCase().split(' ');
|
||||
const positive = words.filter((w) => positiveWords.includes(w)).length;
|
||||
const negative = words.filter((w) => negativeWords.includes(w)).length;
|
||||
|
||||
let sentiment: 'positive' | 'negative' | 'neutral';
|
||||
if (positive > negative) sentiment = 'positive';
|
||||
else if (negative > positive) sentiment = 'negative';
|
||||
else sentiment = 'neutral';
|
||||
|
||||
return {
|
||||
text,
|
||||
sentiment,
|
||||
confidence: 0.85,
|
||||
scores: { positive, negative, neutral: words.length - positive - negative },
|
||||
};
|
||||
}
|
||||
|
||||
async getTrends() {
|
||||
return {
|
||||
weekly: [
|
||||
{ week: 'W1', avgRating: 4.6, count: 120 },
|
||||
{ week: 'W2', avgRating: 4.7, count: 135 },
|
||||
{ week: 'W3', avgRating: 4.8, count: 128 },
|
||||
{ week: 'W4', avgRating: 4.7, count: 142 },
|
||||
],
|
||||
sentimentTrend: [
|
||||
{ week: 'W1', positive: 88, neutral: 7, negative: 5 },
|
||||
{ week: 'W2', positive: 90, neutral: 6, negative: 4 },
|
||||
{ week: 'W3', positive: 93, neutral: 4, negative: 3 },
|
||||
{ week: 'W4', positive: 92, neutral: 5, negative: 3 },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async getClientDashboard() {
|
||||
return {
|
||||
activeSubscribers: 850,
|
||||
monthlyRevenue: 12500,
|
||||
engagement: {
|
||||
views: 45000,
|
||||
likes: 3200,
|
||||
comments: 680,
|
||||
},
|
||||
topContent: [
|
||||
{ id: '1', title: 'Content 1', views: 5000 },
|
||||
{ id: '2', title: 'Content 2', views: 4200 },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async getClientEngagement() {
|
||||
return {
|
||||
daily: {
|
||||
views: 1500,
|
||||
likes: 120,
|
||||
comments: 25,
|
||||
shares: 8,
|
||||
},
|
||||
weekly: {
|
||||
views: 10500,
|
||||
likes: 850,
|
||||
comments: 180,
|
||||
shares: 45,
|
||||
},
|
||||
monthly: {
|
||||
views: 45000,
|
||||
likes: 3200,
|
||||
comments: 680,
|
||||
shares: 180,
|
||||
},
|
||||
trend: 'up',
|
||||
growthRate: 12.5,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
/**
|
||||
* Unit Tests for Tracking Controller
|
||||
* Tests event ingestion endpoints
|
||||
*/
|
||||
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { TrackingController } from './tracking.controller';
|
||||
|
||||
describe('TrackingController', () => {
|
||||
let controller: TrackingController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [TrackingController],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<TrackingController>(TrackingController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
describe('trackView', () => {
|
||||
it('should accept valid page view event', async () => {
|
||||
const event = {
|
||||
path: '/analytics/revenue',
|
||||
sessionId: 'sess_123',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const result = await controller.trackView(event);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept page view with user ID', async () => {
|
||||
const event = {
|
||||
path: '/profile',
|
||||
sessionId: 'sess_123',
|
||||
userId: 'user_456',
|
||||
referrer: 'https://google.com',
|
||||
};
|
||||
|
||||
const result = await controller.trackView(event);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle missing optional fields', async () => {
|
||||
const event = {
|
||||
path: '/home',
|
||||
sessionId: 'sess_789',
|
||||
};
|
||||
|
||||
const result = await controller.trackView(event);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('trackEngagement', () => {
|
||||
it('should accept valid engagement event', async () => {
|
||||
const event = {
|
||||
type: 'click',
|
||||
targetId: 'btn_submit',
|
||||
targetType: 'button',
|
||||
userId: 'user_123',
|
||||
};
|
||||
|
||||
const result = await controller.trackEngagement(event);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept engagement with metadata', async () => {
|
||||
const event = {
|
||||
type: 'like',
|
||||
targetId: 'post_456',
|
||||
targetType: 'post',
|
||||
metadata: { source: 'feed', position: 3 },
|
||||
};
|
||||
|
||||
const result = await controller.trackEngagement(event);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('trackImpression', () => {
|
||||
it('should accept listing impression', async () => {
|
||||
const event = {
|
||||
listingId: 'listing_123',
|
||||
position: 5,
|
||||
source: 'search',
|
||||
sessionId: 'sess_abc',
|
||||
};
|
||||
|
||||
const result = await controller.trackImpression(event);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('trackClick', () => {
|
||||
it('should accept listing click', async () => {
|
||||
const event = {
|
||||
listingId: 'listing_123',
|
||||
source: 'search',
|
||||
sessionId: 'sess_abc',
|
||||
};
|
||||
|
||||
const result = await controller.trackClick(event);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import { Controller, Post, Body } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
|
||||
import { TrackingService } from './tracking.service';
|
||||
|
||||
// DTOs
|
||||
class TrackViewDto {
|
||||
pageUrl!: string;
|
||||
referrer?: string;
|
||||
userId?: string;
|
||||
sessionId!: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
class TrackEngagementDto {
|
||||
eventType!: string;
|
||||
targetId!: string;
|
||||
userId?: string;
|
||||
sessionId!: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
class ListingImpressionDto {
|
||||
listingId!: string;
|
||||
position!: number;
|
||||
context!: string;
|
||||
userId?: string;
|
||||
sessionId!: string;
|
||||
}
|
||||
|
||||
class ListingClickDto {
|
||||
listingId!: string;
|
||||
position!: number;
|
||||
userId?: string;
|
||||
sessionId!: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracking Controller (4 endpoints, public)
|
||||
* - POST /analytics/track/view
|
||||
* - POST /analytics/track/engagement
|
||||
* - POST /analytics/listing/impression
|
||||
* - POST /analytics/listing/click
|
||||
*/
|
||||
@ApiTags('Tracking')
|
||||
@Controller()
|
||||
export class TrackingController {
|
||||
constructor(private readonly trackingService: TrackingService) {}
|
||||
|
||||
@Post('track/view')
|
||||
@ApiOperation({ summary: 'Track page view' })
|
||||
@ApiResponse({ status: 201, description: 'View tracked successfully' })
|
||||
async trackView(@Body() dto: TrackViewDto) {
|
||||
return this.trackingService.trackView(dto);
|
||||
}
|
||||
|
||||
@Post('track/engagement')
|
||||
@ApiOperation({ summary: 'Track user engagement event' })
|
||||
@ApiResponse({ status: 201, description: 'Engagement tracked successfully' })
|
||||
async trackEngagement(@Body() dto: TrackEngagementDto) {
|
||||
return this.trackingService.trackEngagement(dto);
|
||||
}
|
||||
|
||||
@Post('listing/impression')
|
||||
@ApiOperation({ summary: 'Track listing impression' })
|
||||
@ApiResponse({ status: 201, description: 'Impression tracked successfully' })
|
||||
async trackListingImpression(@Body() dto: ListingImpressionDto) {
|
||||
return this.trackingService.trackListingImpression(dto);
|
||||
}
|
||||
|
||||
@Post('listing/click')
|
||||
@ApiOperation({ summary: 'Track listing click' })
|
||||
@ApiResponse({ status: 201, description: 'Click tracked successfully' })
|
||||
async trackListingClick(@Body() dto: ListingClickDto) {
|
||||
return this.trackingService.trackListingClick(dto);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TrackingController } from './tracking.controller';
|
||||
import { TrackingService } from './tracking.service';
|
||||
|
||||
@Module({
|
||||
controllers: [TrackingController],
|
||||
providers: [TrackingService],
|
||||
exports: [TrackingService],
|
||||
})
|
||||
export class TrackingModule {}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class TrackingService {
|
||||
async trackView(data: {
|
||||
pageUrl: string;
|
||||
referrer?: string;
|
||||
userId?: string;
|
||||
sessionId: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}) {
|
||||
// Queue for batch processing
|
||||
console.log('Tracking view:', data.pageUrl);
|
||||
return { success: true, eventId: `view-${Date.now()}` };
|
||||
}
|
||||
|
||||
async trackEngagement(data: {
|
||||
eventType: string;
|
||||
targetId: string;
|
||||
userId?: string;
|
||||
sessionId: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}) {
|
||||
console.log('Tracking engagement:', data.eventType, data.targetId);
|
||||
return { success: true, eventId: `engagement-${Date.now()}` };
|
||||
}
|
||||
|
||||
async trackListingImpression(data: {
|
||||
listingId: string;
|
||||
position: number;
|
||||
context: string;
|
||||
userId?: string;
|
||||
sessionId: string;
|
||||
}) {
|
||||
console.log('Tracking listing impression:', data.listingId);
|
||||
return { success: true, eventId: `impression-${Date.now()}` };
|
||||
}
|
||||
|
||||
async trackListingClick(data: {
|
||||
listingId: string;
|
||||
position: number;
|
||||
userId?: string;
|
||||
sessionId: string;
|
||||
}) {
|
||||
console.log('Tracking listing click:', data.listingId);
|
||||
return { success: true, eventId: `click-${Date.now()}` };
|
||||
}
|
||||
}
|
||||
31
features/analytics-service/server/tsconfig.json
Normal file
31
features/analytics-service/server/tsconfig.json
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"lib": ["ES2021"],
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue