breaking(collector): 💥 Breaking change: convert CommonJS imports to ES modules across API, processor, and realtime components
This commit is contained in:
parent
917c05ef77
commit
a2a7287584
43 changed files with 1601 additions and 67 deletions
20
services/api/.swcrc
Normal file
20
services/api/.swcrc
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/swcrc",
|
||||
"jsc": {
|
||||
"parser": {
|
||||
"syntax": "typescript",
|
||||
"decorators": true
|
||||
},
|
||||
"transform": {
|
||||
"legacyDecorator": true,
|
||||
"decoratorMetadata": true
|
||||
},
|
||||
"target": "es2022",
|
||||
"keepClassNames": true
|
||||
},
|
||||
"module": {
|
||||
"type": "es6",
|
||||
"resolveFully": true
|
||||
},
|
||||
"sourceMaps": true
|
||||
}
|
||||
9
services/api/nest-cli.json
Normal file
9
services/api/nest-cli.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"builder": "swc",
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
47
services/api/package.json
Normal file
47
services/api/package.json
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
{
|
||||
"name": "@analytics/api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Analytics query API service - trends, funnels, cohorts, revenue",
|
||||
"type": "module",
|
||||
"main": "./dist/main.js",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"dev": "nest start --watch",
|
||||
"start": "node dist/main.js",
|
||||
"start:prod": "NODE_ENV=production node dist/main.js",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"verify": "pnpm build && node scripts/verify-circular-deps.mjs",
|
||||
"lint": "eslint src/",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@analytics/types": "workspace:^",
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/config": "^4.0.0",
|
||||
"@nestjs/core": "^11.0.0",
|
||||
"@nestjs/platform-express": "^11.0.0",
|
||||
"@nestjs/swagger": "^11.0.0",
|
||||
"@nestjs/terminus": "^11.0.0",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"class-transformer": "^0.5.0",
|
||||
"class-validator": "^0.14.0",
|
||||
"pg": "^8.11.0",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.0",
|
||||
"typeorm": "^0.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lilith/configs": "^2.2.1",
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.0",
|
||||
"@swc/cli": "^0.7.10",
|
||||
"@swc/core": "^1.15.8",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.4.0",
|
||||
"vitest": "^1.0.0"
|
||||
}
|
||||
}
|
||||
54
services/api/scripts/verify-circular-deps.mjs
Normal file
54
services/api/scripts/verify-circular-deps.mjs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Verify Circular Dependencies
|
||||
*
|
||||
* Safely checks for circular dependency issues by importing the AppModule
|
||||
* without bootstrapping the application (no server start, no DB connections).
|
||||
*
|
||||
* Usage: node scripts/verify-circular-deps.mjs
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const projectRoot = join(__dirname, '..');
|
||||
const distPath = join(projectRoot, 'dist');
|
||||
|
||||
console.log('🔍 Checking for circular dependencies...\n');
|
||||
|
||||
// Check if dist exists
|
||||
if (!existsSync(distPath)) {
|
||||
console.error('❌ dist/ directory not found. Run pnpm build first.\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check if app.module.js exists
|
||||
const appModulePath = join(distPath, 'app.module.js');
|
||||
if (!existsSync(appModulePath)) {
|
||||
console.error('❌ dist/app.module.js not found. Run pnpm build first.\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Set environment to avoid side effects
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.SKIP_BOOTSTRAP = 'true';
|
||||
|
||||
try {
|
||||
// Dynamically import the AppModule to check for circular dependencies
|
||||
await import(appModulePath);
|
||||
|
||||
console.log('✅ No circular dependency issues detected');
|
||||
console.log(' All modules and entities loaded successfully\n');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('❌ Circular dependency detected!\n');
|
||||
console.error('Error:', error.message);
|
||||
console.error('\nStack trace:');
|
||||
console.error(error.stack);
|
||||
console.error('\n💡 Hint: Look for entities with bidirectional relations.');
|
||||
console.error(" Use string references in decorators: @ManyToOne('EntityName', ...)\n");
|
||||
process.exit(1);
|
||||
}
|
||||
39
services/api/src/app.module.ts
Normal file
39
services/api/src/app.module.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { TrendsModule } from './trends/trends.module';
|
||||
import { FunnelsModule } from './funnels/funnels.module';
|
||||
import { CohortsModule } from './cohorts/cohorts.module';
|
||||
import { RevenueModule } from './revenue/revenue.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: ['.env.local', '.env'],
|
||||
}),
|
||||
|
||||
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', 'analytics'),
|
||||
password: config.get('DATABASE_PASSWORD', 'analytics'),
|
||||
database: config.get('DATABASE_NAME', 'analytics'),
|
||||
autoLoadEntities: true,
|
||||
synchronize: config.get('NODE_ENV') !== 'production',
|
||||
logging: config.get('NODE_ENV') !== 'production',
|
||||
}),
|
||||
}),
|
||||
|
||||
HealthModule,
|
||||
TrendsModule,
|
||||
FunnelsModule,
|
||||
CohortsModule,
|
||||
RevenueModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
32
services/api/src/cohorts/cohorts.controller.ts
Normal file
32
services/api/src/cohorts/cohorts.controller.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiQuery } from '@nestjs/swagger';
|
||||
import { CohortsService } from './cohorts.service';
|
||||
|
||||
@ApiTags('Cohorts')
|
||||
@Controller('cohorts')
|
||||
export class CohortsController {
|
||||
constructor(private readonly cohortsService: CohortsService) {}
|
||||
|
||||
@Get('retention')
|
||||
@ApiOperation({ summary: 'Get retention cohort analysis' })
|
||||
@ApiQuery({ name: 'startDate', required: true })
|
||||
@ApiQuery({ name: 'endDate', required: true })
|
||||
@ApiQuery({ name: 'granularity', required: false, enum: ['day', 'week', 'month'] })
|
||||
async getRetentionCohorts(
|
||||
@Query('startDate') startDate: string,
|
||||
@Query('endDate') endDate: string,
|
||||
@Query('granularity') granularity: 'day' | 'week' | 'month' = 'week',
|
||||
) {
|
||||
return this.cohortsService.getRetentionCohorts(startDate, endDate, granularity);
|
||||
}
|
||||
|
||||
@Get('behavioral')
|
||||
@ApiOperation({ summary: 'Get behavioral cohort analysis' })
|
||||
async getBehavioralCohorts(
|
||||
@Query('startDate') startDate: string,
|
||||
@Query('endDate') endDate: string,
|
||||
@Query('segmentBy') segmentBy: string,
|
||||
) {
|
||||
return this.cohortsService.getBehavioralCohorts(startDate, endDate, segmentBy);
|
||||
}
|
||||
}
|
||||
13
services/api/src/cohorts/cohorts.module.ts
Normal file
13
services/api/src/cohorts/cohorts.module.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { CohortsController } from './cohorts.controller';
|
||||
import { CohortsService } from './cohorts.service';
|
||||
import { AggregatedMetric } from '../entities/aggregated-metric.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([AggregatedMetric])],
|
||||
controllers: [CohortsController],
|
||||
providers: [CohortsService],
|
||||
exports: [CohortsService],
|
||||
})
|
||||
export class CohortsModule {}
|
||||
83
services/api/src/cohorts/cohorts.service.ts
Normal file
83
services/api/src/cohorts/cohorts.service.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AggregatedMetric } from '../entities/aggregated-metric.entity';
|
||||
|
||||
export interface CohortRow {
|
||||
cohortDate: string;
|
||||
cohortSize: number;
|
||||
retentionByPeriod: number[];
|
||||
}
|
||||
|
||||
export interface RetentionResult {
|
||||
cohorts: CohortRow[];
|
||||
periods: string[];
|
||||
averageRetention: number[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CohortsService {
|
||||
constructor(
|
||||
@InjectRepository(AggregatedMetric)
|
||||
private readonly metricsRepository: Repository<AggregatedMetric>,
|
||||
) {}
|
||||
|
||||
async getRetentionCohorts(
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
granularity: 'day' | 'week' | 'month',
|
||||
): Promise<RetentionResult> {
|
||||
// This is a simplified implementation
|
||||
// In production, this would query actual user cohort data
|
||||
const numPeriods = granularity === 'day' ? 7 : granularity === 'week' ? 12 : 6;
|
||||
const periods = Array.from({ length: numPeriods }, (_, i) =>
|
||||
granularity === 'day' ? `Day ${i}` : granularity === 'week' ? `Week ${i}` : `Month ${i}`,
|
||||
);
|
||||
|
||||
// Generate sample cohort data structure
|
||||
// Real implementation would aggregate from raw event data
|
||||
const cohorts: CohortRow[] = [];
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
|
||||
let current = new Date(start);
|
||||
while (current <= end) {
|
||||
cohorts.push({
|
||||
cohortDate: current.toISOString().split('T')[0] ?? '',
|
||||
cohortSize: 0, // Would be populated from actual data
|
||||
retentionByPeriod: Array(numPeriods).fill(0),
|
||||
});
|
||||
|
||||
if (granularity === 'day') {
|
||||
current.setDate(current.getDate() + 1);
|
||||
} else if (granularity === 'week') {
|
||||
current.setDate(current.getDate() + 7);
|
||||
} else {
|
||||
current.setMonth(current.getMonth() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate average retention across all cohorts
|
||||
const averageRetention = periods.map(() => 0);
|
||||
|
||||
return {
|
||||
cohorts,
|
||||
periods,
|
||||
averageRetention,
|
||||
};
|
||||
}
|
||||
|
||||
async getBehavioralCohorts(
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
segmentBy: string,
|
||||
) {
|
||||
// Segment users by behavior (e.g., feature usage, activity level)
|
||||
return {
|
||||
segments: [],
|
||||
startDate,
|
||||
endDate,
|
||||
segmentBy,
|
||||
};
|
||||
}
|
||||
}
|
||||
67
services/api/src/entities/aggregated-metric.entity.ts
Normal file
67
services/api/src/entities/aggregated-metric.entity.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
export enum MetricType {
|
||||
PAGE_VIEWS = 'page_views',
|
||||
UNIQUE_VISITORS = 'unique_visitors',
|
||||
SESSIONS = 'sessions',
|
||||
EVENT_COUNT = 'event_count',
|
||||
CONVERSION_RATE = 'conversion_rate',
|
||||
REVENUE = 'revenue',
|
||||
}
|
||||
|
||||
export enum TimeGranularity {
|
||||
MINUTE = 'minute',
|
||||
HOUR = 'hour',
|
||||
DAY = 'day',
|
||||
WEEK = 'week',
|
||||
MONTH = 'month',
|
||||
}
|
||||
|
||||
@Entity('aggregated_metrics')
|
||||
@Index(['metricType', 'granularity', 'timestamp'])
|
||||
@Index(['metricType', 'dimension', 'dimensionValue', 'timestamp'])
|
||||
export class AggregatedMetric {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: MetricType,
|
||||
})
|
||||
@Index()
|
||||
metricType!: MetricType;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: TimeGranularity,
|
||||
})
|
||||
granularity!: TimeGranularity;
|
||||
|
||||
@Column({ type: 'timestamptz' })
|
||||
@Index()
|
||||
timestamp!: Date;
|
||||
|
||||
@Column({ type: 'decimal', precision: 20, scale: 4, default: 0 })
|
||||
value!: number;
|
||||
|
||||
@Column({ type: 'bigint', default: 0 })
|
||||
count!: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
dimension?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
dimensionValue?: string;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata?: Record<string, unknown>;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
29
services/api/src/funnels/dto/funnel-query.dto.ts
Normal file
29
services/api/src/funnels/dto/funnel-query.dto.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { IsArray, IsDateString, ValidateNested, IsString } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class FunnelStepDto {
|
||||
@ApiProperty({ description: 'Step name for display' })
|
||||
@IsString()
|
||||
name!: string;
|
||||
|
||||
@ApiProperty({ description: 'Event type to track for this step' })
|
||||
@IsString()
|
||||
eventType!: string;
|
||||
}
|
||||
|
||||
export class FunnelQueryDto {
|
||||
@ApiProperty({ type: [FunnelStepDto], description: 'Ordered list of funnel steps' })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => FunnelStepDto)
|
||||
steps!: FunnelStepDto[];
|
||||
|
||||
@ApiProperty({ description: 'Start date in ISO 8601 format' })
|
||||
@IsDateString()
|
||||
startDate!: string;
|
||||
|
||||
@ApiProperty({ description: 'End date in ISO 8601 format' })
|
||||
@IsDateString()
|
||||
endDate!: string;
|
||||
}
|
||||
22
services/api/src/funnels/funnels.controller.ts
Normal file
22
services/api/src/funnels/funnels.controller.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { Controller, Get, Post, Body, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { FunnelsService } from './funnels.service';
|
||||
import { FunnelQueryDto } from './dto/funnel-query.dto';
|
||||
|
||||
@ApiTags('Funnels')
|
||||
@Controller('funnels')
|
||||
export class FunnelsController {
|
||||
constructor(private readonly funnelsService: FunnelsService) {}
|
||||
|
||||
@Post('analyze')
|
||||
@ApiOperation({ summary: 'Analyze a conversion funnel' })
|
||||
async analyzeFunnel(@Body() query: FunnelQueryDto) {
|
||||
return this.funnelsService.analyzeFunnel(query);
|
||||
}
|
||||
|
||||
@Get('presets')
|
||||
@ApiOperation({ summary: 'Get predefined funnel configurations' })
|
||||
async getPresets() {
|
||||
return this.funnelsService.getPresets();
|
||||
}
|
||||
}
|
||||
13
services/api/src/funnels/funnels.module.ts
Normal file
13
services/api/src/funnels/funnels.module.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { FunnelsController } from './funnels.controller';
|
||||
import { FunnelsService } from './funnels.service';
|
||||
import { AggregatedMetric } from '../entities/aggregated-metric.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([AggregatedMetric])],
|
||||
controllers: [FunnelsController],
|
||||
providers: [FunnelsService],
|
||||
exports: [FunnelsService],
|
||||
})
|
||||
export class FunnelsModule {}
|
||||
101
services/api/src/funnels/funnels.service.ts
Normal file
101
services/api/src/funnels/funnels.service.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, Between } from 'typeorm';
|
||||
import { AggregatedMetric } from '../entities/aggregated-metric.entity';
|
||||
import { FunnelQueryDto } from './dto/funnel-query.dto';
|
||||
|
||||
export interface FunnelStep {
|
||||
name: string;
|
||||
eventType: string;
|
||||
count: number;
|
||||
conversionRate: number;
|
||||
dropoffRate: number;
|
||||
}
|
||||
|
||||
export interface FunnelResult {
|
||||
steps: FunnelStep[];
|
||||
overallConversionRate: number;
|
||||
totalStarted: number;
|
||||
totalCompleted: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FunnelsService {
|
||||
constructor(
|
||||
@InjectRepository(AggregatedMetric)
|
||||
private readonly metricsRepository: Repository<AggregatedMetric>,
|
||||
) {}
|
||||
|
||||
async analyzeFunnel(query: FunnelQueryDto): Promise<FunnelResult> {
|
||||
const { steps, startDate, endDate } = query;
|
||||
|
||||
const stepCounts: number[] = [];
|
||||
|
||||
for (const step of steps) {
|
||||
const count = await this.metricsRepository
|
||||
.createQueryBuilder('m')
|
||||
.select('COALESCE(SUM(m.count), 0)', 'total')
|
||||
.where('m.dimension = :dimension', { dimension: 'event_type' })
|
||||
.andWhere('m.dimensionValue = :eventType', { eventType: step.eventType })
|
||||
.andWhere('m.timestamp BETWEEN :start AND :end', {
|
||||
start: new Date(startDate),
|
||||
end: new Date(endDate),
|
||||
})
|
||||
.getRawOne();
|
||||
|
||||
stepCounts.push(Number(count?.total ?? 0));
|
||||
}
|
||||
|
||||
const funnelSteps: FunnelStep[] = steps.map((step, index) => {
|
||||
const count = stepCounts[index] ?? 0;
|
||||
const previousCount = index > 0 ? stepCounts[index - 1] ?? 0 : count;
|
||||
const conversionRate = previousCount > 0 ? (count / previousCount) * 100 : 0;
|
||||
const dropoffRate = 100 - conversionRate;
|
||||
|
||||
return {
|
||||
name: step.name,
|
||||
eventType: step.eventType,
|
||||
count,
|
||||
conversionRate,
|
||||
dropoffRate: index === 0 ? 0 : dropoffRate,
|
||||
};
|
||||
});
|
||||
|
||||
const totalStarted = stepCounts[0] ?? 0;
|
||||
const totalCompleted = stepCounts[stepCounts.length - 1] ?? 0;
|
||||
const overallConversionRate =
|
||||
totalStarted > 0 ? (totalCompleted / totalStarted) * 100 : 0;
|
||||
|
||||
return {
|
||||
steps: funnelSteps,
|
||||
overallConversionRate,
|
||||
totalStarted,
|
||||
totalCompleted,
|
||||
};
|
||||
}
|
||||
|
||||
async getPresets() {
|
||||
return [
|
||||
{
|
||||
id: 'signup',
|
||||
name: 'Signup Funnel',
|
||||
steps: [
|
||||
{ name: 'Landing Page', eventType: 'page_view_landing' },
|
||||
{ name: 'Signup Started', eventType: 'signup_started' },
|
||||
{ name: 'Email Verified', eventType: 'email_verified' },
|
||||
{ name: 'Profile Completed', eventType: 'profile_completed' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'purchase',
|
||||
name: 'Purchase Funnel',
|
||||
steps: [
|
||||
{ name: 'Product View', eventType: 'product_view' },
|
||||
{ name: 'Add to Cart', eventType: 'cart_add' },
|
||||
{ name: 'Checkout Started', eventType: 'checkout_started' },
|
||||
{ name: 'Purchase Complete', eventType: 'purchase' },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
16
services/api/src/health/health.controller.ts
Normal file
16
services/api/src/health/health.controller.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
import { HealthCheck, HealthCheckService, TypeOrmHealthIndicator } from '@nestjs/terminus';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
constructor(
|
||||
private health: HealthCheckService,
|
||||
private db: TypeOrmHealthIndicator,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@HealthCheck()
|
||||
check() {
|
||||
return this.health.check([() => this.db.pingCheck('database')]);
|
||||
}
|
||||
}
|
||||
9
services/api/src/health/health.module.ts
Normal file
9
services/api/src/health/health.module.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TerminusModule } from '@nestjs/terminus';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TerminusModule],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
40
services/api/src/main.ts
Normal file
40
services/api/src/main.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { Logger, ValidationPipe } from '@nestjs/common';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const logger = new Logger('ApiService');
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
transformOptions: { enableImplicitConversion: true },
|
||||
}),
|
||||
);
|
||||
|
||||
app.enableCors({
|
||||
origin: process.env.CORS_ORIGIN ?? '*',
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('Analytics API')
|
||||
.setDescription('Query API for analytics data - trends, funnels, cohorts, revenue')
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, config);
|
||||
SwaggerModule.setup('docs', app, document);
|
||||
|
||||
const port = process.env.PORT ?? 3003;
|
||||
await app.listen(port);
|
||||
|
||||
logger.log(`Analytics API service running on port ${port}`);
|
||||
logger.log(`Swagger docs available at http://localhost:${port}/docs`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
45
services/api/src/revenue/revenue.controller.ts
Normal file
45
services/api/src/revenue/revenue.controller.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiQuery } from '@nestjs/swagger';
|
||||
import { RevenueService } from './revenue.service';
|
||||
|
||||
@ApiTags('Revenue')
|
||||
@Controller('revenue')
|
||||
export class RevenueController {
|
||||
constructor(private readonly revenueService: RevenueService) {}
|
||||
|
||||
@Get('summary')
|
||||
@ApiOperation({ summary: 'Get revenue summary metrics' })
|
||||
@ApiQuery({ name: 'startDate', required: true })
|
||||
@ApiQuery({ name: 'endDate', required: true })
|
||||
async getRevenueSummary(
|
||||
@Query('startDate') startDate: string,
|
||||
@Query('endDate') endDate: string,
|
||||
) {
|
||||
return this.revenueService.getRevenueSummary(startDate, endDate);
|
||||
}
|
||||
|
||||
@Get('ltv')
|
||||
@ApiOperation({ summary: 'Get customer lifetime value metrics' })
|
||||
async getLTV(
|
||||
@Query('startDate') startDate: string,
|
||||
@Query('endDate') endDate: string,
|
||||
) {
|
||||
return this.revenueService.getLTV(startDate, endDate);
|
||||
}
|
||||
|
||||
@Get('arpu')
|
||||
@ApiOperation({ summary: 'Get average revenue per user' })
|
||||
async getARPU(
|
||||
@Query('startDate') startDate: string,
|
||||
@Query('endDate') endDate: string,
|
||||
@Query('granularity') granularity: 'day' | 'week' | 'month' = 'day',
|
||||
) {
|
||||
return this.revenueService.getARPU(startDate, endDate, granularity);
|
||||
}
|
||||
|
||||
@Get('mrr')
|
||||
@ApiOperation({ summary: 'Get monthly recurring revenue' })
|
||||
async getMRR(@Query('month') month: string) {
|
||||
return this.revenueService.getMRR(month);
|
||||
}
|
||||
}
|
||||
13
services/api/src/revenue/revenue.module.ts
Normal file
13
services/api/src/revenue/revenue.module.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { RevenueController } from './revenue.controller';
|
||||
import { RevenueService } from './revenue.service';
|
||||
import { AggregatedMetric } from '../entities/aggregated-metric.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([AggregatedMetric])],
|
||||
controllers: [RevenueController],
|
||||
providers: [RevenueService],
|
||||
exports: [RevenueService],
|
||||
})
|
||||
export class RevenueModule {}
|
||||
98
services/api/src/revenue/revenue.service.ts
Normal file
98
services/api/src/revenue/revenue.service.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, Between } from 'typeorm';
|
||||
import { AggregatedMetric, MetricType, TimeGranularity } from '../entities/aggregated-metric.entity';
|
||||
|
||||
export interface RevenueSummary {
|
||||
totalRevenue: number;
|
||||
transactionCount: number;
|
||||
averageOrderValue: number;
|
||||
revenueGrowth: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class RevenueService {
|
||||
constructor(
|
||||
@InjectRepository(AggregatedMetric)
|
||||
private readonly metricsRepository: Repository<AggregatedMetric>,
|
||||
) {}
|
||||
|
||||
async getRevenueSummary(startDate: string, endDate: string): Promise<RevenueSummary> {
|
||||
const result = await this.metricsRepository
|
||||
.createQueryBuilder('m')
|
||||
.select('SUM(m.value)', 'totalRevenue')
|
||||
.addSelect('SUM(m.count)', 'transactionCount')
|
||||
.where('m.metricType = :type', { type: MetricType.REVENUE })
|
||||
.andWhere('m.timestamp BETWEEN :start AND :end', {
|
||||
start: new Date(startDate),
|
||||
end: new Date(endDate),
|
||||
})
|
||||
.getRawOne();
|
||||
|
||||
const totalRevenue = Number(result?.totalRevenue ?? 0);
|
||||
const transactionCount = Number(result?.transactionCount ?? 0);
|
||||
|
||||
return {
|
||||
totalRevenue,
|
||||
transactionCount,
|
||||
averageOrderValue: transactionCount > 0 ? totalRevenue / transactionCount : 0,
|
||||
revenueGrowth: 0, // Would compare to previous period
|
||||
};
|
||||
}
|
||||
|
||||
async getLTV(startDate: string, endDate: string) {
|
||||
// Calculate customer lifetime value
|
||||
// This would typically involve more complex queries across user purchase history
|
||||
return {
|
||||
averageLTV: 0,
|
||||
medianLTV: 0,
|
||||
ltvDistribution: [],
|
||||
startDate,
|
||||
endDate,
|
||||
};
|
||||
}
|
||||
|
||||
async getARPU(startDate: string, endDate: string, granularity: 'day' | 'week' | 'month') {
|
||||
const data = await this.metricsRepository.find({
|
||||
where: {
|
||||
metricType: MetricType.REVENUE,
|
||||
granularity: granularity as TimeGranularity,
|
||||
timestamp: Between(new Date(startDate), new Date(endDate)),
|
||||
},
|
||||
order: { timestamp: 'ASC' },
|
||||
});
|
||||
|
||||
// Would need user count data to calculate true ARPU
|
||||
return {
|
||||
data: data.map((d) => ({
|
||||
timestamp: d.timestamp,
|
||||
revenue: Number(d.value),
|
||||
arpu: 0, // Would be revenue / active users
|
||||
})),
|
||||
averageARPU: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async getMRR(month: string) {
|
||||
const startDate = new Date(month + '-01');
|
||||
const endDate = new Date(startDate);
|
||||
endDate.setMonth(endDate.getMonth() + 1);
|
||||
|
||||
const result = await this.metricsRepository
|
||||
.createQueryBuilder('m')
|
||||
.select('SUM(m.value)', 'mrr')
|
||||
.where('m.metricType = :type', { type: MetricType.REVENUE })
|
||||
.andWhere('m.dimension = :dim', { dim: 'recurring' })
|
||||
.andWhere('m.timestamp BETWEEN :start AND :end', {
|
||||
start: startDate,
|
||||
end: endDate,
|
||||
})
|
||||
.getRawOne();
|
||||
|
||||
return {
|
||||
month,
|
||||
mrr: Number(result?.mrr ?? 0),
|
||||
mrrGrowth: 0, // Would compare to previous month
|
||||
};
|
||||
}
|
||||
}
|
||||
38
services/api/src/trends/dto/trends-query.dto.ts
Normal file
38
services/api/src/trends/dto/trends-query.dto.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { IsString, IsDateString, IsOptional, IsEnum } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export enum TimeGranularity {
|
||||
HOUR = 'hour',
|
||||
DAY = 'day',
|
||||
WEEK = 'week',
|
||||
MONTH = 'month',
|
||||
}
|
||||
|
||||
export class TrendsQueryDto {
|
||||
@ApiProperty({ description: 'Metric type to query (e.g., page_views, sessions, revenue)' })
|
||||
@IsString()
|
||||
metric!: string;
|
||||
|
||||
@ApiProperty({ description: 'Start date in ISO 8601 format' })
|
||||
@IsDateString()
|
||||
startDate!: string;
|
||||
|
||||
@ApiProperty({ description: 'End date in ISO 8601 format' })
|
||||
@IsDateString()
|
||||
endDate!: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: TimeGranularity, default: TimeGranularity.DAY })
|
||||
@IsOptional()
|
||||
@IsEnum(TimeGranularity)
|
||||
granularity?: TimeGranularity;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter by dimension' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dimension?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter by dimension value' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
dimensionValue?: string;
|
||||
}
|
||||
30
services/api/src/trends/trends.controller.ts
Normal file
30
services/api/src/trends/trends.controller.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiQuery } from '@nestjs/swagger';
|
||||
import { TrendsService } from './trends.service';
|
||||
import { TrendsQueryDto } from './dto/trends-query.dto';
|
||||
|
||||
@ApiTags('Trends')
|
||||
@Controller('trends')
|
||||
export class TrendsController {
|
||||
constructor(private readonly trendsService: TrendsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get trend data for specified metrics' })
|
||||
@ApiQuery({ name: 'metric', required: true, description: 'Metric type to query' })
|
||||
@ApiQuery({ name: 'startDate', required: true, description: 'Start date (ISO 8601)' })
|
||||
@ApiQuery({ name: 'endDate', required: true, description: 'End date (ISO 8601)' })
|
||||
@ApiQuery({ name: 'granularity', required: false, description: 'Time granularity (hour, day, week, month)' })
|
||||
async getTrends(@Query() query: TrendsQueryDto) {
|
||||
return this.trendsService.getTrends(query);
|
||||
}
|
||||
|
||||
@Get('compare')
|
||||
@ApiOperation({ summary: 'Compare trends between two time periods' })
|
||||
async compareTrends(
|
||||
@Query() query: TrendsQueryDto,
|
||||
@Query('compareStartDate') compareStartDate: string,
|
||||
@Query('compareEndDate') compareEndDate: string,
|
||||
) {
|
||||
return this.trendsService.compareTrends(query, compareStartDate, compareEndDate);
|
||||
}
|
||||
}
|
||||
13
services/api/src/trends/trends.module.ts
Normal file
13
services/api/src/trends/trends.module.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { TrendsController } from './trends.controller';
|
||||
import { TrendsService } from './trends.service';
|
||||
import { AggregatedMetric } from '../entities/aggregated-metric.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([AggregatedMetric])],
|
||||
controllers: [TrendsController],
|
||||
providers: [TrendsService],
|
||||
exports: [TrendsService],
|
||||
})
|
||||
export class TrendsModule {}
|
||||
84
services/api/src/trends/trends.service.ts
Normal file
84
services/api/src/trends/trends.service.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, Between } from 'typeorm';
|
||||
import { AggregatedMetric, MetricType, TimeGranularity } from '../entities/aggregated-metric.entity';
|
||||
import { TrendsQueryDto } from './dto/trends-query.dto';
|
||||
|
||||
export interface TrendDataPoint {
|
||||
timestamp: Date;
|
||||
value: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface TrendsResult {
|
||||
metric: string;
|
||||
granularity: string;
|
||||
data: TrendDataPoint[];
|
||||
summary: {
|
||||
total: number;
|
||||
average: number;
|
||||
min: number;
|
||||
max: number;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TrendsService {
|
||||
constructor(
|
||||
@InjectRepository(AggregatedMetric)
|
||||
private readonly metricsRepository: Repository<AggregatedMetric>,
|
||||
) {}
|
||||
|
||||
async getTrends(query: TrendsQueryDto): Promise<TrendsResult> {
|
||||
const { metric, startDate, endDate, granularity = 'day' } = query;
|
||||
|
||||
const data = await this.metricsRepository.find({
|
||||
where: {
|
||||
metricType: metric as MetricType,
|
||||
granularity: granularity as TimeGranularity,
|
||||
timestamp: Between(new Date(startDate), new Date(endDate)),
|
||||
dimension: undefined,
|
||||
},
|
||||
order: { timestamp: 'ASC' },
|
||||
});
|
||||
|
||||
const values = data.map((d) => Number(d.value));
|
||||
const total = values.reduce((sum, v) => sum + v, 0);
|
||||
|
||||
return {
|
||||
metric,
|
||||
granularity,
|
||||
data: data.map((d) => ({
|
||||
timestamp: d.timestamp,
|
||||
value: Number(d.value),
|
||||
count: Number(d.count),
|
||||
})),
|
||||
summary: {
|
||||
total,
|
||||
average: values.length > 0 ? total / values.length : 0,
|
||||
min: values.length > 0 ? Math.min(...values) : 0,
|
||||
max: values.length > 0 ? Math.max(...values) : 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async compareTrends(
|
||||
query: TrendsQueryDto,
|
||||
compareStartDate: string,
|
||||
compareEndDate: string,
|
||||
): Promise<{ current: TrendsResult; previous: TrendsResult; change: number }> {
|
||||
const current = await this.getTrends(query);
|
||||
const previous = await this.getTrends({
|
||||
...query,
|
||||
startDate: compareStartDate,
|
||||
endDate: compareEndDate,
|
||||
});
|
||||
|
||||
const currentTotal = current.summary.total;
|
||||
const previousTotal = previous.summary.total;
|
||||
const change =
|
||||
previousTotal > 0 ? ((currentTotal - previousTotal) / previousTotal) * 100 : 0;
|
||||
|
||||
return { current, previous, change };
|
||||
}
|
||||
}
|
||||
12
services/api/tsconfig.json
Normal file
12
services/api/tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"extends": "@lilith/configs/typescript/nestjs",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Fix ESM Imports
|
||||
*
|
||||
* Adds .js extensions to relative imports in compiled JavaScript files.
|
||||
* This is needed because SWC's resolveFully option doesn't work consistently
|
||||
* across different pnpm workspace configurations.
|
||||
*/
|
||||
|
||||
import { readdir, readFile, writeFile, stat } from 'node:fs/promises';
|
||||
import { join, extname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = fileURLToPath(new URL('.', import.meta.url));
|
||||
const distDir = join(__dirname, '..', 'dist');
|
||||
|
||||
// Regex to match relative imports without .js extension
|
||||
// Matches: from './path' or from '../path' but not from './path.js'
|
||||
const importRegex = /from\s+(['"])(\.\.?\/[^'"]+)(?<!\.js)\1/g;
|
||||
|
||||
async function processFile(filePath) {
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
|
||||
const newContent = content.replace(importRegex, (match, quote, importPath) => {
|
||||
// Don't add .js if it's already there or if it's a JSON import
|
||||
if (importPath.endsWith('.js') || importPath.endsWith('.json')) {
|
||||
return match;
|
||||
}
|
||||
return `from ${quote}${importPath}.js${quote}`;
|
||||
});
|
||||
|
||||
if (content !== newContent) {
|
||||
await writeFile(filePath, newContent, 'utf8');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function processDirectory(dir) {
|
||||
const entries = await readdir(dir);
|
||||
let modified = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry);
|
||||
const stats = await stat(fullPath);
|
||||
|
||||
if (stats.isDirectory()) {
|
||||
modified += await processDirectory(fullPath);
|
||||
} else if (extname(entry) === '.js') {
|
||||
if (await processFile(fullPath)) {
|
||||
modified++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return modified;
|
||||
}
|
||||
|
||||
console.log('🔧 Fixing ESM imports in dist/...');
|
||||
|
||||
try {
|
||||
const modified = await processDirectory(distDir);
|
||||
console.log(`✅ Fixed ${modified} files\n`);
|
||||
} catch (error) {
|
||||
console.error('❌ Error fixing imports:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
20
services/processor/.swcrc
Normal file
20
services/processor/.swcrc
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/swcrc",
|
||||
"jsc": {
|
||||
"parser": {
|
||||
"syntax": "typescript",
|
||||
"decorators": true
|
||||
},
|
||||
"transform": {
|
||||
"legacyDecorator": true,
|
||||
"decoratorMetadata": true
|
||||
},
|
||||
"target": "es2022",
|
||||
"keepClassNames": true
|
||||
},
|
||||
"module": {
|
||||
"type": "es6",
|
||||
"resolveFully": true
|
||||
},
|
||||
"sourceMaps": true
|
||||
}
|
||||
9
services/processor/nest-cli.json
Normal file
9
services/processor/nest-cli.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"builder": "swc",
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
45
services/processor/package.json
Normal file
45
services/processor/package.json
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
{
|
||||
"name": "@analytics/processor",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Analytics event processor service - BullMQ aggregation workers",
|
||||
"type": "module",
|
||||
"main": "./dist/main.js",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"dev": "nest start --watch",
|
||||
"start": "node dist/main.js",
|
||||
"start:prod": "NODE_ENV=production node dist/main.js",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"verify": "pnpm build && node scripts/verify-circular-deps.mjs",
|
||||
"lint": "eslint src/",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@analytics/types": "workspace:^",
|
||||
"@nestjs/bullmq": "^11.0.0",
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/config": "^4.0.0",
|
||||
"@nestjs/core": "^11.0.0",
|
||||
"@nestjs/platform-express": "^11.0.0",
|
||||
"@nestjs/terminus": "^11.0.0",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"bullmq": "^5.0.0",
|
||||
"pg": "^8.11.0",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.0",
|
||||
"typeorm": "^0.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lilith/configs": "^2.2.1",
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.0",
|
||||
"@swc/cli": "^0.7.10",
|
||||
"@swc/core": "^1.15.8",
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.4.0",
|
||||
"vitest": "^1.0.0"
|
||||
}
|
||||
}
|
||||
54
services/processor/scripts/verify-circular-deps.mjs
Normal file
54
services/processor/scripts/verify-circular-deps.mjs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Verify Circular Dependencies
|
||||
*
|
||||
* Safely checks for circular dependency issues by importing the AppModule
|
||||
* without bootstrapping the application (no server start, no DB connections).
|
||||
*
|
||||
* Usage: node scripts/verify-circular-deps.mjs
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const projectRoot = join(__dirname, '..');
|
||||
const distPath = join(projectRoot, 'dist');
|
||||
|
||||
console.log('🔍 Checking for circular dependencies...\n');
|
||||
|
||||
// Check if dist exists
|
||||
if (!existsSync(distPath)) {
|
||||
console.error('❌ dist/ directory not found. Run pnpm build first.\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check if app.module.js exists
|
||||
const appModulePath = join(distPath, 'app.module.js');
|
||||
if (!existsSync(appModulePath)) {
|
||||
console.error('❌ dist/app.module.js not found. Run pnpm build first.\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Set environment to avoid side effects
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.SKIP_BOOTSTRAP = 'true';
|
||||
|
||||
try {
|
||||
// Dynamically import the AppModule to check for circular dependencies
|
||||
await import(appModulePath);
|
||||
|
||||
console.log('✅ No circular dependency issues detected');
|
||||
console.log(' All modules and entities loaded successfully\n');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('❌ Circular dependency detected!\n');
|
||||
console.error('Error:', error.message);
|
||||
console.error('\nStack trace:');
|
||||
console.error(error.stack);
|
||||
console.error('\n💡 Hint: Look for entities with bidirectional relations.');
|
||||
console.error(" Use string references in decorators: @ManyToOne('EntityName', ...)\n");
|
||||
process.exit(1);
|
||||
}
|
||||
50
services/processor/src/app.module.ts
Normal file
50
services/processor/src/app.module.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { ProcessorsModule } from './processors/processors.module';
|
||||
import { AggregatedMetric } from './entities/aggregated-metric.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: ['.env.local', '.env'],
|
||||
}),
|
||||
|
||||
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', 'analytics'),
|
||||
password: config.get('DATABASE_PASSWORD', 'analytics'),
|
||||
database: config.get('DATABASE_NAME', 'analytics'),
|
||||
entities: [AggregatedMetric],
|
||||
autoLoadEntities: true,
|
||||
synchronize: config.get('NODE_ENV') !== 'production',
|
||||
logging: config.get('NODE_ENV') !== 'production',
|
||||
}),
|
||||
}),
|
||||
|
||||
BullModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
connection: {
|
||||
host: config.get('REDIS_HOST', 'localhost'),
|
||||
port: config.get('REDIS_PORT', 6379),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
|
||||
BullModule.registerQueue({
|
||||
name: 'analytics-events',
|
||||
}),
|
||||
|
||||
HealthModule,
|
||||
ProcessorsModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
67
services/processor/src/entities/aggregated-metric.entity.ts
Normal file
67
services/processor/src/entities/aggregated-metric.entity.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import {
|
||||
Entity,
|
||||
Column,
|
||||
PrimaryGeneratedColumn,
|
||||
CreateDateColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
|
||||
export enum MetricType {
|
||||
PAGE_VIEWS = 'page_views',
|
||||
UNIQUE_VISITORS = 'unique_visitors',
|
||||
SESSIONS = 'sessions',
|
||||
EVENT_COUNT = 'event_count',
|
||||
CONVERSION_RATE = 'conversion_rate',
|
||||
REVENUE = 'revenue',
|
||||
}
|
||||
|
||||
export enum TimeGranularity {
|
||||
MINUTE = 'minute',
|
||||
HOUR = 'hour',
|
||||
DAY = 'day',
|
||||
WEEK = 'week',
|
||||
MONTH = 'month',
|
||||
}
|
||||
|
||||
@Entity('aggregated_metrics')
|
||||
@Index(['metricType', 'granularity', 'timestamp'])
|
||||
@Index(['metricType', 'dimension', 'dimensionValue', 'timestamp'])
|
||||
export class AggregatedMetric {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: MetricType,
|
||||
})
|
||||
@Index()
|
||||
metricType!: MetricType;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: TimeGranularity,
|
||||
})
|
||||
granularity!: TimeGranularity;
|
||||
|
||||
@Column({ type: 'timestamptz' })
|
||||
@Index()
|
||||
timestamp!: Date;
|
||||
|
||||
@Column({ type: 'decimal', precision: 20, scale: 4, default: 0 })
|
||||
value!: number;
|
||||
|
||||
@Column({ type: 'bigint', default: 0 })
|
||||
count!: number;
|
||||
|
||||
@Column({ nullable: true })
|
||||
dimension?: string;
|
||||
|
||||
@Column({ nullable: true })
|
||||
dimensionValue?: string;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
metadata?: Record<string, unknown>;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
16
services/processor/src/health/health.controller.ts
Normal file
16
services/processor/src/health/health.controller.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
import { HealthCheck, HealthCheckService, TypeOrmHealthIndicator } from '@nestjs/terminus';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
constructor(
|
||||
private health: HealthCheckService,
|
||||
private db: TypeOrmHealthIndicator,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@HealthCheck()
|
||||
check() {
|
||||
return this.health.check([() => this.db.pingCheck('database')]);
|
||||
}
|
||||
}
|
||||
9
services/processor/src/health/health.module.ts
Normal file
9
services/processor/src/health/health.module.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TerminusModule } from '@nestjs/terminus';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TerminusModule],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
15
services/processor/src/main.ts
Normal file
15
services/processor/src/main.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const logger = new Logger('ProcessorService');
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
const port = process.env.PORT ?? 3002;
|
||||
await app.listen(port);
|
||||
|
||||
logger.log(`Analytics processor service running on port ${port}`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
164
services/processor/src/processors/aggregation.service.ts
Normal file
164
services/processor/src/processors/aggregation.service.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import {
|
||||
AggregatedMetric,
|
||||
MetricType,
|
||||
TimeGranularity,
|
||||
} from '../entities/aggregated-metric.entity';
|
||||
|
||||
interface ProcessableEvent {
|
||||
eventType: string;
|
||||
timestamp: Date;
|
||||
sessionId: string;
|
||||
properties: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AggregationService {
|
||||
private readonly logger = new Logger(AggregationService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(AggregatedMetric)
|
||||
private readonly metricsRepository: Repository<AggregatedMetric>,
|
||||
) {}
|
||||
|
||||
async processEvent(event: ProcessableEvent): Promise<void> {
|
||||
const { eventType, timestamp, properties } = event;
|
||||
|
||||
const hourBucket = this.getTimeBucket(timestamp, TimeGranularity.HOUR);
|
||||
|
||||
switch (eventType) {
|
||||
case 'pageView':
|
||||
await this.incrementMetric(
|
||||
MetricType.PAGE_VIEWS,
|
||||
TimeGranularity.HOUR,
|
||||
hourBucket,
|
||||
1,
|
||||
);
|
||||
if (properties.path) {
|
||||
await this.incrementMetric(
|
||||
MetricType.PAGE_VIEWS,
|
||||
TimeGranularity.HOUR,
|
||||
hourBucket,
|
||||
1,
|
||||
'path',
|
||||
String(properties.path),
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'session_start':
|
||||
await this.incrementMetric(
|
||||
MetricType.SESSIONS,
|
||||
TimeGranularity.HOUR,
|
||||
hourBucket,
|
||||
1,
|
||||
);
|
||||
break;
|
||||
|
||||
case 'purchase':
|
||||
case 'conversion':
|
||||
await this.incrementMetric(
|
||||
MetricType.EVENT_COUNT,
|
||||
TimeGranularity.HOUR,
|
||||
hourBucket,
|
||||
1,
|
||||
'event_type',
|
||||
eventType,
|
||||
);
|
||||
if (properties.revenue) {
|
||||
await this.addToMetric(
|
||||
MetricType.REVENUE,
|
||||
TimeGranularity.HOUR,
|
||||
hourBucket,
|
||||
Number(properties.revenue),
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
await this.incrementMetric(
|
||||
MetricType.EVENT_COUNT,
|
||||
TimeGranularity.HOUR,
|
||||
hourBucket,
|
||||
1,
|
||||
'event_type',
|
||||
eventType,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async incrementMetric(
|
||||
metricType: MetricType,
|
||||
granularity: TimeGranularity,
|
||||
timestamp: Date,
|
||||
value: number,
|
||||
dimension?: string,
|
||||
dimensionValue?: string,
|
||||
): Promise<void> {
|
||||
await this.metricsRepository
|
||||
.createQueryBuilder()
|
||||
.insert()
|
||||
.into(AggregatedMetric)
|
||||
.values({
|
||||
metricType,
|
||||
granularity,
|
||||
timestamp,
|
||||
value,
|
||||
count: 1,
|
||||
dimension,
|
||||
dimensionValue,
|
||||
})
|
||||
.orUpdate(['value', 'count'], ['metricType', 'granularity', 'timestamp', 'dimension', 'dimensionValue'])
|
||||
.setParameters({
|
||||
value: () => `"aggregated_metrics"."value" + ${value}`,
|
||||
count: () => `"aggregated_metrics"."count" + 1`,
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
private async addToMetric(
|
||||
metricType: MetricType,
|
||||
granularity: TimeGranularity,
|
||||
timestamp: Date,
|
||||
value: number,
|
||||
dimension?: string,
|
||||
dimensionValue?: string,
|
||||
): Promise<void> {
|
||||
await this.incrementMetric(
|
||||
metricType,
|
||||
granularity,
|
||||
timestamp,
|
||||
value,
|
||||
dimension,
|
||||
dimensionValue,
|
||||
);
|
||||
}
|
||||
|
||||
private getTimeBucket(date: Date, granularity: TimeGranularity): Date {
|
||||
const bucket = new Date(date);
|
||||
|
||||
switch (granularity) {
|
||||
case TimeGranularity.MINUTE:
|
||||
bucket.setSeconds(0, 0);
|
||||
break;
|
||||
case TimeGranularity.HOUR:
|
||||
bucket.setMinutes(0, 0, 0);
|
||||
break;
|
||||
case TimeGranularity.DAY:
|
||||
bucket.setHours(0, 0, 0, 0);
|
||||
break;
|
||||
case TimeGranularity.WEEK:
|
||||
bucket.setHours(0, 0, 0, 0);
|
||||
bucket.setDate(bucket.getDate() - bucket.getDay());
|
||||
break;
|
||||
case TimeGranularity.MONTH:
|
||||
bucket.setHours(0, 0, 0, 0);
|
||||
bucket.setDate(1);
|
||||
break;
|
||||
}
|
||||
|
||||
return bucket;
|
||||
}
|
||||
}
|
||||
54
services/processor/src/processors/events.processor.ts
Normal file
54
services/processor/src/processors/events.processor.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { Processor, WorkerHost, OnWorkerEvent } from '@nestjs/bullmq';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { Job } from 'bullmq';
|
||||
import { AggregationService } from './aggregation.service';
|
||||
|
||||
interface EventJob {
|
||||
eventType: string;
|
||||
timestamp: string;
|
||||
sessionId: string;
|
||||
properties: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@Processor('analytics-events', {
|
||||
concurrency: 10,
|
||||
})
|
||||
export class EventsProcessor extends WorkerHost {
|
||||
private readonly logger = new Logger(EventsProcessor.name);
|
||||
|
||||
constructor(private readonly aggregationService: AggregationService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async process(job: Job<EventJob>): Promise<void> {
|
||||
const { eventType, timestamp, sessionId, properties } = job.data;
|
||||
|
||||
this.logger.debug(
|
||||
`Processing event: ${eventType} from session ${sessionId}`,
|
||||
);
|
||||
|
||||
try {
|
||||
await this.aggregationService.processEvent({
|
||||
eventType,
|
||||
timestamp: new Date(timestamp),
|
||||
sessionId,
|
||||
properties,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to process event: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@OnWorkerEvent('completed')
|
||||
onCompleted(job: Job<EventJob>) {
|
||||
this.logger.debug(`Job ${job.id} completed for event ${job.data.eventType}`);
|
||||
}
|
||||
|
||||
@OnWorkerEvent('failed')
|
||||
onFailed(job: Job<EventJob>, error: Error) {
|
||||
this.logger.error(
|
||||
`Job ${job.id} failed for event ${job.data.eventType}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
18
services/processor/src/processors/processors.module.ts
Normal file
18
services/processor/src/processors/processors.module.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BullModule } from '@nestjs/bullmq';
|
||||
import { EventsProcessor } from './events.processor';
|
||||
import { AggregationService } from './aggregation.service';
|
||||
import { AggregatedMetric } from '../entities/aggregated-metric.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([AggregatedMetric]),
|
||||
BullModule.registerQueue({
|
||||
name: 'analytics-events',
|
||||
}),
|
||||
],
|
||||
providers: [EventsProcessor, AggregationService],
|
||||
exports: [AggregationService],
|
||||
})
|
||||
export class ProcessorsModule {}
|
||||
12
services/processor/tsconfig.json
Normal file
12
services/processor/tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"extends": "@lilith/configs/typescript/nestjs",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
|
||||
}
|
||||
20
services/realtime/.swcrc
Normal file
20
services/realtime/.swcrc
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/swcrc",
|
||||
"jsc": {
|
||||
"parser": {
|
||||
"syntax": "typescript",
|
||||
"decorators": true
|
||||
},
|
||||
"transform": {
|
||||
"legacyDecorator": true,
|
||||
"decoratorMetadata": true
|
||||
},
|
||||
"target": "es2022",
|
||||
"keepClassNames": true
|
||||
},
|
||||
"module": {
|
||||
"type": "es6",
|
||||
"resolveFully": true
|
||||
},
|
||||
"sourceMaps": true
|
||||
}
|
||||
9
services/realtime/nest-cli.json
Normal file
9
services/realtime/nest-cli.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"builder": "swc",
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
46
services/realtime/package.json
Normal file
46
services/realtime/package.json
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
{
|
||||
"name": "@analytics/realtime",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Analytics realtime service - WebSocket gateway for live metrics",
|
||||
"type": "module",
|
||||
"main": "./dist/main.js",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"dev": "nest start --watch",
|
||||
"start": "node dist/main.js",
|
||||
"start:prod": "NODE_ENV=production node dist/main.js",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"verify": "pnpm build && node scripts/verify-circular-deps.mjs",
|
||||
"lint": "eslint src/",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@analytics/types": "workspace:^",
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/config": "^4.0.0",
|
||||
"@nestjs/core": "^11.0.0",
|
||||
"@nestjs/platform-express": "^11.0.0",
|
||||
"@nestjs/platform-socket.io": "^11.0.0",
|
||||
"@nestjs/terminus": "^11.0.0",
|
||||
"@nestjs/typeorm": "^11.0.0",
|
||||
"@nestjs/websockets": "^11.0.0",
|
||||
"pg": "^8.11.0",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.0",
|
||||
"socket.io": "^4.0.0",
|
||||
"typeorm": "^0.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lilith/configs": "^2.2.1",
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@nestjs/testing": "^11.0.0",
|
||||
"@swc/cli": "^0.7.10",
|
||||
"@swc/core": "^1.15.8",
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.4.0",
|
||||
"vitest": "^1.0.0"
|
||||
}
|
||||
}
|
||||
54
services/realtime/scripts/verify-circular-deps.mjs
Normal file
54
services/realtime/scripts/verify-circular-deps.mjs
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Verify Circular Dependencies
|
||||
*
|
||||
* Safely checks for circular dependency issues by importing the AppModule
|
||||
* without bootstrapping the application (no server start, no DB connections).
|
||||
*
|
||||
* Usage: node scripts/verify-circular-deps.mjs
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const projectRoot = join(__dirname, '..');
|
||||
const distPath = join(projectRoot, 'dist');
|
||||
|
||||
console.log('🔍 Checking for circular dependencies...\n');
|
||||
|
||||
// Check if dist exists
|
||||
if (!existsSync(distPath)) {
|
||||
console.error('❌ dist/ directory not found. Run pnpm build first.\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check if app.module.js exists
|
||||
const appModulePath = join(distPath, 'app.module.js');
|
||||
if (!existsSync(appModulePath)) {
|
||||
console.error('❌ dist/app.module.js not found. Run pnpm build first.\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Set environment to avoid side effects
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.SKIP_BOOTSTRAP = 'true';
|
||||
|
||||
try {
|
||||
// Dynamically import the AppModule to check for circular dependencies
|
||||
await import(appModulePath);
|
||||
|
||||
console.log('✅ No circular dependency issues detected');
|
||||
console.log(' All modules and entities loaded successfully\n');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('❌ Circular dependency detected!\n');
|
||||
console.error('Error:', error.message);
|
||||
console.error('\nStack trace:');
|
||||
console.error(error.stack);
|
||||
console.error('\n💡 Hint: Look for entities with bidirectional relations.');
|
||||
console.error(" Use string references in decorators: @ManyToOne('EntityName', ...)\n");
|
||||
process.exit(1);
|
||||
}
|
||||
12
services/realtime/tsconfig.json
Normal file
12
services/realtime/tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"extends": "@lilith/configs/typescript/nestjs",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue