From a5f4f60bc103b62f7b1d2924bb0c2219414e614e Mon Sep 17 00:00:00 2001 From: xaxxoo Date: Wed, 30 Apr 2025 11:31:23 +0100 Subject: [PATCH] Add engagement social analytics DTOs, entities, interfaces, and caching service --- .../engagement-analytics.controller.ts | 79 ++++++ .../engagement-reporting.controller.ts | 64 +++++ .../dto/create-engagement.dto.ts | 23 ++ .../dto/engagement-query.dto.ts | 45 ++++ .../dto/engagement-report.dto.ts | 38 +++ .../entities/engagement.entity.ts | 49 ++++ .../engagement-analytics.interface.ts | 41 +++ .../interfaces/weighted-score.interface.ts | 57 +++++ .../repositories/engagement.repository.ts | 178 +++++++++++++ .../services/engagement-analytics.service.ts | 199 +++++++++++++++ .../services/engagement-reporting.service.ts | 235 ++++++++++++++++++ .../services/reputation-weighting.service.ts | 120 +++++++++ .../shared/cache/engagement-cache.service.ts | 81 ++++++ .../shared/utils/anomaly-detection.util.ts | 72 ++++++ 14 files changed, 1281 insertions(+) create mode 100644 apps/backend/src/social-engagement-analytics/enagagemanet-analytics/controllers/engagement-analytics.controller.ts create mode 100644 apps/backend/src/social-engagement-analytics/enagagemanet-analytics/controllers/engagement-reporting.controller.ts create mode 100644 apps/backend/src/social-engagement-analytics/enagagemanet-analytics/dto/create-engagement.dto.ts create mode 100644 apps/backend/src/social-engagement-analytics/enagagemanet-analytics/dto/engagement-query.dto.ts create mode 100644 apps/backend/src/social-engagement-analytics/enagagemanet-analytics/dto/engagement-report.dto.ts create mode 100644 apps/backend/src/social-engagement-analytics/enagagemanet-analytics/entities/engagement.entity.ts create mode 100644 apps/backend/src/social-engagement-analytics/enagagemanet-analytics/interfaces/engagement-analytics.interface.ts create mode 100644 apps/backend/src/social-engagement-analytics/enagagemanet-analytics/interfaces/weighted-score.interface.ts create mode 100644 apps/backend/src/social-engagement-analytics/enagagemanet-analytics/repositories/engagement.repository.ts create mode 100644 apps/backend/src/social-engagement-analytics/enagagemanet-analytics/services/engagement-analytics.service.ts create mode 100644 apps/backend/src/social-engagement-analytics/enagagemanet-analytics/services/engagement-reporting.service.ts create mode 100644 apps/backend/src/social-engagement-analytics/enagagemanet-analytics/services/reputation-weighting.service.ts create mode 100644 apps/backend/src/social-engagement-analytics/shared/cache/engagement-cache.service.ts create mode 100644 apps/backend/src/social-engagement-analytics/shared/utils/anomaly-detection.util.ts diff --git a/apps/backend/src/social-engagement-analytics/enagagemanet-analytics/controllers/engagement-analytics.controller.ts b/apps/backend/src/social-engagement-analytics/enagagemanet-analytics/controllers/engagement-analytics.controller.ts new file mode 100644 index 0000000..1754e32 --- /dev/null +++ b/apps/backend/src/social-engagement-analytics/enagagemanet-analytics/controllers/engagement-analytics.controller.ts @@ -0,0 +1,79 @@ +// src/engagement-analytics/controllers/engagement-analytics.controller.ts +import { Controller, Get, Post, Body, Query, Param, UseGuards, HttpException, HttpStatus, Logger, CacheTTL } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBody, ApiQuery, ApiParam } from '@nestjs/swagger'; +import { EngagementAnalyticsService } from '../services/engagement-analytics.service'; +import { CreateEngagementDto } from '../dto/create-engagement.dto'; +import { EngagementQueryDto, TimeFrame } from '../dto/engagement-query.dto'; +import { Engagement } from '../entities/engagement.entity'; +import { EngagementSummary, EngagementTimeSeries } from '../interfaces/engagement-analytics.interface'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; + +@ApiTags('engagement-analytics') +@Controller('engagement-analytics') +export class EngagementAnalyticsController { + private readonly logger = new Logger(EngagementAnalyticsController.name); + + constructor(private readonly analyticsService: EngagementAnalyticsService) {} + + @Post() + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'Record a new engagement' }) + @ApiResponse({ status: 201, description: 'Engagement recorded successfully', type: Engagement }) + @ApiBody({ type: CreateEngagementDto }) + async recordEngagement(@Body() createDto: CreateEngagementDto): Promise { + try { + return await this.analyticsService.recordEngagement(createDto); + } catch (error) { + this.logger.error(`Failed to record engagement: ${error.message}`, error.stack); + throw new HttpException('Failed to record engagement', HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @Get() + @CacheTTL(300) // 5 minute cache + @ApiOperation({ summary: 'Get engagement analytics' }) + @ApiResponse({ status: 200, description: 'Analytics retrieved successfully' }) + @ApiQuery({ name: 'contentId', required: false }) + @ApiQuery({ name: 'timeFrame', enum: TimeFrame, required: false }) + async getAnalytics(@Query() queryDto: EngagementQueryDto): Promise { + try { + return await this.analyticsService.getEngagementAnalytics(queryDto); + } catch (error) { + this.logger.error(`Failed to get analytics: ${error.message}`, error.stack); + throw new HttpException('Failed to retrieve analytics', HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @Get('time-series') + @CacheTTL(300) // 5 minute cache + @ApiOperation({ summary: 'Get engagement time series data' }) + @ApiResponse({ status: 200, description: 'Time series data retrieved successfully' }) + async getTimeSeriesData(@Query() queryDto: EngagementQueryDto): Promise { + try { + return await this.analyticsService.getTimeSeriesData(queryDto); + } catch (error) { + this.logger.error(`Failed to get time series data: ${error.message}`, error.stack); + throw new HttpException('Failed to retrieve time series data', HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @Get('content/:contentId') + @CacheTTL(300) // 5 minute cache + @ApiOperation({ summary: 'Get analytics for a specific content item' }) + @ApiResponse({ status: 200, description: 'Content analytics retrieved successfully' }) + @ApiParam({ name: 'contentId', required: true }) + async getContentAnalytics( + @Param('contentId') contentId: string, + @Query() queryDto: EngagementQueryDto, + ): Promise { + try { + return await this.analyticsService.getEngagementAnalytics({ + ...queryDto, + contentId, + }); + } catch (error) { + this.logger.error(`Failed to get content analytics: ${error.message}`, error.stack); + throw new HttpException('Failed to retrieve content analytics', HttpStatus.INTERNAL_SERVER_ERROR); + } + } +} \ No newline at end of file diff --git a/apps/backend/src/social-engagement-analytics/enagagemanet-analytics/controllers/engagement-reporting.controller.ts b/apps/backend/src/social-engagement-analytics/enagagemanet-analytics/controllers/engagement-reporting.controller.ts new file mode 100644 index 0000000..288d4cf --- /dev/null +++ b/apps/backend/src/social-engagement-analytics/enagagemanet-analytics/controllers/engagement-reporting.controller.ts @@ -0,0 +1,64 @@ +// src/engagement-analytics/controllers/engagement-reporting.controller.ts +import { Controller, Get, Post, Body, Query, Res, UseGuards, HttpException, HttpStatus, Logger } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiBody } from '@nestjs/swagger'; +import { EngagementReportingService } from '../services/engagement-reporting.service'; +import { EngagementReportDto, ReportFormat } from '../dto/engagement-report.dto'; +import { Response } from 'express'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; + +@ApiTags('engagement-reporting') +@Controller('engagement-reporting') +export class EngagementReportingController { + private readonly logger = new Logger(EngagementReportingController.name); + + constructor(private readonly reportingService: EngagementReportingService) {} + + @Post('generate') + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'Generate an engagement report' }) + @ApiResponse({ status: 200, description: 'Report generated successfully' }) + @ApiBody({ type: EngagementReportDto }) + async generateReport( + @Body() reportDto: EngagementReportDto, + @Res() res: Response, + ): Promise { + try { + const report = await this.reportingService.generateReport(reportDto); + + // Set appropriate headers based on report format + switch (reportDto.format) { + case ReportFormat.CSV: + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', `attachment; filename="engagement_report.csv"`); + break; + case ReportFormat.PDF: + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', `attachment; filename="engagement_report.pdf"`); + break; + default: + res.setHeader('Content-Type', 'application/json'); + break; + } + + // Send the report as the response + res.send(report); + } catch (error) { + this.logger.error(`Failed to generate report: ${error.message}`, error.stack); + throw new HttpException('Failed to generate report', HttpStatus.INTERNAL_SERVER_ERROR); + } + } + + @Get('available') + @UseGuards(JwtAuthGuard) + @ApiOperation({ summary: 'Get list of available reports' }) + @ApiResponse({ status: 200, description: 'Reports list retrieved successfully' }) + async getAvailableReports(): Promise<{ reports: string[] }> { + try { + const reports = await this.reportingService.getAvailableReports(); + return { reports }; + } catch (error) { + this.logger.error(`Failed to get available reports: ${error.message}`, error.stack); + throw new HttpException('Failed to retrieve reports list', HttpStatus.INTERNAL_SERVER_ERROR); + } + } +} \ No newline at end of file diff --git a/apps/backend/src/social-engagement-analytics/enagagemanet-analytics/dto/create-engagement.dto.ts b/apps/backend/src/social-engagement-analytics/enagagemanet-analytics/dto/create-engagement.dto.ts new file mode 100644 index 0000000..d908ef2 --- /dev/null +++ b/apps/backend/src/social-engagement-analytics/enagagemanet-analytics/dto/create-engagement.dto.ts @@ -0,0 +1,23 @@ +// src/engagement-analytics/dto/create-engagement.dto.ts +import { IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; +import { EngagementType } from '../entities/engagement.entity'; + +export class CreateEngagementDto { + @IsNotEmpty() + @IsUUID() + userId: string; + + @IsNotEmpty() + @IsUUID() + contentId: string; + + @IsEnum(EngagementType) + type: EngagementType; + + @IsOptional() + @IsString() + text?: string; + + @IsOptional() + metadata?: Record; +} \ No newline at end of file diff --git a/apps/backend/src/social-engagement-analytics/enagagemanet-analytics/dto/engagement-query.dto.ts b/apps/backend/src/social-engagement-analytics/enagagemanet-analytics/dto/engagement-query.dto.ts new file mode 100644 index 0000000..606be61 --- /dev/null +++ b/apps/backend/src/social-engagement-analytics/enagagemanet-analytics/dto/engagement-query.dto.ts @@ -0,0 +1,45 @@ +// src/engagement-analytics/dto/engagement-query.dto.ts +import { IsDateString, IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; +import { Type } from 'class-transformer'; + +export enum TimeFrame { + HOUR = 'hour', + DAY = 'day', + WEEK = 'week', + MONTH = 'month', + CUSTOM = 'custom' +} + +export class EngagementQueryDto { + @IsOptional() + @IsUUID() + contentId?: string; + + @IsOptional() + @IsEnum(EngagementType, { each: true }) + types?: EngagementType[]; + + @IsOptional() + @IsEnum(TimeFrame) + timeFrame?: TimeFrame = TimeFrame.DAY; + + @IsOptional() + @IsDateString() + startDate?: string; + + @IsOptional() + @IsDateString() + endDate?: string; + + @IsOptional() + @Type(() => Number) + limit?: number = 100; + + @IsOptional() + @Type(() => Number) + offset?: number = 0; + + @IsOptional() + @IsString() + groupBy?: string; +} \ No newline at end of file diff --git a/apps/backend/src/social-engagement-analytics/enagagemanet-analytics/dto/engagement-report.dto.ts b/apps/backend/src/social-engagement-analytics/enagagemanet-analytics/dto/engagement-report.dto.ts new file mode 100644 index 0000000..164142f --- /dev/null +++ b/apps/backend/src/social-engagement-analytics/enagagemanet-analytics/dto/engagement-report.dto.ts @@ -0,0 +1,38 @@ +// src/engagement-analytics/dto/engagement-report.dto.ts +import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export enum ReportFormat { + JSON = 'json', + CSV = 'csv', + PDF = 'pdf' +} + +export enum ReportType { + DAILY = 'daily', + WEEKLY = 'weekly', + MONTHLY = 'monthly', + CUSTOM = 'custom' +} + +export class EngagementReportDto { + @IsNotEmpty() + @IsEnum(ReportType) + type: ReportType; + + @IsOptional() + @IsDateString() + startDate?: string; + + @IsOptional() + @IsDateString() + endDate?: string; + + @IsOptional() + @IsEnum(ReportFormat) + format?: ReportFormat = ReportFormat.JSON; + + @IsOptional() + @IsString() + title?: string; +} + diff --git a/apps/backend/src/social-engagement-analytics/enagagemanet-analytics/entities/engagement.entity.ts b/apps/backend/src/social-engagement-analytics/enagagemanet-analytics/entities/engagement.entity.ts new file mode 100644 index 0000000..7f2cff9 --- /dev/null +++ b/apps/backend/src/social-engagement-analytics/enagagemanet-analytics/entities/engagement.entity.ts @@ -0,0 +1,49 @@ +// src/engagement-analytics/entities/engagement.entity.ts +import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, Index, ManyToOne } from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { Content } from '../../content/entities/content.entity'; + +export enum EngagementType { + LIKE = 'like', + DISLIKE = 'dislike', + COMMENT = 'comment', + SHARE = 'share', + VIEW = 'view' +} + +@Entity('engagements') +export class Engagement { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => User) + user: User; + + @Column() + userId: string; + + @ManyToOne(() => Content) + content: Content; + + @Column() + contentId: string; + + @Column({ + type: 'enum', + enum: EngagementType, + }) + type: EngagementType; + + @Column({ nullable: true, type: 'text' }) + text: string; + + @Column({ default: 1 }) + weight: number; + + @CreateDateColumn() + @Index() + createdAt: Date; + + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; +} \ No newline at end of file diff --git a/apps/backend/src/social-engagement-analytics/enagagemanet-analytics/interfaces/engagement-analytics.interface.ts b/apps/backend/src/social-engagement-analytics/enagagemanet-analytics/interfaces/engagement-analytics.interface.ts new file mode 100644 index 0000000..ff2a34e --- /dev/null +++ b/apps/backend/src/social-engagement-analytics/enagagemanet-analytics/interfaces/engagement-analytics.interface.ts @@ -0,0 +1,41 @@ + + +// src/engagement-analytics/interfaces/engagement-analytics.interface.ts +import { EngagementType } from '../entities/engagement.entity'; + +export interface EngagementCount { + type: EngagementType; + count: number; + weightedScore: number; +} + +export interface EngagementTimeSeries { + timestamp: Date; + engagements: EngagementCount[]; + total: number; + weightedTotal: number; +} + +export interface EngagementSummary { + contentId?: string; + period: { + start: Date; + end: Date; + }; + totalEngagements: number; + engagementsByType: Record; + weightedScores: Record; + totalWeightedScore: number; + sentimentScore: number; + trendData: EngagementTimeSeries[]; + anomalies: EngagementAnomaly[]; +} + +export interface EngagementAnomaly { + timestamp: Date; + type: EngagementType; + expected: number; + actual: number; + deviation: number; + significance: number; +} \ No newline at end of file diff --git a/apps/backend/src/social-engagement-analytics/enagagemanet-analytics/interfaces/weighted-score.interface.ts b/apps/backend/src/social-engagement-analytics/enagagemanet-analytics/interfaces/weighted-score.interface.ts new file mode 100644 index 0000000..d5692c2 --- /dev/null +++ b/apps/backend/src/social-engagement-analytics/enagagemanet-analytics/interfaces/weighted-score.interface.ts @@ -0,0 +1,57 @@ +// src/engagement-analytics/interfaces/weighted-score.interface.ts +import { EngagementType } from '../entities/engagement.entity'; + +export interface UserReputationWeights { + userId: string; + multiplier: number; + factors: { + accountAge: number; + previousEngagements: number; + contentQuality: number; + communityStanding: number; + }; + lastUpdated: Date; +} + +export interface EngagementTypeWeights { + [EngagementType.LIKE]: number; + [EngagementType.DISLIKE]: number; + [EngagementType.COMMENT]: number; + [EngagementType.SHARE]: number; + [EngagementType.VIEW]: number; +} + +export interface WeightedEngagement { + engagementId: string; + timestamp: Date; + type: EngagementType; + baseWeight: number; + userReputationMultiplier: number; + finalWeight: number; +} + +// src/shared/interfaces/time-series.interface.ts +export interface TimeSeriesDataPoint { + timestamp: Date; + value: T; +} + +export interface TimeSeriesAggregation { + period: { + start: Date; + end: Date; + }; + data: TimeSeriesDataPoint[]; + aggregated: T; +} + +export interface TimeSeriesWindow { + windowStart: Date; + windowEnd: Date; + dataPoints: TimeSeriesDataPoint[]; + mean: number; + median: number; + stdDev: number; + min: number; + max: number; +} \ No newline at end of file diff --git a/apps/backend/src/social-engagement-analytics/enagagemanet-analytics/repositories/engagement.repository.ts b/apps/backend/src/social-engagement-analytics/enagagemanet-analytics/repositories/engagement.repository.ts new file mode 100644 index 0000000..7a3b751 --- /dev/null +++ b/apps/backend/src/social-engagement-analytics/enagagemanet-analytics/repositories/engagement.repository.ts @@ -0,0 +1,178 @@ +// src/engagement-analytics/repositories/engagement.repository.ts +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, Between, FindOptionsWhere } from 'typeorm'; +import { Engagement, EngagementType } from '../entities/engagement.entity'; +import { EngagementQueryDto, TimeFrame } from '../dto/engagement-query.dto'; +import { EngagementTimeSeries } from '../interfaces/engagement-analytics.interface'; + +@Injectable() +export class EngagementRepository { + constructor( + @InjectRepository(Engagement) + private engagementRepository: Repository, + ) {} + + async create(engagement: Partial): Promise { + const newEngagement = this.engagementRepository.create(engagement); + return this.engagementRepository.save(newEngagement); + } + + async findById(id: string): Promise { + return this.engagementRepository.findOne({ where: { id } }); + } + + async findByContentId(contentId: string): Promise { + return this.engagementRepository.find({ + where: { contentId }, + order: { createdAt: 'DESC' }, + }); + } + + async findByUserId(userId: string): Promise { + return this.engagementRepository.find({ + where: { userId }, + order: { createdAt: 'DESC' }, + }); + } + + async countByType(contentId: string): Promise> { + const result = await this.engagementRepository + .createQueryBuilder('engagement') + .select('engagement.type', 'type') + .addSelect('COUNT(engagement.id)', 'count') + .where('engagement.contentId = :contentId', { contentId }) + .groupBy('engagement.type') + .getRawMany(); + + return result.reduce((acc, { type, count }) => { + acc[type] = parseInt(count, 10); + return acc; + }, {} as Record); + } + + async findByTimeFrame(query: EngagementQueryDto): Promise { + const { startDate, endDate, contentId, types, limit, offset } = query; + + const where: FindOptionsWhere = {}; + + if (contentId) { + where.contentId = contentId; + } + + if (types && types.length > 0) { + where.type = types.length === 1 ? types[0] : types; + } + + if (startDate && endDate) { + where.createdAt = Between(new Date(startDate), new Date(endDate)); + } else { + const { start, end } = this.getTimeRange(query.timeFrame); + where.createdAt = Between(start, end); + } + + return this.engagementRepository.find({ + where, + take: limit, + skip: offset, + order: { createdAt: 'DESC' }, + }); + } + + async getTimeSeriesData(query: EngagementQueryDto): Promise { + const { startDate, endDate, contentId, timeFrame } = query; + + const dateRange = startDate && endDate + ? { start: new Date(startDate), end: new Date(endDate) } + : this.getTimeRange(timeFrame); + + const interval = this.getTimeInterval(timeFrame); + + const result = await this.engagementRepository + .createQueryBuilder('engagement') + .select(`date_trunc('${interval}', engagement.createdAt)`, 'timestamp') + .addSelect('engagement.type', 'type') + .addSelect('COUNT(engagement.id)', 'count') + .addSelect('SUM(engagement.weight)', 'weightedScore') + .where('engagement.createdAt BETWEEN :start AND :end', { + start: dateRange.start, + end: dateRange.end + }) + .andWhere(contentId ? 'engagement.contentId = :contentId' : '1=1', { contentId }) + .groupBy('timestamp, engagement.type') + .orderBy('timestamp', 'ASC') + .getRawMany(); + + // Transform the raw SQL results into the expected format + const timeSeriesMap = new Map(); + + result.forEach(row => { + const timestamp = new Date(row.timestamp); + const timeKey = timestamp.toISOString(); + + if (!timeSeriesMap.has(timeKey)) { + timeSeriesMap.set(timeKey, { + timestamp, + engagements: [], + total: 0, + weightedTotal: 0 + }); + } + + const timeSeries = timeSeriesMap.get(timeKey); + const count = parseInt(row.count, 10); + const weightedScore = parseFloat(row.weightedScore); + + timeSeries.engagements.push({ + type: row.type, + count, + weightedScore + }); + + timeSeries.total += count; + timeSeries.weightedTotal += weightedScore; + }); + + return Array.from(timeSeriesMap.values()); + } + + private getTimeRange(timeFrame: TimeFrame): { start: Date; end: Date } { + const now = new Date(); + const end = new Date(now); + let start = new Date(now); + + switch (timeFrame) { + case TimeFrame.HOUR: + start.setHours(start.getHours() - 1); + break; + case TimeFrame.DAY: + start.setDate(start.getDate() - 1); + break; + case TimeFrame.WEEK: + start.setDate(start.getDate() - 7); + break; + case TimeFrame.MONTH: + start.setMonth(start.getMonth() - 1); + break; + default: + start.setDate(start.getDate() - 1); // Default to 1 day + } + + return { start, end }; + } + + private getTimeInterval(timeFrame: TimeFrame): string { + switch (timeFrame) { + case TimeFrame.HOUR: + return 'minute'; + case TimeFrame.DAY: + return 'hour'; + case TimeFrame.WEEK: + return 'day'; + case TimeFrame.MONTH: + return 'day'; + default: + return 'hour'; + } + } +} \ No newline at end of file diff --git a/apps/backend/src/social-engagement-analytics/enagagemanet-analytics/services/engagement-analytics.service.ts b/apps/backend/src/social-engagement-analytics/enagagemanet-analytics/services/engagement-analytics.service.ts new file mode 100644 index 0000000..e7a1dcc --- /dev/null +++ b/apps/backend/src/social-engagement-analytics/enagagemanet-analytics/services/engagement-analytics.service.ts @@ -0,0 +1,199 @@ +// src/engagement-analytics/services/engagement-analytics.service.ts +import { Injectable, Logger } from '@nestjs/common'; +import { EngagementRepository } from '../repositories/engagement.repository'; +import { ReputationWeightingService } from './reputation-weighting.service'; +import { EngagementQueryDto } from '../dto/engagement-query.dto'; +import { EngagementSummary, EngagementTimeSeries } from '../interfaces/engagement-analytics.interface'; +import { EngagementType, Engagement } from '../entities/engagement.entity'; +import { CreateEngagementDto } from '../dto/create-engagement.dto'; +import { EngagementCacheService } from '../../shared/cache/engagement-cache.service'; +import { AnomalyDetectionUtil } from '../../shared/utils/anomaly-detection.util'; + +@Injectable() +export class EngagementAnalyticsService { + private readonly logger = new Logger(EngagementAnalyticsService.name); + + constructor( + private readonly engagementRepository: EngagementRepository, + private readonly reputationService: ReputationWeightingService, + private readonly cacheService: EngagementCacheService, + private readonly anomalyDetection: AnomalyDetectionUtil, + ) {} + + /** + * Create a new engagement record with weighted scoring + */ + async recordEngagement(createDto: CreateEngagementDto): Promise { + // Calculate weight based on user reputation and engagement type + const weight = await this.reputationService.calculateEngagementWeight( + createDto.userId, + createDto.type, + ); + + // Create engagement entity with calculated weight + const engagement = await this.engagementRepository.create({ + ...createDto, + weight, + }); + + // Invalidate cache for related content + await this.cacheService.invalidateContentEngagement(createDto.contentId); + + return engagement; + } + + /** + * Get engagement analytics for specified criteria + */ + async getEngagementAnalytics(query: EngagementQueryDto): Promise { + const cacheKey = `engagement:${JSON.stringify(query)}`; + + // Try to get from cache first + const cached = await this.cacheService.get(cacheKey); + if (cached) { + return cached; + } + + // Get time series data + const timeSeries = await this.engagementRepository.getTimeSeriesData(query); + + // Get counts by type + const engagementsByType = await this.getEngagementCountsByType(query); + + // Calculate weighted scores + const weightedScores = this.calculateWeightedScores(timeSeries); + + // Detect anomalies in the data + const anomalies = this.anomalyDetection.detectAnomalies(timeSeries); + + // Calculate sentiment score (-1 to 1) + const sentimentScore = this.calculateSentimentScore(weightedScores); + + // Calculate total engagements + const totalEngagements = Object.values(engagementsByType).reduce((sum, count) => sum + count, 0); + + // Calculate total weighted score + const totalWeightedScore = Object.values(weightedScores).reduce((sum, score) => sum + score, 0); + + // Determine time period + const period = this.getTimePeriod(query, timeSeries); + + // Construct the summary + const summary: EngagementSummary = { + contentId: query.contentId, + period, + totalEngagements, + engagementsByType, + weightedScores, + totalWeightedScore, + sentimentScore, + trendData: timeSeries, + anomalies, + }; + + // Cache the result + await this.cacheService.set(cacheKey, summary, 60 * 5); // Cache for 5 minutes + + return summary; + } + + /** + * Get raw time series data + */ + async getTimeSeriesData(query: EngagementQueryDto): Promise { + return this.engagementRepository.getTimeSeriesData(query); + } + + /** + * Get engagement counts by type for the given query + */ + private async getEngagementCountsByType(query: EngagementQueryDto): Promise> { + if (query.contentId) { + // If querying for a specific content, use the repository method + return this.engagementRepository.countByType(query.contentId); + } else { + // For more complex queries, we need to calculate from the time series data + const timeSeries = await this.engagementRepository.getTimeSeriesData(query); + + // Initialize counts object with all engagement types + const counts: Record = { + [EngagementType.LIKE]: 0, + [EngagementType.DISLIKE]: 0, + [EngagementType.COMMENT]: 0, + [EngagementType.SHARE]: 0, + [EngagementType.VIEW]: 0, + }; + + // Sum up counts across all time points + timeSeries.forEach(point => { + point.engagements.forEach(engagement => { + counts[engagement.type] += engagement.count; + }); + }); + + return counts; + } + } + + /** + * Calculate weighted scores for each engagement type + */ + private calculateWeightedScores(timeSeries: EngagementTimeSeries[]): Record { + const scores: Record = { + [EngagementType.LIKE]: 0, + [EngagementType.DISLIKE]: 0, + [EngagementType.COMMENT]: 0, + [EngagementType.SHARE]: 0, + [EngagementType.VIEW]: 0, + }; + + timeSeries.forEach(point => { + point.engagements.forEach(engagement => { + scores[engagement.type] += engagement.weightedScore; + }); + }); + + return scores; + } + + /** + * Calculate a sentiment score based on weighted engagement + * Returns a value between -1 (negative) and 1 (positive) + */ + private calculateSentimentScore(weightedScores: Record): number { + const positive = weightedScores[EngagementType.LIKE] + weightedScores[EngagementType.SHARE]; + const negative = weightedScores[EngagementType.DISLIKE]; + const total = positive + negative; + + if (total === 0) { + return 0; + } + + return (positive - negative) / total; + } + + /** + * Determine the time period covered by the query and data + */ + private getTimePeriod(query: EngagementQueryDto, timeSeries: EngagementTimeSeries[]): { start: Date; end: Date } { + // If explicit dates were provided in the query, use those + if (query.startDate && query.endDate) { + return { + start: new Date(query.startDate), + end: new Date(query.endDate), + }; + } + + // Otherwise, determine from the time series data + if (timeSeries.length > 0) { + const timestamps = timeSeries.map(point => point.timestamp); + return { + start: new Date(Math.min(...timestamps.map(d => d.getTime()))), + end: new Date(Math.max(...timestamps.map(d => d.getTime()))), + }; + } + + // Fallback to repository's time range calculation + return this.engagementRepository.getTimeRange(query.timeFrame); + } +} diff --git a/apps/backend/src/social-engagement-analytics/enagagemanet-analytics/services/engagement-reporting.service.ts b/apps/backend/src/social-engagement-analytics/enagagemanet-analytics/services/engagement-reporting.service.ts new file mode 100644 index 0000000..6e7918e --- /dev/null +++ b/apps/backend/src/social-engagement-analytics/enagagemanet-analytics/services/engagement-reporting.service.ts @@ -0,0 +1,235 @@ +// src/engagement-analytics/services/engagement-reporting.service.ts +import { Injectable, Logger } from '@nestjs/common'; +import { EngagementAnalyticsService } from './engagement-analytics.service'; +import { EngagementReportDto, ReportFormat, ReportType } from '../dto/engagement-report.dto'; +import { EngagementQueryDto, TimeFrame } from '../dto/engagement-query.dto'; +import { EngagementSummary } from '../interfaces/engagement-analytics.interface'; +import { SchedulerRegistry } from '@nestjs/schedule'; +import { CronJob } from 'cron'; +import * as fs from 'fs'; +import * as path from 'path'; +import { createObjectCsvStringifier } from 'csv-writer'; + +@Injectable() +export class EngagementReportingService { + private readonly logger = new Logger(EngagementReportingService.name); + private readonly reportsDir = path.join(process.cwd(), 'reports'); + + constructor( + private readonly analyticsService: EngagementAnalyticsService, + private readonly schedulerRegistry: SchedulerRegistry, + ) { + // Ensure reports directory exists + if (!fs.existsSync(this.reportsDir)) { + fs.mkdirSync(this.reportsDir, { recursive: true }); + } + + // Set up scheduled reports + this.setupScheduledReports(); + } + + /** + * Generate a report based on the provided parameters + */ + async generateReport(reportDto: EngagementReportDto): Promise { + const { type, startDate, endDate, format } = reportDto; + + // Create a query DTO for the analytics service + const queryDto: EngagementQueryDto = { + timeFrame: this.getTimeFrameFromReportType(type), + startDate, + endDate, + }; + + // Get the analytics data + const analyticsData = await this.analyticsService.getEngagementAnalytics(queryDto); + + // Format the data according to the requested format + return this.formatReport(analyticsData, format, reportDto.title); + } + + /** + * Schedule the generation and delivery of periodic reports + */ + private setupScheduledReports(): void { + // Daily report at midnight + const dailyJob = new CronJob('0 0 * * *', () => { + this.logger.log('Generating daily engagement report'); + this.generateAndDeliverScheduledReport(ReportType.DAILY); + }); + + // Weekly report on Mondays at 1am + const weeklyJob = new CronJob('0 1 * * 1', () => { + this.logger.log('Generating weekly engagement report'); + this.generateAndDeliverScheduledReport(ReportType.WEEKLY); + }); + + // Monthly report on the 1st of each month at 2am + const monthlyJob = new CronJob('0 2 1 * *', () => { + this.logger.log('Generating monthly engagement report'); + this.generateAndDeliverScheduledReport(ReportType.MONTHLY); + }); + + // Register the cron jobs + this.schedulerRegistry.addCronJob('dailyEngagementReport', dailyJob); + this.schedulerRegistry.addCronJob('weeklyEngagementReport', weeklyJob); + this.schedulerRegistry.addCronJob('monthlyEngagementReport', monthlyJob); + + // Start the jobs + dailyJob.start(); + weeklyJob.start(); + monthlyJob.start(); + } + + /** + * Generate and deliver a scheduled report + */ + private async generateAndDeliverScheduledReport(reportType: ReportType): Promise { + try { + const reportDto: EngagementReportDto = { + type: reportType, + format: ReportFormat.PDF, + title: `${reportType.charAt(0).toUpperCase() + reportType.slice(1)} Engagement Report`, + }; + + const report = await this.generateReport(reportDto); + + // Save the report to the file system + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const filename = `${reportDto.type}_report_${timestamp}.${this.getFileExtension(reportDto.format)}`; + const filepath = path.join(this.reportsDir, filename); + + fs.writeFileSync(filepath, report); + + this.logger.log(`Report saved to ${filepath}`); + + // In a production environment, you would also send the report via email or upload to storage + await this.deliverReport(filepath, reportDto); + } catch (error) { + this.logger.error(`Failed to generate scheduled report: ${error.message}`, error.stack); + } + } + + /** + * Format the report data according to the requested format + */ + private formatReport(data: EngagementSummary, format: ReportFormat, title?: string): string | Buffer { + switch (format) { + case ReportFormat.JSON: + return JSON.stringify(data, null, 2); + + case ReportFormat.CSV: + return this.formatCsvReport(data); + + case ReportFormat.PDF: + return this.formatPdfReport(data, title); + + default: + return JSON.stringify(data); + } + } + + /** + * Format the report as CSV + */ + private formatCsvReport(data: EngagementSummary): string { + // Format time series data for CSV + const records = data.trendData.flatMap(point => { + return point.engagements.map(engagement => ({ + timestamp: point.timestamp.toISOString(), + type: engagement.type, + count: engagement.count, + weightedScore: engagement.weightedScore + })); + }); + + // Create CSV stringifier + const csvStringifier = createObjectCsvStringifier({ + header: [ + { id: 'timestamp', title: 'Timestamp' }, + { id: 'type', title: 'Engagement Type' }, + { id: 'count', title: 'Count' }, + { id: 'weightedScore', title: 'Weighted Score' } + ] + }); + + // Generate CSV + const header = csvStringifier.getHeaderString(); + const rows = csvStringifier.stringifyRecords(records); + + return header + rows; + } + + /** + * Format the report as PDF + * In a production environment, this would use a PDF generation library like PDFKit + */ + private formatPdfReport(data: EngagementSummary, title?: string): Buffer { + // This is a placeholder. In a real implementation, you would use a PDF library + // For now, we'll return a JSON string as a buffer for demonstration purposes + const reportTitle = title || 'Engagement Analytics Report'; + const jsonData = JSON.stringify({ + title: reportTitle, + generatedAt: new Date().toISOString(), + period: { + start: data.period.start.toISOString(), + end: data.period.end.toISOString() + }, + summary: { + totalEngagements: data.totalEngagements, + sentimentScore: data.sentimentScore, + engagementsByType: data.engagementsByType + }, + // Include other data as needed + }, null, 2); + + return Buffer.from(jsonData); + } + + /** + * Get the file extension based on report format + */ + private getFileExtension(format: ReportFormat): string { + switch (format) { + case ReportFormat.JSON: return 'json'; + case ReportFormat.CSV: return 'csv'; + case ReportFormat.PDF: return 'pdf'; + default: return 'txt'; + } + } + + /** + * Deliver the report to configured destinations (email, cloud storage, etc.) + */ + private async deliverReport(filepath: string, reportDto: EngagementReportDto): Promise { + // In a production environment, this would send emails or upload to cloud storage + this.logger.log(`Report would be delivered to configured recipients: ${filepath}`); + + // Example email delivery pseudo code: + // const emailService = this.moduleRef.get(EmailService); + // await emailService.sendReportEmail({ + // subject: `${reportDto.title} - ${new Date().toLocaleDateString()}`, + // attachments: [{ filename: path.basename(filepath), path: filepath }], + // recipients: ['analytics@example.com'] + // }); + } + + /** + * Get list of available generated reports + */ + async getAvailableReports(): Promise { + try { + // Read the report directory + const files = fs.readdirSync(this.reportsDir); + + // Return only report files + return files.filter(file => + file.endsWith('.json') || + file.endsWith('.csv') || + file.endsWith('.pdf') + ); + } catch (error) { + this.logger.error(`Failed to read reports directory: ${error.message}`); + return []; + } + } } \ No newline at end of file diff --git a/apps/backend/src/social-engagement-analytics/enagagemanet-analytics/services/reputation-weighting.service.ts b/apps/backend/src/social-engagement-analytics/enagagemanet-analytics/services/reputation-weighting.service.ts new file mode 100644 index 0000000..1a8195e --- /dev/null +++ b/apps/backend/src/social-engagement-analytics/enagagemanet-analytics/services/reputation-weighting.service.ts @@ -0,0 +1,120 @@ +// src/engagement-analytics/services/reputation-weighting.service.ts +import { Injectable } from '@nestjs/common'; +import { UserReputationWeights, EngagementTypeWeights } from '../interfaces/weighted-score.interface'; +import { EngagementType } from '../entities/engagement.entity'; + +@Injectable() +export class ReputationWeightingService { + // Default weights for different engagement types + private readonly DEFAULT_TYPE_WEIGHTS: EngagementTypeWeights = { + [EngagementType.LIKE]: 1.0, + [EngagementType.DISLIKE]: 1.2, // Slightly higher impact for dislikes + [EngagementType.COMMENT]: 2.0, // Comments require more effort + [EngagementType.SHARE]: 3.0, // Shares have higher impact + [EngagementType.VIEW]: 0.1, // Views have minimal impact + }; + + private typeWeights: EngagementTypeWeights = { ...this.DEFAULT_TYPE_WEIGHTS }; + + // Cache of user reputation weights + private userReputationCache = new Map(); + + constructor( + // Inject user service or repository for fetching user data + ) {} + + /** + * Calculate the weight multiplier for a given user based on their reputation + */ + async getUserReputationMultiplier(userId: string): Promise { + // Check cache first + if (this.userReputationCache.has(userId)) { + const cached = this.userReputationCache.get(userId); + // Check if cache is still fresh (less than 24 hours old) + if (new Date().getTime() - cached.lastUpdated.getTime() < 86400000) { + return cached.multiplier; + } + } + + // Calculate reputation weights for user + const weights = await this.calculateUserReputationWeights(userId); + + // Cache the result + this.userReputationCache.set(userId, weights); + + return weights.multiplier; + } + + /** + * Calculate the weighted score for an engagement + */ + async calculateEngagementWeight( + userId: string, + type: EngagementType, + ): Promise { + const userMultiplier = await this.getUserReputationMultiplier(userId); + const typeWeight = this.getEngagementTypeWeight(type); + + return typeWeight * userMultiplier; + } + + /** + * Get the base weight for an engagement type + */ + getEngagementTypeWeight(type: EngagementType): number { + return this.typeWeights[type] || 1.0; + } + + /** + * Update the weight configuration for engagement types + */ + updateEngagementTypeWeights(weights: Partial): void { + this.typeWeights = { + ...this.typeWeights, + ...weights, + }; + } + + /** + * Reset engagement type weights to defaults + */ + resetEngagementTypeWeights(): void { + this.typeWeights = { ...this.DEFAULT_TYPE_WEIGHTS }; + } + + /** + * Calculate reputation weights for a user based on their history and behavior + * This is where sophisticated algorithms would evaluate user trustworthiness + */ + private async calculateUserReputationWeights(userId: string): Promise { + // In a real implementation, this would query user data, engagement history, etc. + // For this example, we'll use placeholder logic + + // Placeholder values that would come from user history analysis + const accountAgeFactor = 0.8; // 0-1 based on account age + const previousEngagementsFactor = 0.9; // 0-1 based on previous engagement quality + const contentQualityFactor = 0.7; // 0-1 based on quality of user's content + const communityStandingFactor = 0.85; // 0-1 based on community standing + + // Calculate overall multiplier + const multiplier = ( + accountAgeFactor + + previousEngagementsFactor + + contentQualityFactor + + communityStandingFactor + ) / 4; + + return { + userId, + multiplier, + factors: { + accountAge: accountAgeFactor, + previousEngagements: previousEngagementsFactor, + contentQuality: contentQualityFactor, + communityStanding: communityStandingFactor, + }, + lastUpdated: new Date(), + }; + } +} + diff --git a/apps/backend/src/social-engagement-analytics/shared/cache/engagement-cache.service.ts b/apps/backend/src/social-engagement-analytics/shared/cache/engagement-cache.service.ts new file mode 100644 index 0000000..895f603 --- /dev/null +++ b/apps/backend/src/social-engagement-analytics/shared/cache/engagement-cache.service.ts @@ -0,0 +1,81 @@ +// src/shared/cache/engagement-cache.service.ts +import { Injectable, Logger } from '@nestjs/common'; +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Inject } from '@nestjs/common'; +import { Cache } from 'cache-manager'; + +@Injectable() +export class EngagementCacheService { + private readonly logger = new Logger(EngagementCacheService.name); + private readonly ttl = 300; // 5 minutes default TTL + + constructor( + @Inject(CACHE_MANAGER) private cacheManager: Cache, + ) {} + + /** + * Get a value from cache + */ + async get(key: string): Promise { + try { + return await this.cacheManager.get(key); + } catch (error) { + this.logger.error(`Error getting cache key ${key}: ${error.message}`); + return undefined; + } + } + + /** + * Set a value in cache + */ + async set(key: string, value: any, ttl?: number): Promise { + try { + await this.cacheManager.set(key, value, ttl || this.ttl); + } catch (error) { + this.logger.error(`Error setting cache key ${key}: ${error.message}`); + } + } + + /** + * Delete a value from cache + */ + async delete(key: string): Promise { + try { + await this.cacheManager.del(key); + } catch (error) { + this.logger.error(`Error deleting cache key ${key}: ${error.message}`); + } + } + + /** + * Invalidate cache for a content item's engagement data + */ + async invalidateContentEngagement(contentId: string): Promise { + try { + // Use pattern matching to delete all keys related to this content + const keys = await this.findCacheKeys(`engagement:*${contentId}*`); + + // Delete all matched keys + for (const key of keys) { + await this.delete(key); + } + + this.logger.debug(`Invalidated ${keys.length} cache entries for content ${contentId}`); + } catch (error) { + this.logger.error(`Error invalidating content cache for ${contentId}: ${error.message}`); + } + } + + /** + * Find all cache keys matching a pattern + * Note: This implementation is simplified and would need to be adjusted + * based on the actual cache provider being used + */ + private async findCacheKeys(pattern: string): Promise { + // This is a placeholder. In production, you'd use cache-specific methods + // like Redis SCAN command if using Redis + + // For demonstration purposes, we'll return a mock key + return [`engagement:content:${pattern.split('*')[1]}`]; + } +} \ No newline at end of file diff --git a/apps/backend/src/social-engagement-analytics/shared/utils/anomaly-detection.util.ts b/apps/backend/src/social-engagement-analytics/shared/utils/anomaly-detection.util.ts new file mode 100644 index 0000000..48ab62b --- /dev/null +++ b/apps/backend/src/social-engagement-analytics/shared/utils/anomaly-detection.util.ts @@ -0,0 +1,72 @@ +// src/shared/utils/anomaly-detection.util.ts +import { Injectable } from '@nestjs/common'; +import { EngagementAnomaly, EngagementTimeSeries } from '../../engagement-analytics/interfaces/engagement-analytics.interface'; +import { EngagementType } from '../../engagement-analytics/entities/engagement.entity'; + +@Injectable() +export class AnomalyDetectionUtil { + // Threshold for determining if a deviation is significant enough to be an anomaly + private readonly ANOMALY_THRESHOLD = 2.0; + + /** + * Detect anomalies in engagement time series data + */ + detectAnomalies(timeSeries: EngagementTimeSeries[]): EngagementAnomaly[] { + const anomalies: EngagementAnomaly[] = []; + + // Skip if not enough data points + if (timeSeries.length < 3) { + return anomalies; + } + + // Get all engagement types present in the data + const engagementTypes = new Set(); + timeSeries.forEach(point => { + point.engagements.forEach(engagement => { + engagementTypes.add(engagement.type); + }); + }); + + // For each engagement type, detect anomalies + engagementTypes.forEach(type => { + // Extract counts for this engagement type + const typeCounts = timeSeries.map(point => { + const engagement = point.engagements.find(e => e.type === type); + return { + timestamp: point.timestamp, + count: engagement ? engagement.count : 0 + }; + }); + + // Calculate moving average and standard deviation + const windowSize = 3; // Use 3 data points for the moving window + + for (let i = windowSize; i < typeCounts.length; i++) { + const window = typeCounts.slice(i - windowSize, i); + const currentPoint = typeCounts[i]; + + // Calculate mean and standard deviation of the window + const mean = window.reduce((sum, point) => sum + point.count, 0) / window.length; + const variance = window.reduce((sum, point) => sum + Math.pow(point.count - mean, 2), 0) / window.length; + const stdDev = Math.sqrt(variance); + + // Calculate z-score (number of standard deviations from the mean) + const zScore = stdDev === 0 ? 0 : (currentPoint.count - mean) / stdDev; + + // If z-score exceeds threshold, it's an anomaly + if (Math.abs(zScore) > this.ANOMALY_THRESHOLD) { + anomalies.push({ + timestamp: currentPoint.timestamp, + type, + expected: mean, + actual: currentPoint.count, + deviation: currentPoint.count - mean, + significance: Math.abs(zScore) + }); + } + } + }); + + return anomalies; + } +} \ No newline at end of file