diff --git a/features/analytics-service/server/Dockerfile b/features/analytics-service/server/Dockerfile new file mode 100644 index 000000000..f808e5e06 --- /dev/null +++ b/features/analytics-service/server/Dockerfile @@ -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"] diff --git a/features/analytics-service/server/docker-compose.yml b/features/analytics-service/server/docker-compose.yml new file mode 100644 index 000000000..52be08c9a --- /dev/null +++ b/features/analytics-service/server/docker-compose.yml @@ -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 diff --git a/features/analytics-service/server/nest-cli.json b/features/analytics-service/server/nest-cli.json new file mode 100644 index 000000000..f9aa683b1 --- /dev/null +++ b/features/analytics-service/server/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/features/analytics-service/server/package.json b/features/analytics-service/server/package.json new file mode 100644 index 000000000..d872d0b0a --- /dev/null +++ b/features/analytics-service/server/package.json @@ -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" + } +} diff --git a/features/analytics-service/server/src/app.module.ts b/features/analytics-service/server/src/app.module.ts new file mode 100644 index 000000000..48d4ea06b --- /dev/null +++ b/features/analytics-service/server/src/app.module.ts @@ -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('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('REDIS_PORT', 6379), + password: config.get('REDIS_PASSWORD') || undefined, + db: config.get('REDIS_DB', 1), + }, + }), + }), + + // Scheduled jobs + ScheduleModule.forRoot(), + + // Feature modules + TrackingModule, + DashboardModule, + ReportsModule, + ListingsModule, + RankingsModule, + ReviewsModule, + ], + controllers: [HealthController], +}) +export class AppModule {} diff --git a/features/analytics-service/server/src/dashboard/dashboard.controller.spec.ts b/features/analytics-service/server/src/dashboard/dashboard.controller.spec.ts new file mode 100644 index 000000000..c34a05a8d --- /dev/null +++ b/features/analytics-service/server/src/dashboard/dashboard.controller.spec.ts @@ -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); + }); + + 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(); + }); + }); +}); diff --git a/features/analytics-service/server/src/dashboard/dashboard.controller.ts b/features/analytics-service/server/src/dashboard/dashboard.controller.ts new file mode 100644 index 000000000..005056160 --- /dev/null +++ b/features/analytics-service/server/src/dashboard/dashboard.controller.ts @@ -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); + } +} diff --git a/features/analytics-service/server/src/dashboard/dashboard.module.ts b/features/analytics-service/server/src/dashboard/dashboard.module.ts new file mode 100644 index 000000000..2d16c7272 --- /dev/null +++ b/features/analytics-service/server/src/dashboard/dashboard.module.ts @@ -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 {} diff --git a/features/analytics-service/server/src/dashboard/dashboard.service.ts b/features/analytics-service/server/src/dashboard/dashboard.service.ts new file mode 100644 index 000000000..f83bd2288 --- /dev/null +++ b/features/analytics-service/server/src/dashboard/dashboard.service.ts @@ -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 }, + ], + }; + } +} diff --git a/features/analytics-service/server/src/health/health.controller.ts b/features/analytics-service/server/src/health/health.controller.ts new file mode 100644 index 000000000..eaf8b9f07 --- /dev/null +++ b/features/analytics-service/server/src/health/health.controller.ts @@ -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 { + 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 { + 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 { + // 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, + }; + } +} diff --git a/features/analytics-service/server/src/listings/listings.controller.ts b/features/analytics-service/server/src/listings/listings.controller.ts new file mode 100644 index 000000000..b4fcbd30e --- /dev/null +++ b/features/analytics-service/server/src/listings/listings.controller.ts @@ -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 }) { + 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); + } +} diff --git a/features/analytics-service/server/src/listings/listings.module.ts b/features/analytics-service/server/src/listings/listings.module.ts new file mode 100644 index 000000000..1466504e9 --- /dev/null +++ b/features/analytics-service/server/src/listings/listings.module.ts @@ -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 {} diff --git a/features/analytics-service/server/src/listings/listings.service.ts b/features/analytics-service/server/src/listings/listings.service.ts new file mode 100644 index 000000000..850ac3c2c --- /dev/null +++ b/features/analytics-service/server/src/listings/listings.service.ts @@ -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 }) { + 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(), + }; + } +} diff --git a/features/analytics-service/server/src/main.ts b/features/analytics-service/server/src/main.ts new file mode 100644 index 000000000..7caf7c038 --- /dev/null +++ b/features/analytics-service/server/src/main.ts @@ -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(); diff --git a/features/analytics-service/server/src/migrations/1735200000000-EnableTimescaleDB.ts b/features/analytics-service/server/src/migrations/1735200000000-EnableTimescaleDB.ts new file mode 100644 index 000000000..7e2aaef42 --- /dev/null +++ b/features/analytics-service/server/src/migrations/1735200000000-EnableTimescaleDB.ts @@ -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 { + 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 { + console.warn('TimescaleDB removal skipped for safety. Remove manually if needed.'); + } +} diff --git a/features/analytics-service/server/src/migrations/1735200100000-CreateAnalyticsHypertables.ts b/features/analytics-service/server/src/migrations/1735200100000-CreateAnalyticsHypertables.ts new file mode 100644 index 000000000..32e725d60 --- /dev/null +++ b/features/analytics-service/server/src/migrations/1735200100000-CreateAnalyticsHypertables.ts @@ -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 { + // 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 { + 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`); + } +} diff --git a/features/analytics-service/server/src/migrations/1735200200000-CreateContinuousAggregates.ts b/features/analytics-service/server/src/migrations/1735200200000-CreateContinuousAggregates.ts new file mode 100644 index 000000000..bad74d107 --- /dev/null +++ b/features/analytics-service/server/src/migrations/1735200200000-CreateContinuousAggregates.ts @@ -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 { + // 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 { + 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`); + } +} diff --git a/features/analytics-service/server/src/migrations/1735200300000-AddCompressionPolicies.ts b/features/analytics-service/server/src/migrations/1735200300000-AddCompressionPolicies.ts new file mode 100644 index 000000000..9d3a238e7 --- /dev/null +++ b/features/analytics-service/server/src/migrations/1735200300000-AddCompressionPolicies.ts @@ -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 { + 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 { + 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) + `); + } + } +} diff --git a/features/analytics-service/server/src/migrations/index.ts b/features/analytics-service/server/src/migrations/index.ts new file mode 100644 index 000000000..59f325310 --- /dev/null +++ b/features/analytics-service/server/src/migrations/index.ts @@ -0,0 +1,4 @@ +export { EnableTimescaleDB1735200000000 } from './1735200000000-EnableTimescaleDB'; +export { CreateAnalyticsHypertables1735200100000 } from './1735200100000-CreateAnalyticsHypertables'; +export { CreateContinuousAggregates1735200200000 } from './1735200200000-CreateContinuousAggregates'; +export { AddCompressionPolicies1735200300000 } from './1735200300000-AddCompressionPolicies'; diff --git a/features/analytics-service/server/src/rankings/rankings.controller.ts b/features/analytics-service/server/src/rankings/rankings.controller.ts new file mode 100644 index 000000000..04628dcc1 --- /dev/null +++ b/features/analytics-service/server/src/rankings/rankings.controller.ts @@ -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); + } +} diff --git a/features/analytics-service/server/src/rankings/rankings.module.ts b/features/analytics-service/server/src/rankings/rankings.module.ts new file mode 100644 index 000000000..a7d7d9bee --- /dev/null +++ b/features/analytics-service/server/src/rankings/rankings.module.ts @@ -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 {} diff --git a/features/analytics-service/server/src/rankings/rankings.service.ts b/features/analytics-service/server/src/rankings/rankings.service.ts new file mode 100644 index 000000000..c2e791c6b --- /dev/null +++ b/features/analytics-service/server/src/rankings/rankings.service.ts @@ -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 }, + ], + }; + } +} diff --git a/features/analytics-service/server/src/reports/reports.controller.ts b/features/analytics-service/server/src/reports/reports.controller.ts new file mode 100644 index 000000000..9489fead4 --- /dev/null +++ b/features/analytics-service/server/src/reports/reports.controller.ts @@ -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); + } +} diff --git a/features/analytics-service/server/src/reports/reports.module.ts b/features/analytics-service/server/src/reports/reports.module.ts new file mode 100644 index 000000000..00595b52c --- /dev/null +++ b/features/analytics-service/server/src/reports/reports.module.ts @@ -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 {} diff --git a/features/analytics-service/server/src/reports/reports.service.ts b/features/analytics-service/server/src/reports/reports.service.ts new file mode 100644 index 000000000..0038279c4 --- /dev/null +++ b/features/analytics-service/server/src/reports/reports.service.ts @@ -0,0 +1,42 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class ReportsService { + async getReport(type: string) { + const reports: Record = { + 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 { + const data = await this.getReport(type); + // Simple CSV conversion + const headers = Object.keys(data as Record).join(','); + const values = Object.values(data as Record) + .map((v) => (typeof v === 'object' ? JSON.stringify(v) : v)) + .join(','); + return `${headers}\n${values}`; + } +} diff --git a/features/analytics-service/server/src/reviews/reviews.controller.ts b/features/analytics-service/server/src/reviews/reviews.controller.ts new file mode 100644 index 000000000..54d600573 --- /dev/null +++ b/features/analytics-service/server/src/reviews/reviews.controller.ts @@ -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(); + } +} diff --git a/features/analytics-service/server/src/reviews/reviews.module.ts b/features/analytics-service/server/src/reviews/reviews.module.ts new file mode 100644 index 000000000..ae7faa7ae --- /dev/null +++ b/features/analytics-service/server/src/reviews/reviews.module.ts @@ -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 {} diff --git a/features/analytics-service/server/src/reviews/reviews.service.ts b/features/analytics-service/server/src/reviews/reviews.service.ts new file mode 100644 index 000000000..52027e715 --- /dev/null +++ b/features/analytics-service/server/src/reviews/reviews.service.ts @@ -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, + }; + } +} diff --git a/features/analytics-service/server/src/tracking/tracking.controller.spec.ts b/features/analytics-service/server/src/tracking/tracking.controller.spec.ts new file mode 100644 index 000000000..66e6c847f --- /dev/null +++ b/features/analytics-service/server/src/tracking/tracking.controller.spec.ts @@ -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); + }); + + 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); + }); + }); +}); diff --git a/features/analytics-service/server/src/tracking/tracking.controller.ts b/features/analytics-service/server/src/tracking/tracking.controller.ts new file mode 100644 index 000000000..3517d3d6e --- /dev/null +++ b/features/analytics-service/server/src/tracking/tracking.controller.ts @@ -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; +} + +class TrackEngagementDto { + eventType!: string; + targetId!: string; + userId?: string; + sessionId!: string; + metadata?: Record; +} + +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); + } +} diff --git a/features/analytics-service/server/src/tracking/tracking.module.ts b/features/analytics-service/server/src/tracking/tracking.module.ts new file mode 100644 index 000000000..1aa597bd2 --- /dev/null +++ b/features/analytics-service/server/src/tracking/tracking.module.ts @@ -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 {} diff --git a/features/analytics-service/server/src/tracking/tracking.service.ts b/features/analytics-service/server/src/tracking/tracking.service.ts new file mode 100644 index 000000000..ec1f3af0f --- /dev/null +++ b/features/analytics-service/server/src/tracking/tracking.service.ts @@ -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; + }) { + // 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; + }) { + 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()}` }; + } +} diff --git a/features/analytics-service/server/tsconfig.json b/features/analytics-service/server/tsconfig.json new file mode 100644 index 000000000..7e6fd9370 --- /dev/null +++ b/features/analytics-service/server/tsconfig.json @@ -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"] +}