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:
Quinn Ftw 2025-12-28 16:10:28 -08:00
parent 2dc1828214
commit c6f2f6d878
33 changed files with 1930 additions and 0 deletions

View 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"]

View 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

View file

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

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

View 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 {}

View file

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

View file

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

View file

@ -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 {}

View file

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

View file

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

View file

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

View file

@ -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 {}

View file

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

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

View file

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

View file

@ -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`);
}
}

View file

@ -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`);
}
}

View file

@ -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)
`);
}
}
}

View file

@ -0,0 +1,4 @@
export { EnableTimescaleDB1735200000000 } from './1735200000000-EnableTimescaleDB';
export { CreateAnalyticsHypertables1735200100000 } from './1735200100000-CreateAnalyticsHypertables';
export { CreateContinuousAggregates1735200200000 } from './1735200200000-CreateContinuousAggregates';
export { AddCompressionPolicies1735200300000 } from './1735200300000-AddCompressionPolicies';

View file

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

View file

@ -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 {}

View file

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

View file

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

View file

@ -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 {}

View file

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

View file

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

View file

@ -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 {}

View file

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

View file

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

View file

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

View file

@ -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 {}

View file

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

View 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"]
}