diff --git a/prisma/migrations/20260730100000_add_analytics_events/migration.sql b/prisma/migrations/20260730100000_add_analytics_events/migration.sql new file mode 100644 index 0000000..1be661d --- /dev/null +++ b/prisma/migrations/20260730100000_add_analytics_events/migration.sql @@ -0,0 +1,27 @@ +-- CreateTable +CREATE TABLE "analytics_events" ( + "id" TEXT NOT NULL, + "eventType" TEXT NOT NULL, + "userId" TEXT, + "sessionId" TEXT, + "path" TEXT, + "properties" JSONB, + "userAgent" TEXT, + "ipAddress" TEXT, + "occurredAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "analytics_events_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "analytics_events_eventType_idx" ON "analytics_events"("eventType"); + +-- CreateIndex +CREATE INDEX "analytics_events_userId_idx" ON "analytics_events"("userId"); + +-- CreateIndex +CREATE INDEX "analytics_events_occurredAt_idx" ON "analytics_events"("occurredAt"); + +-- CreateIndex +CREATE INDEX "analytics_events_eventType_occurredAt_idx" ON "analytics_events"("eventType", "occurredAt"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 81d2997..6fec9a1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1230,6 +1230,26 @@ model AdminAuditLog { } // ============================================================ +// ANALYTICS EVENTS +// ============================================================ + +model AnalyticsEvent { + id String @id @default(uuid()) + eventType String + userId String? + sessionId String? + path String? + properties Json? + userAgent String? + ipAddress String? + occurredAt DateTime @default(now()) + createdAt DateTime @default(now()) + + @@index([eventType]) + @@index([userId]) + @@index([occurredAt]) + @@index([eventType, occurredAt]) + @@map("analytics_events") // COURSE DATA RESTORE // ============================================================ diff --git a/src/app.module.ts b/src/app.module.ts index a00af8e..ac9ba1e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -29,6 +29,7 @@ import { ModerationModule } from './modules/moderation/moderation.module'; import { GamificationModule } from './modules/gamification/gamification.module'; import { ReferralsModule } from './modules/referrals/referrals.module'; import { AuditLogModule } from './modules/audit-log/audit-log.module'; +import { AnalyticsModule } from './modules/analytics/analytics.module'; import { BackupsModule } from './modules/backups/backups.module'; import { UploadsModule } from './modules/uploads/uploads.module'; @@ -74,6 +75,7 @@ import { UploadsModule } from './modules/uploads/uploads.module'; GamificationModule, ReferralsModule, AuditLogModule, + AnalyticsModule, BackupsModule, UploadsModule, ], diff --git a/src/modules/analytics/analytics.controller.ts b/src/modules/analytics/analytics.controller.ts new file mode 100644 index 0000000..de5a4c4 --- /dev/null +++ b/src/modules/analytics/analytics.controller.ts @@ -0,0 +1,93 @@ +import { + Body, + Controller, + Get, + Headers, + HttpCode, + HttpStatus, + Post, + Query, + Req, + UseGuards, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiQuery, + ApiTags, +} from '@nestjs/swagger'; +import { UserRole } from '@prisma/client'; +import { Request } from 'express'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { Roles, RolesGuard } from '../../common/guards/roles.guard'; +import { CurrentUser } from '../../common/decorators/current-user.decorator'; +import { AnalyticsService } from './analytics.service'; +import { + BatchTrackAnalyticsEventsDto, + QueryAnalyticsEventsDto, + TrackAnalyticsEventDto, +} from './dto/track-analytics-event.dto'; + +@ApiTags('analytics') +@Controller('analytics') +export class AnalyticsController { + constructor(private readonly analyticsService: AnalyticsService) {} + + @Post('events') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ summary: 'Ingest a single analytics event' }) + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + trackEvent( + @Body() dto: TrackAnalyticsEventDto, + @CurrentUser('id') userId: string, + @Req() req: Request, + @Headers('user-agent') userAgent?: string, + ) { + return this.analyticsService.trackEvent(dto, { + userId: dto.userId ?? userId, + ipAddress: req.ip, + userAgent: dto.userAgent ?? userAgent, + }); + } + + @Post('events/batch') + @HttpCode(HttpStatus.CREATED) + @ApiOperation({ + summary: 'Batch-ingest analytics events for high-frequency tracking', + }) + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + trackEventsBatch( + @Body() dto: BatchTrackAnalyticsEventsDto, + @CurrentUser('id') userId: string, + @Req() req: Request, + @Headers('user-agent') userAgent?: string, + ) { + return this.analyticsService.trackEventsBatch(dto, { + userId, + ipAddress: req.ip, + userAgent, + }); + } + + @Get('volume') + @ApiBearerAuth() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Get event volume aggregated by event type (admin)' }) + @ApiQuery({ name: 'from', required: false }) + @ApiQuery({ name: 'to', required: false }) + getVolume(@Query('from') from?: string, @Query('to') to?: string) { + return this.analyticsService.getVolumeByEventType(from, to); + } + + @Get('events') + @ApiBearerAuth() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @ApiOperation({ summary: 'Query raw analytics events (admin only)' }) + queryRawEvents(@Query() query: QueryAnalyticsEventsDto) { + return this.analyticsService.queryRawEvents(query); + } +} diff --git a/src/modules/analytics/analytics.module.ts b/src/modules/analytics/analytics.module.ts new file mode 100644 index 0000000..d1b0000 --- /dev/null +++ b/src/modules/analytics/analytics.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { AnalyticsController } from './analytics.controller'; +import { AnalyticsService } from './analytics.service'; + +@Module({ + controllers: [AnalyticsController], + providers: [AnalyticsService], + exports: [AnalyticsService], +}) +export class AnalyticsModule {} diff --git a/src/modules/analytics/analytics.service.spec.ts b/src/modules/analytics/analytics.service.spec.ts new file mode 100644 index 0000000..fc3e23e --- /dev/null +++ b/src/modules/analytics/analytics.service.spec.ts @@ -0,0 +1,153 @@ +import { BadRequestException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { PrismaService } from '../../common/prisma/prisma.service'; +import { AnalyticsService } from './analytics.service'; + +describe('AnalyticsService', () => { + let service: AnalyticsService; + + const mockPrisma = { + analyticsEvent: { + create: jest.fn(), + createMany: jest.fn(), + groupBy: jest.fn(), + findMany: jest.fn(), + count: jest.fn(), + }, + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AnalyticsService, + { provide: PrismaService, useValue: mockPrisma }, + ], + }).compile(); + + service = module.get(AnalyticsService); + jest.clearAllMocks(); + }); + + describe('trackEvent()', () => { + it('ingests a valid analytics event', async () => { + mockPrisma.analyticsEvent.create.mockResolvedValue({ + id: 'evt-1', + eventType: 'page_view', + path: '/courses', + }); + + const result = await service.trackEvent( + { eventType: 'page_view', path: '/courses' }, + { userId: 'user-1', ipAddress: '127.0.0.1' }, + ); + + expect(result.id).toBe('evt-1'); + expect(mockPrisma.analyticsEvent.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + eventType: 'page_view', + path: '/courses', + userId: 'user-1', + ipAddress: '127.0.0.1', + }), + }); + }); + + it('rejects empty eventType', async () => { + await expect( + service.trackEvent({ eventType: ' ' } as any), + ).rejects.toThrow(BadRequestException); + }); + + it('rejects non-object properties', async () => { + await expect( + service.trackEvent({ + eventType: 'click', + properties: ['bad'] as any, + }), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('trackEventsBatch()', () => { + it('batch-ingests high-frequency events', async () => { + mockPrisma.analyticsEvent.createMany.mockResolvedValue({ count: 3 }); + + const result = await service.trackEventsBatch({ + events: [ + { eventType: 'click', path: '/a' }, + { eventType: 'click', path: '/b' }, + { eventType: 'page_view', path: '/c' }, + ], + }); + + expect(result).toEqual({ ingested: 3, requested: 3 }); + expect(mockPrisma.analyticsEvent.createMany).toHaveBeenCalledTimes(1); + }); + + it('rejects empty batches', async () => { + await expect( + service.trackEventsBatch({ events: [] }), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('getVolumeByEventType()', () => { + it('returns volume metrics per event type', async () => { + mockPrisma.analyticsEvent.groupBy.mockResolvedValue([ + { eventType: 'page_view', _count: { _all: 10 } }, + { eventType: 'click', _count: { _all: 4 } }, + ]); + + const result = await service.getVolumeByEventType(); + expect(result.total).toBe(14); + expect(result.byEventType).toEqual([ + { eventType: 'page_view', count: 10 }, + { eventType: 'click', count: 4 }, + ]); + }); + }); + + describe('queryRawEvents()', () => { + it('returns paginated raw events for admins', async () => { + mockPrisma.analyticsEvent.findMany.mockResolvedValue([ + { id: 'evt-1', eventType: 'page_view' }, + ]); + mockPrisma.analyticsEvent.count.mockResolvedValue(1); + + const result = await service.queryRawEvents({ + eventType: 'page_view', + page: 1, + limit: 10, + }); + + expect(result.data).toHaveLength(1); + expect(result.meta).toEqual({ + total: 1, + page: 1, + limit: 10, + totalPages: 1, + }); + }); + }); + + describe('validateEventPayload()', () => { + it('allows known and custom event types', () => { + expect(() => + service.validateEventPayload({ eventType: 'page_view' }), + ).not.toThrow(); + expect(() => + service.validateEventPayload({ eventType: 'custom.widget_open' }), + ).not.toThrow(); + }); + + it('rejects oversized properties payloads', () => { + const huge = { blob: 'x'.repeat(20_000) }; + expect(() => + service.validateEventPayload({ + eventType: 'click', + properties: huge, + }), + ).toThrow(BadRequestException); + }); + }); +}); diff --git a/src/modules/analytics/analytics.service.ts b/src/modules/analytics/analytics.service.ts new file mode 100644 index 0000000..aa397ab --- /dev/null +++ b/src/modules/analytics/analytics.service.ts @@ -0,0 +1,193 @@ +import { + BadRequestException, + Injectable, + Logger, +} from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../../common/prisma/prisma.service'; +import { + ANALYTICS_EVENT_TYPES, + BatchTrackAnalyticsEventsDto, + QueryAnalyticsEventsDto, + TrackAnalyticsEventDto, +} from './dto/track-analytics-event.dto'; + +@Injectable() +export class AnalyticsService { + private readonly logger = new Logger(AnalyticsService.name); + + constructor(private readonly prisma: PrismaService) {} + + /** + * Ingest a single analytics event after schema validation. + */ + async trackEvent( + dto: TrackAnalyticsEventDto, + meta?: { ipAddress?: string; userAgent?: string; userId?: string }, + ) { + this.validateEventPayload(dto); + + const event = await this.prisma.analyticsEvent.create({ + data: this.toCreateData(dto, meta), + }); + + this.logger.debug(`Analytics event stored: ${event.eventType} (${event.id})`); + return event; + } + + /** + * Batch-ingest high-frequency analytics events (up to 500 per request). + */ + async trackEventsBatch( + dto: BatchTrackAnalyticsEventsDto, + meta?: { ipAddress?: string; userAgent?: string; userId?: string }, + ) { + if (!dto.events?.length) { + throw new BadRequestException('events array must not be empty'); + } + + for (const event of dto.events) { + this.validateEventPayload(event); + } + + const result = await this.prisma.analyticsEvent.createMany({ + data: dto.events.map((event) => this.toCreateData(event, meta)), + }); + + this.logger.log(`Analytics batch ingested: ${result.count} events`); + return { + ingested: result.count, + requested: dto.events.length, + }; + } + + /** + * Aggregate event volume grouped by event type. + */ + async getVolumeByEventType(from?: string, to?: string) { + const where: Prisma.AnalyticsEventWhereInput = {}; + if (from || to) { + where.occurredAt = {}; + if (from) where.occurredAt.gte = new Date(from); + if (to) where.occurredAt.lte = new Date(to); + } + + const grouped = await this.prisma.analyticsEvent.groupBy({ + by: ['eventType'], + where, + _count: { _all: true }, + }); + + const byEventType = grouped + .map((row) => ({ + eventType: row.eventType, + count: row._count._all, + })) + .sort((a, b) => b.count - a.count); + + const total = byEventType.reduce((sum, row) => sum + row.count, 0); + + return { + total, + byEventType, + }; + } + + /** + * Admin-only raw event query with pagination and filters. + */ + async queryRawEvents(query: QueryAnalyticsEventsDto) { + const page = Math.max(1, query.page ?? 1); + const limit = Math.min(200, Math.max(1, query.limit ?? 50)); + const skip = (page - 1) * limit; + + const where: Prisma.AnalyticsEventWhereInput = {}; + if (query.eventType) where.eventType = query.eventType; + if (query.userId) where.userId = query.userId; + if (query.sessionId) where.sessionId = query.sessionId; + if (query.from || query.to) { + where.occurredAt = {}; + if (query.from) where.occurredAt.gte = new Date(query.from); + if (query.to) where.occurredAt.lte = new Date(query.to); + } + + const [data, total] = await Promise.all([ + this.prisma.analyticsEvent.findMany({ + where, + skip, + take: limit, + orderBy: { occurredAt: 'desc' }, + }), + this.prisma.analyticsEvent.count({ where }), + ]); + + return { + data, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit) || 0, + }, + }; + } + + /** + * Validate analytics payload schema beyond class-validator decorators. + */ + validateEventPayload(dto: TrackAnalyticsEventDto) { + if (!dto.eventType?.trim()) { + throw new BadRequestException('eventType is required'); + } + + if (dto.eventType.length > 100) { + throw new BadRequestException('eventType must be at most 100 characters'); + } + + if (dto.properties !== undefined && dto.properties !== null) { + if ( + typeof dto.properties !== 'object' || + Array.isArray(dto.properties) + ) { + throw new BadRequestException('properties must be a plain object'); + } + + let serialized: string; + try { + serialized = JSON.stringify(dto.properties); + } catch { + throw new BadRequestException('properties must be JSON-serializable'); + } + if (serialized.length > 16_384) { + throw new BadRequestException('properties payload is too large'); + } + } + + // Warn (don't fail) for unknown event types so custom events remain allowed + if ( + !ANALYTICS_EVENT_TYPES.includes( + dto.eventType as (typeof ANALYTICS_EVENT_TYPES)[number], + ) && + dto.eventType !== 'custom' && + !dto.eventType.startsWith('custom.') + ) { + this.logger.debug(`Non-standard analytics eventType: ${dto.eventType}`); + } + } + + private toCreateData( + dto: TrackAnalyticsEventDto, + meta?: { ipAddress?: string; userAgent?: string; userId?: string }, + ): Prisma.AnalyticsEventCreateManyInput { + return { + eventType: dto.eventType.trim(), + userId: dto.userId ?? meta?.userId ?? null, + sessionId: dto.sessionId ?? null, + path: dto.path ?? null, + properties: (dto.properties as Prisma.InputJsonValue) ?? Prisma.JsonNull, + userAgent: dto.userAgent ?? meta?.userAgent ?? null, + ipAddress: meta?.ipAddress ?? null, + occurredAt: dto.occurredAt ? new Date(dto.occurredAt) : new Date(), + }; + } +} diff --git a/src/modules/analytics/dto/track-analytics-event.dto.ts b/src/modules/analytics/dto/track-analytics-event.dto.ts new file mode 100644 index 0000000..5b3c74a --- /dev/null +++ b/src/modules/analytics/dto/track-analytics-event.dto.ts @@ -0,0 +1,124 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsDateString, + IsObject, + IsOptional, + IsString, + IsUUID, + MaxLength, + MinLength, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +/** Allowed high-level analytics event types */ +export const ANALYTICS_EVENT_TYPES = [ + 'page_view', + 'click', + 'course_view', + 'lesson_start', + 'lesson_complete', + 'enrollment_start', + 'search', + 'video_play', + 'video_pause', + 'custom', +] as const; + +export type AnalyticsEventType = (typeof ANALYTICS_EVENT_TYPES)[number] | string; + +export class TrackAnalyticsEventDto { + @ApiProperty({ + example: 'page_view', + description: 'Event type (e.g. page_view, click, course_view)', + }) + @IsString() + @MinLength(1) + @MaxLength(100) + eventType: string; + + @ApiPropertyOptional({ description: 'Authenticated user id when known' }) + @IsOptional() + @IsUUID() + userId?: string; + + @ApiPropertyOptional({ example: 'sess_abc123' }) + @IsOptional() + @IsString() + @MaxLength(128) + sessionId?: string; + + @ApiPropertyOptional({ example: '/courses/tailoring' }) + @IsOptional() + @IsString() + @MaxLength(500) + path?: string; + + @ApiPropertyOptional({ + example: { buttonId: 'enroll-cta', courseId: 'COURSE-1' }, + }) + @IsOptional() + @IsObject() + properties?: Record; + + @ApiPropertyOptional({ description: 'Client-side event timestamp (ISO)' }) + @IsOptional() + @IsDateString() + occurredAt?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(512) + userAgent?: string; +} + +export class BatchTrackAnalyticsEventsDto { + @ApiProperty({ type: [TrackAnalyticsEventDto] }) + @IsArray() + @ArrayMinSize(1) + @ArrayMaxSize(500) + @ValidateNested({ each: true }) + @Type(() => TrackAnalyticsEventDto) + events: TrackAnalyticsEventDto[]; +} + +export class QueryAnalyticsEventsDto { + @ApiPropertyOptional({ example: 'page_view' }) + @IsOptional() + @IsString() + eventType?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsUUID() + userId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + sessionId?: string; + + @ApiPropertyOptional({ description: 'ISO start datetime filter' }) + @IsOptional() + @IsDateString() + from?: string; + + @ApiPropertyOptional({ description: 'ISO end datetime filter' }) + @IsOptional() + @IsDateString() + to?: string; + + @ApiPropertyOptional({ default: 1 }) + @IsOptional() + @Type(() => Number) + page?: number; + + @ApiPropertyOptional({ default: 50 }) + @IsOptional() + @Type(() => Number) + limit?: number; +}