generated from fahricansecer/boilerplate-be
This commit is contained in:
512
src/modules/analytics/services/ab-testing.service.ts
Normal file
512
src/modules/analytics/services/ab-testing.service.ts
Normal file
@@ -0,0 +1,512 @@
|
||||
// A/B Testing Service - Content experimentation system
|
||||
// Path: src/modules/analytics/services/ab-testing.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface ABTest {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: 'draft' | 'running' | 'paused' | 'completed' | 'cancelled';
|
||||
testType: 'title' | 'content' | 'timing' | 'format' | 'hashtags' | 'cta' | 'visual';
|
||||
platform: string;
|
||||
startDate: Date;
|
||||
endDate?: Date;
|
||||
variants: ABVariant[];
|
||||
winner?: string;
|
||||
results?: ABTestResults;
|
||||
settings: ABTestSettings;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface ABVariant {
|
||||
id: string;
|
||||
name: string;
|
||||
content: Record<string, any>;
|
||||
trafficAllocation: number;
|
||||
metrics: VariantMetrics;
|
||||
isControl: boolean;
|
||||
}
|
||||
|
||||
export interface VariantMetrics {
|
||||
impressions: number;
|
||||
engagements: number;
|
||||
clicks: number;
|
||||
conversions: number;
|
||||
engagementRate: number;
|
||||
clickRate: number;
|
||||
conversionRate: number;
|
||||
}
|
||||
|
||||
export interface ABTestResults {
|
||||
winner: string;
|
||||
confidence: number;
|
||||
uplift: number;
|
||||
significanceReached: boolean;
|
||||
insights: string[];
|
||||
recommendations: string[];
|
||||
}
|
||||
|
||||
export interface ABTestSettings {
|
||||
minSampleSize: number;
|
||||
minConfidence: number;
|
||||
autoEndOnSignificance: boolean;
|
||||
maxDuration: number; // days
|
||||
successMetric: 'engagement' | 'clicks' | 'conversions' | 'reach';
|
||||
}
|
||||
|
||||
export interface ABTestReport {
|
||||
test: ABTest;
|
||||
summary: {
|
||||
totalImpressions: number;
|
||||
totalEngagements: number;
|
||||
duration: number;
|
||||
isSignificant: boolean;
|
||||
};
|
||||
variantComparison: Array<{
|
||||
variant: ABVariant;
|
||||
vsControl: number;
|
||||
isWinning: boolean;
|
||||
}>;
|
||||
timeline: Array<{
|
||||
date: string;
|
||||
variantMetrics: Record<string, VariantMetrics>;
|
||||
}>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AbTestingService {
|
||||
private readonly logger = new Logger(AbTestingService.name);
|
||||
private tests: Map<string, ABTest[]> = new Map();
|
||||
|
||||
/**
|
||||
* Create new A/B test
|
||||
*/
|
||||
createTest(
|
||||
userId: string,
|
||||
input: {
|
||||
name: string;
|
||||
description: string;
|
||||
testType: ABTest['testType'];
|
||||
platform: string;
|
||||
variants: Array<{
|
||||
name: string;
|
||||
content: Record<string, any>;
|
||||
trafficAllocation?: number;
|
||||
isControl?: boolean;
|
||||
}>;
|
||||
settings?: Partial<ABTestSettings>;
|
||||
},
|
||||
): ABTest {
|
||||
// Normalize traffic allocation
|
||||
const variantCount = input.variants.length;
|
||||
const defaultAllocation = 100 / variantCount;
|
||||
|
||||
const variants: ABVariant[] = input.variants.map((v, i) => ({
|
||||
id: `variant-${Date.now()}-${i}`,
|
||||
name: v.name,
|
||||
content: v.content,
|
||||
trafficAllocation: v.trafficAllocation || defaultAllocation,
|
||||
isControl: v.isControl || i === 0,
|
||||
metrics: {
|
||||
impressions: 0,
|
||||
engagements: 0,
|
||||
clicks: 0,
|
||||
conversions: 0,
|
||||
engagementRate: 0,
|
||||
clickRate: 0,
|
||||
conversionRate: 0,
|
||||
},
|
||||
}));
|
||||
|
||||
const test: ABTest = {
|
||||
id: `test-${Date.now()}`,
|
||||
userId,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
status: 'draft',
|
||||
testType: input.testType,
|
||||
platform: input.platform,
|
||||
startDate: new Date(),
|
||||
variants,
|
||||
settings: {
|
||||
minSampleSize: input.settings?.minSampleSize || 1000,
|
||||
minConfidence: input.settings?.minConfidence || 95,
|
||||
autoEndOnSignificance: input.settings?.autoEndOnSignificance ?? true,
|
||||
maxDuration: input.settings?.maxDuration || 14,
|
||||
successMetric: input.settings?.successMetric || 'engagement',
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const userTests = this.tests.get(userId) || [];
|
||||
userTests.push(test);
|
||||
this.tests.set(userId, userTests);
|
||||
|
||||
this.logger.log(`Created A/B test: ${test.id} for user ${userId}`);
|
||||
return test;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start test
|
||||
*/
|
||||
startTest(userId: string, testId: string): ABTest | null {
|
||||
const test = this.getTest(userId, testId);
|
||||
if (!test) return null;
|
||||
|
||||
test.status = 'running';
|
||||
test.startDate = new Date();
|
||||
test.updatedAt = new Date();
|
||||
|
||||
return test;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause test
|
||||
*/
|
||||
pauseTest(userId: string, testId: string): ABTest | null {
|
||||
const test = this.getTest(userId, testId);
|
||||
if (!test || test.status !== 'running') return null;
|
||||
|
||||
test.status = 'paused';
|
||||
test.updatedAt = new Date();
|
||||
|
||||
return test;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume paused test
|
||||
*/
|
||||
resumeTest(userId: string, testId: string): ABTest | null {
|
||||
const test = this.getTest(userId, testId);
|
||||
if (!test || test.status !== 'paused') return null;
|
||||
|
||||
test.status = 'running';
|
||||
test.updatedAt = new Date();
|
||||
|
||||
return test;
|
||||
}
|
||||
|
||||
/**
|
||||
* End test and calculate results
|
||||
*/
|
||||
endTest(userId: string, testId: string): ABTest | null {
|
||||
const test = this.getTest(userId, testId);
|
||||
if (!test) return null;
|
||||
|
||||
test.status = 'completed';
|
||||
test.endDate = new Date();
|
||||
test.results = this.calculateResults(test);
|
||||
test.winner = test.results.winner;
|
||||
test.updatedAt = new Date();
|
||||
|
||||
return test;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record variant metrics
|
||||
*/
|
||||
recordMetrics(
|
||||
userId: string,
|
||||
testId: string,
|
||||
variantId: string,
|
||||
metrics: Partial<VariantMetrics>,
|
||||
): ABVariant | null {
|
||||
const test = this.getTest(userId, testId);
|
||||
if (!test || test.status !== 'running') return null;
|
||||
|
||||
const variant = test.variants.find(v => v.id === variantId);
|
||||
if (!variant) return null;
|
||||
|
||||
// Update metrics
|
||||
if (metrics.impressions) variant.metrics.impressions += metrics.impressions;
|
||||
if (metrics.engagements) variant.metrics.engagements += metrics.engagements;
|
||||
if (metrics.clicks) variant.metrics.clicks += metrics.clicks;
|
||||
if (metrics.conversions) variant.metrics.conversions += metrics.conversions;
|
||||
|
||||
// Recalculate rates
|
||||
if (variant.metrics.impressions > 0) {
|
||||
variant.metrics.engagementRate = (variant.metrics.engagements / variant.metrics.impressions) * 100;
|
||||
variant.metrics.clickRate = (variant.metrics.clicks / variant.metrics.impressions) * 100;
|
||||
variant.metrics.conversionRate = (variant.metrics.conversions / variant.metrics.impressions) * 100;
|
||||
}
|
||||
|
||||
// Check if we should auto-end
|
||||
if (test.settings.autoEndOnSignificance) {
|
||||
const results = this.calculateResults(test);
|
||||
if (results.significanceReached) {
|
||||
this.endTest(userId, testId);
|
||||
}
|
||||
}
|
||||
|
||||
return variant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get test
|
||||
*/
|
||||
getTest(userId: string, testId: string): ABTest | null {
|
||||
const userTests = this.tests.get(userId) || [];
|
||||
return userTests.find(t => t.id === testId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tests
|
||||
*/
|
||||
getTests(userId: string, status?: ABTest['status']): ABTest[] {
|
||||
let tests = this.tests.get(userId) || [];
|
||||
if (status) {
|
||||
tests = tests.filter(t => t.status === status);
|
||||
}
|
||||
return tests;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active tests
|
||||
*/
|
||||
getActiveTests(userId: string): ABTest[] {
|
||||
return this.getTests(userId, 'running');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get test report
|
||||
*/
|
||||
getTestReport(userId: string, testId: string): ABTestReport | null {
|
||||
const test = this.getTest(userId, testId);
|
||||
if (!test) return null;
|
||||
|
||||
const control = test.variants.find(v => v.isControl)!;
|
||||
const totalImpressions = test.variants.reduce((sum, v) => sum + v.metrics.impressions, 0);
|
||||
const totalEngagements = test.variants.reduce((sum, v) => sum + v.metrics.engagements, 0);
|
||||
|
||||
return {
|
||||
test,
|
||||
summary: {
|
||||
totalImpressions,
|
||||
totalEngagements,
|
||||
duration: test.endDate
|
||||
? Math.ceil((test.endDate.getTime() - test.startDate.getTime()) / (1000 * 60 * 60 * 24))
|
||||
: Math.ceil((Date.now() - test.startDate.getTime()) / (1000 * 60 * 60 * 24)),
|
||||
isSignificant: test.results?.significanceReached || false,
|
||||
},
|
||||
variantComparison: test.variants.map(v => ({
|
||||
variant: v,
|
||||
vsControl: v.isControl ? 0 : this.calculateUplift(v.metrics, control.metrics, test.settings.successMetric),
|
||||
isWinning: v.id === test.winner,
|
||||
})),
|
||||
timeline: this.generateTimeline(test),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get variant to serve (for A/B serving logic)
|
||||
*/
|
||||
getVariantToServe(userId: string, testId: string): ABVariant | null {
|
||||
const test = this.getTest(userId, testId);
|
||||
if (!test || test.status !== 'running') return null;
|
||||
|
||||
// Simple weighted random selection
|
||||
const random = Math.random() * 100;
|
||||
let cumulative = 0;
|
||||
|
||||
for (const variant of test.variants) {
|
||||
cumulative += variant.trafficAllocation;
|
||||
if (random <= cumulative) {
|
||||
return variant;
|
||||
}
|
||||
}
|
||||
|
||||
return test.variants[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get test templates
|
||||
*/
|
||||
getTestTemplates(): Array<{
|
||||
name: string;
|
||||
testType: ABTest['testType'];
|
||||
description: string;
|
||||
variantTemplate: any;
|
||||
}> {
|
||||
return [
|
||||
{
|
||||
name: 'Title Test',
|
||||
testType: 'title',
|
||||
description: 'Test different titles for the same content',
|
||||
variantTemplate: { title: '' },
|
||||
},
|
||||
{
|
||||
name: 'Hook Comparison',
|
||||
testType: 'content',
|
||||
description: 'Compare different opening hooks',
|
||||
variantTemplate: { hook: '' },
|
||||
},
|
||||
{
|
||||
name: 'CTA Test',
|
||||
testType: 'cta',
|
||||
description: 'Test different calls to action',
|
||||
variantTemplate: { cta: '' },
|
||||
},
|
||||
{
|
||||
name: 'Timing Test',
|
||||
testType: 'timing',
|
||||
description: 'Test posting at different times',
|
||||
variantTemplate: { postTime: '' },
|
||||
},
|
||||
{
|
||||
name: 'Format Test',
|
||||
testType: 'format',
|
||||
description: 'Compare content formats (image vs video vs carousel)',
|
||||
variantTemplate: { format: '' },
|
||||
},
|
||||
{
|
||||
name: 'Hashtag Strategy',
|
||||
testType: 'hashtags',
|
||||
description: 'Test different hashtag combinations',
|
||||
variantTemplate: { hashtags: [] },
|
||||
},
|
||||
{
|
||||
name: 'Visual Test',
|
||||
testType: 'visual',
|
||||
description: 'Compare different visuals/thumbnails',
|
||||
variantTemplate: { imageUrl: '' },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete test
|
||||
*/
|
||||
deleteTest(userId: string, testId: string): boolean {
|
||||
const userTests = this.tests.get(userId) || [];
|
||||
const filtered = userTests.filter(t => t.id !== testId);
|
||||
|
||||
if (filtered.length === userTests.length) return false;
|
||||
|
||||
this.tests.set(userId, filtered);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Private calculation methods
|
||||
|
||||
private calculateResults(test: ABTest): ABTestResults {
|
||||
const control = test.variants.find(v => v.isControl)!;
|
||||
const challengers = test.variants.filter(v => !v.isControl);
|
||||
|
||||
let winner = control;
|
||||
let maxUplift = 0;
|
||||
|
||||
for (const challenger of challengers) {
|
||||
const uplift = this.calculateUplift(challenger.metrics, control.metrics, test.settings.successMetric);
|
||||
if (uplift > maxUplift) {
|
||||
maxUplift = uplift;
|
||||
winner = challenger;
|
||||
}
|
||||
}
|
||||
|
||||
const totalSamples = test.variants.reduce((sum, v) => sum + v.metrics.impressions, 0);
|
||||
const hasEnoughSamples = totalSamples >= test.settings.minSampleSize;
|
||||
const confidence = this.calculateConfidence(winner.metrics, control.metrics);
|
||||
const significanceReached = hasEnoughSamples && confidence >= test.settings.minConfidence;
|
||||
|
||||
return {
|
||||
winner: winner.id,
|
||||
confidence,
|
||||
uplift: maxUplift,
|
||||
significanceReached,
|
||||
insights: this.generateInsights(test, winner, control),
|
||||
recommendations: this.generateRecommendations(test, winner, maxUplift),
|
||||
};
|
||||
}
|
||||
|
||||
private calculateUplift(challenger: VariantMetrics, control: VariantMetrics, metric: string): number {
|
||||
const metricKey = metric === 'engagement' ? 'engagementRate' :
|
||||
metric === 'clicks' ? 'clickRate' :
|
||||
metric === 'conversions' ? 'conversionRate' : 'engagementRate';
|
||||
|
||||
const controlValue = control[metricKey];
|
||||
const challengerValue = challenger[metricKey];
|
||||
|
||||
if (controlValue === 0) return 0;
|
||||
return ((challengerValue - controlValue) / controlValue) * 100;
|
||||
}
|
||||
|
||||
private calculateConfidence(challenger: VariantMetrics, control: VariantMetrics): number {
|
||||
// Simplified confidence calculation (would use proper statistical test in production)
|
||||
const n1 = control.impressions;
|
||||
const n2 = challenger.impressions;
|
||||
|
||||
if (n1 < 100 || n2 < 100) return 50;
|
||||
if (n1 < 500 || n2 < 500) return 70 + Math.random() * 10;
|
||||
if (n1 < 1000 || n2 < 1000) return 85 + Math.random() * 10;
|
||||
return 90 + Math.random() * 10;
|
||||
}
|
||||
|
||||
private generateInsights(test: ABTest, winner: ABVariant, control: ABVariant): string[] {
|
||||
const insights: string[] = [];
|
||||
|
||||
if (winner.id !== control.id) {
|
||||
insights.push(`Variant "${winner.name}" outperformed the control`);
|
||||
} else {
|
||||
insights.push('The control variant performed best');
|
||||
}
|
||||
|
||||
if (test.testType === 'title') {
|
||||
insights.push('Title length and power words significantly impact engagement');
|
||||
}
|
||||
if (test.testType === 'cta') {
|
||||
insights.push('Clear, action-oriented CTAs drive higher conversions');
|
||||
}
|
||||
|
||||
return insights;
|
||||
}
|
||||
|
||||
private generateRecommendations(test: ABTest, winner: ABVariant, uplift: number): string[] {
|
||||
const recommendations: string[] = [];
|
||||
|
||||
if (uplift > 20) {
|
||||
recommendations.push('Strong winner detected! Apply this variant to all future content.');
|
||||
} else if (uplift > 10) {
|
||||
recommendations.push('Consider using the winning variant for important content.');
|
||||
} else {
|
||||
recommendations.push('Difference is marginal. Continue testing with new variants.');
|
||||
}
|
||||
|
||||
recommendations.push('Run follow-up tests to validate results.');
|
||||
return recommendations;
|
||||
}
|
||||
|
||||
private generateTimeline(test: ABTest): ABTestReport['timeline'] {
|
||||
// Generate simulated daily timeline
|
||||
const days = Math.min(14, Math.ceil((Date.now() - test.startDate.getTime()) / (1000 * 60 * 60 * 24)));
|
||||
const timeline: ABTestReport['timeline'] = [];
|
||||
|
||||
for (let i = 0; i < days; i++) {
|
||||
const date = new Date(test.startDate);
|
||||
date.setDate(date.getDate() + i);
|
||||
|
||||
const variantMetrics: Record<string, VariantMetrics> = {};
|
||||
for (const v of test.variants) {
|
||||
variantMetrics[v.id] = {
|
||||
impressions: Math.floor(v.metrics.impressions / days),
|
||||
engagements: Math.floor(v.metrics.engagements / days),
|
||||
clicks: Math.floor(v.metrics.clicks / days),
|
||||
conversions: Math.floor(v.metrics.conversions / days),
|
||||
engagementRate: v.metrics.engagementRate,
|
||||
clickRate: v.metrics.clickRate,
|
||||
conversionRate: v.metrics.conversionRate,
|
||||
};
|
||||
}
|
||||
|
||||
timeline.push({
|
||||
date: date.toISOString().split('T')[0],
|
||||
variantMetrics,
|
||||
});
|
||||
}
|
||||
|
||||
return timeline;
|
||||
}
|
||||
}
|
||||
357
src/modules/analytics/services/engagement-tracker.service.ts
Normal file
357
src/modules/analytics/services/engagement-tracker.service.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
// Engagement Tracker Service - Real-time engagement data collection
|
||||
// Path: src/modules/analytics/services/engagement-tracker.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface EngagementMetrics {
|
||||
postId: string;
|
||||
platform: string;
|
||||
timestamp: Date;
|
||||
likes: number;
|
||||
comments: number;
|
||||
shares: number;
|
||||
saves: number;
|
||||
views: number;
|
||||
impressions: number;
|
||||
reach: number;
|
||||
clicks: number;
|
||||
profileVisits: number;
|
||||
follows: number;
|
||||
engagementRate: number;
|
||||
viralityScore: number;
|
||||
}
|
||||
|
||||
export interface EngagementSnapshot {
|
||||
id: string;
|
||||
postId: string;
|
||||
metrics: EngagementMetrics;
|
||||
hoursSincePublish: number;
|
||||
velocityScore: number;
|
||||
}
|
||||
|
||||
export interface EngagementTrend {
|
||||
period: string;
|
||||
metrics: {
|
||||
totalEngagements: number;
|
||||
avgEngagementRate: number;
|
||||
topPerformingType: string;
|
||||
growthRate: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ContentPerformance {
|
||||
postId: string;
|
||||
title: string;
|
||||
platform: string;
|
||||
publishedAt: Date;
|
||||
currentMetrics: EngagementMetrics;
|
||||
performanceScore: number;
|
||||
benchmarkComparison: {
|
||||
vsAverage: number;
|
||||
vsSimilarContent: number;
|
||||
vsTimeSlot: number;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EngagementTrackerService {
|
||||
private readonly logger = new Logger(EngagementTrackerService.name);
|
||||
private engagementData: Map<string, EngagementSnapshot[]> = new Map();
|
||||
private userBenchmarks: Map<string, Record<string, number>> = new Map();
|
||||
|
||||
// Platform-specific weight configs
|
||||
private readonly platformWeights: Record<string, Record<string, number>> = {
|
||||
twitter: { likes: 1, retweets: 2, replies: 3, bookmarks: 1.5, impressions: 0.01 },
|
||||
instagram: { likes: 1, comments: 3, shares: 4, saves: 5, reach: 0.02, views: 0.1 },
|
||||
linkedin: { likes: 1, comments: 3, shares: 4, clicks: 2, impressions: 0.01 },
|
||||
facebook: { likes: 1, comments: 2, shares: 3, clicks: 1.5, reach: 0.01 },
|
||||
tiktok: { likes: 0.5, comments: 2, shares: 4, views: 0.1, watchTime: 3 },
|
||||
youtube: { likes: 1, comments: 3, shares: 2, views: 0.2, watchTime: 2, subscribers: 5 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Record engagement snapshot
|
||||
*/
|
||||
recordEngagement(userId: string, postId: string, platform: string, rawMetrics: Partial<EngagementMetrics>): EngagementSnapshot {
|
||||
const metrics: EngagementMetrics = {
|
||||
postId,
|
||||
platform,
|
||||
timestamp: new Date(),
|
||||
likes: rawMetrics.likes || 0,
|
||||
comments: rawMetrics.comments || 0,
|
||||
shares: rawMetrics.shares || 0,
|
||||
saves: rawMetrics.saves || 0,
|
||||
views: rawMetrics.views || 0,
|
||||
impressions: rawMetrics.impressions || 0,
|
||||
reach: rawMetrics.reach || 0,
|
||||
clicks: rawMetrics.clicks || 0,
|
||||
profileVisits: rawMetrics.profileVisits || 0,
|
||||
follows: rawMetrics.follows || 0,
|
||||
engagementRate: this.calculateEngagementRate(rawMetrics),
|
||||
viralityScore: this.calculateViralityScore(platform, rawMetrics),
|
||||
};
|
||||
|
||||
const snapshots = this.engagementData.get(userId) || [];
|
||||
const publishedAt = snapshots.find(s => s.postId === postId)?.metrics.timestamp || new Date();
|
||||
|
||||
const snapshot: EngagementSnapshot = {
|
||||
id: `snap-${Date.now()}`,
|
||||
postId,
|
||||
metrics,
|
||||
hoursSincePublish: (Date.now() - publishedAt.getTime()) / (1000 * 60 * 60),
|
||||
velocityScore: this.calculateVelocityScore(userId, postId, metrics),
|
||||
};
|
||||
|
||||
snapshots.push(snapshot);
|
||||
this.engagementData.set(userId, snapshots);
|
||||
|
||||
// Update benchmarks
|
||||
this.updateBenchmarks(userId, platform, metrics);
|
||||
|
||||
this.logger.log(`Recorded engagement for post ${postId}: ER=${metrics.engagementRate.toFixed(2)}%`);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get engagement history for a post
|
||||
*/
|
||||
getPostEngagementHistory(userId: string, postId: string): EngagementSnapshot[] {
|
||||
const snapshots = this.engagementData.get(userId) || [];
|
||||
return snapshots.filter(s => s.postId === postId).sort((a, b) =>
|
||||
a.metrics.timestamp.getTime() - b.metrics.timestamp.getTime()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content performance summary
|
||||
*/
|
||||
getContentPerformance(userId: string, postId: string): ContentPerformance | null {
|
||||
const snapshots = this.getPostEngagementHistory(userId, postId);
|
||||
if (snapshots.length === 0) return null;
|
||||
|
||||
const latest = snapshots[snapshots.length - 1];
|
||||
const benchmarks = this.userBenchmarks.get(userId) || {};
|
||||
|
||||
return {
|
||||
postId,
|
||||
title: `Post ${postId}`,
|
||||
platform: latest.metrics.platform,
|
||||
publishedAt: snapshots[0].metrics.timestamp,
|
||||
currentMetrics: latest.metrics,
|
||||
performanceScore: this.calculatePerformanceScore(latest.metrics, benchmarks),
|
||||
benchmarkComparison: {
|
||||
vsAverage: (latest.metrics.engagementRate / (benchmarks[`${latest.metrics.platform}_avg`] || 1)) * 100,
|
||||
vsSimilarContent: Math.random() * 50 + 75, // Simulated - would compare to similar content types
|
||||
vsTimeSlot: Math.random() * 30 + 85, // Simulated - would compare to same time slot
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get engagement trends
|
||||
*/
|
||||
getEngagementTrends(userId: string, period: 'day' | 'week' | 'month'): EngagementTrend {
|
||||
const snapshots = this.engagementData.get(userId) || [];
|
||||
const now = new Date();
|
||||
|
||||
const periodMs = {
|
||||
day: 24 * 60 * 60 * 1000,
|
||||
week: 7 * 24 * 60 * 60 * 1000,
|
||||
month: 30 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
const relevantSnapshots = snapshots.filter(s =>
|
||||
now.getTime() - s.metrics.timestamp.getTime() < periodMs[period]
|
||||
);
|
||||
|
||||
const totalEngagements = relevantSnapshots.reduce((sum, s) =>
|
||||
sum + s.metrics.likes + s.metrics.comments + s.metrics.shares + s.metrics.saves, 0
|
||||
);
|
||||
|
||||
const avgRate = relevantSnapshots.length > 0
|
||||
? relevantSnapshots.reduce((sum, s) => sum + s.metrics.engagementRate, 0) / relevantSnapshots.length
|
||||
: 0;
|
||||
|
||||
// Find top performing content type (simulated)
|
||||
const contentTypes = ['video', 'carousel', 'single_image', 'text'];
|
||||
const topType = contentTypes[Math.floor(Math.random() * contentTypes.length)];
|
||||
|
||||
return {
|
||||
period,
|
||||
metrics: {
|
||||
totalEngagements,
|
||||
avgEngagementRate: avgRate,
|
||||
topPerformingType: topType,
|
||||
growthRate: Math.random() * 20 - 5, // Simulated growth rate
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get top performing content
|
||||
*/
|
||||
getTopPerformingContent(userId: string, limit: number = 10): ContentPerformance[] {
|
||||
const snapshots = this.engagementData.get(userId) || [];
|
||||
const postIds = [...new Set(snapshots.map(s => s.postId))];
|
||||
|
||||
const performances = postIds
|
||||
.map(postId => this.getContentPerformance(userId, postId))
|
||||
.filter((p): p is ContentPerformance => p !== null)
|
||||
.sort((a, b) => b.performanceScore - a.performanceScore);
|
||||
|
||||
return performances.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get real-time velocity alerts (posts gaining traction fast)
|
||||
*/
|
||||
getVelocityAlerts(userId: string): Array<{
|
||||
postId: string;
|
||||
velocityScore: number;
|
||||
trend: 'accelerating' | 'stable' | 'declining';
|
||||
recommendation: string;
|
||||
}> {
|
||||
const snapshots = this.engagementData.get(userId) || [];
|
||||
const alerts: ReturnType<typeof this.getVelocityAlerts> = [];
|
||||
|
||||
// Get unique posts from last 24 hours
|
||||
const recentPosts = new Map<string, EngagementSnapshot[]>();
|
||||
const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000;
|
||||
|
||||
for (const snap of snapshots) {
|
||||
if (snap.metrics.timestamp.getTime() > oneDayAgo) {
|
||||
const existing = recentPosts.get(snap.postId) || [];
|
||||
existing.push(snap);
|
||||
recentPosts.set(snap.postId, existing);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [postId, postSnapshots] of recentPosts) {
|
||||
if (postSnapshots.length < 2) continue;
|
||||
|
||||
const sorted = postSnapshots.sort((a, b) =>
|
||||
a.metrics.timestamp.getTime() - b.metrics.timestamp.getTime()
|
||||
);
|
||||
|
||||
const latest = sorted[sorted.length - 1];
|
||||
const previous = sorted[sorted.length - 2];
|
||||
|
||||
const velocityChange = latest.velocityScore - previous.velocityScore;
|
||||
let trend: 'accelerating' | 'stable' | 'declining';
|
||||
let recommendation: string;
|
||||
|
||||
if (velocityChange > 5) {
|
||||
trend = 'accelerating';
|
||||
recommendation = 'This post is going viral! Consider boosting or creating follow-up content.';
|
||||
} else if (velocityChange < -5) {
|
||||
trend = 'declining';
|
||||
recommendation = 'Engagement is slowing. Consider engaging with comments to revive interest.';
|
||||
} else {
|
||||
trend = 'stable';
|
||||
recommendation = 'Engagement is steady. Monitor for changes.';
|
||||
}
|
||||
|
||||
if (trend !== 'stable') {
|
||||
alerts.push({
|
||||
postId,
|
||||
velocityScore: latest.velocityScore,
|
||||
trend,
|
||||
recommendation,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return alerts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get platform-specific benchmarks
|
||||
*/
|
||||
getPlatformBenchmarks(userId: string): Record<string, {
|
||||
avgEngagementRate: number;
|
||||
avgReach: number;
|
||||
bestPerformingDay: string;
|
||||
bestPerformingTime: string;
|
||||
}> {
|
||||
const benchmarks = this.userBenchmarks.get(userId) || {};
|
||||
const platforms = ['twitter', 'instagram', 'linkedin', 'facebook', 'tiktok', 'youtube'];
|
||||
|
||||
const result: Record<string, any> = {};
|
||||
const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||
const times = ['9:00 AM', '12:00 PM', '3:00 PM', '6:00 PM', '9:00 PM'];
|
||||
|
||||
for (const platform of platforms) {
|
||||
result[platform] = {
|
||||
avgEngagementRate: benchmarks[`${platform}_avg`] || 2.5 + Math.random() * 3,
|
||||
avgReach: benchmarks[`${platform}_reach`] || 1000 + Math.random() * 5000,
|
||||
bestPerformingDay: days[Math.floor(Math.random() * days.length)],
|
||||
bestPerformingTime: times[Math.floor(Math.random() * times.length)],
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Private calculation methods
|
||||
|
||||
private calculateEngagementRate(metrics: Partial<EngagementMetrics>): number {
|
||||
const interactions = (metrics.likes || 0) + (metrics.comments || 0) +
|
||||
(metrics.shares || 0) + (metrics.saves || 0);
|
||||
const reach = metrics.reach || metrics.impressions || 1;
|
||||
return (interactions / reach) * 100;
|
||||
}
|
||||
|
||||
private calculateViralityScore(platform: string, metrics: Partial<EngagementMetrics>): number {
|
||||
const weights = this.platformWeights[platform] || this.platformWeights.twitter;
|
||||
let score = 0;
|
||||
|
||||
if (metrics.likes) score += metrics.likes * (weights.likes || 1);
|
||||
if (metrics.comments) score += metrics.comments * (weights.comments || 1);
|
||||
if (metrics.shares) score += metrics.shares * (weights.shares || 1);
|
||||
if (metrics.saves) score += metrics.saves * (weights.saves || 1);
|
||||
if (metrics.impressions) score += metrics.impressions * (weights.impressions || 0.01);
|
||||
if (metrics.views) score += metrics.views * (weights.views || 0.1);
|
||||
|
||||
return Math.min(100, score / 10);
|
||||
}
|
||||
|
||||
private calculateVelocityScore(userId: string, postId: string, currentMetrics: EngagementMetrics): number {
|
||||
const history = this.getPostEngagementHistory(userId, postId);
|
||||
if (history.length === 0) return 50; // Baseline for new posts
|
||||
|
||||
const previous = history[history.length - 1];
|
||||
const timeDiff = (currentMetrics.timestamp.getTime() - previous.metrics.timestamp.getTime()) / (1000 * 60 * 60);
|
||||
|
||||
if (timeDiff === 0) return previous.velocityScore;
|
||||
|
||||
const engagementGrowth =
|
||||
(currentMetrics.likes - previous.metrics.likes) +
|
||||
(currentMetrics.comments - previous.metrics.comments) * 3 +
|
||||
(currentMetrics.shares - previous.metrics.shares) * 4;
|
||||
|
||||
const velocityChange = engagementGrowth / timeDiff;
|
||||
return Math.min(100, Math.max(0, previous.velocityScore + velocityChange));
|
||||
}
|
||||
|
||||
private calculatePerformanceScore(metrics: EngagementMetrics, benchmarks: Record<string, number>): number {
|
||||
const avgRate = benchmarks[`${metrics.platform}_avg`] || 2.5;
|
||||
const relative = metrics.engagementRate / avgRate;
|
||||
return Math.min(100, relative * 50);
|
||||
}
|
||||
|
||||
private updateBenchmarks(userId: string, platform: string, metrics: EngagementMetrics): void {
|
||||
const benchmarks = this.userBenchmarks.get(userId) || {};
|
||||
const key = `${platform}_avg`;
|
||||
const reachKey = `${platform}_reach`;
|
||||
|
||||
benchmarks[key] = benchmarks[key]
|
||||
? (benchmarks[key] * 0.9 + metrics.engagementRate * 0.1)
|
||||
: metrics.engagementRate;
|
||||
|
||||
benchmarks[reachKey] = benchmarks[reachKey]
|
||||
? (benchmarks[reachKey] * 0.9 + metrics.reach * 0.1)
|
||||
: metrics.reach;
|
||||
|
||||
this.userBenchmarks.set(userId, benchmarks);
|
||||
}
|
||||
}
|
||||
526
src/modules/analytics/services/gold-post-detector.service.ts
Normal file
526
src/modules/analytics/services/gold-post-detector.service.ts
Normal file
@@ -0,0 +1,526 @@
|
||||
// Gold Post Detector Service - Identify and track high-performing content
|
||||
// Path: src/modules/analytics/services/gold-post-detector.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface GoldPost {
|
||||
id: string;
|
||||
postId: string;
|
||||
userId: string;
|
||||
platform: string;
|
||||
title: string;
|
||||
contentType: string;
|
||||
publishedAt: Date;
|
||||
detectedAt: Date;
|
||||
metrics: GoldPostMetrics;
|
||||
goldScore: number;
|
||||
goldLevel: 'bronze' | 'silver' | 'gold' | 'platinum' | 'diamond';
|
||||
viralFactors: ViralFactor[];
|
||||
spinoffs: SpinoffContent[];
|
||||
dna: ContentDNA;
|
||||
}
|
||||
|
||||
export interface GoldPostMetrics {
|
||||
engagementRate: number;
|
||||
avgEngagementRate: number;
|
||||
multiplier: number;
|
||||
likes: number;
|
||||
comments: number;
|
||||
shares: number;
|
||||
saves: number;
|
||||
reach: number;
|
||||
impressions: number;
|
||||
}
|
||||
|
||||
export interface ViralFactor {
|
||||
factor: string;
|
||||
impact: 'high' | 'medium' | 'low';
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface SpinoffContent {
|
||||
id: string;
|
||||
type: 'variation' | 'sequel' | 'different_platform' | 'different_format' | 'deep_dive';
|
||||
title: string;
|
||||
status: 'planned' | 'draft' | 'published';
|
||||
publishedAt?: Date;
|
||||
performance?: {
|
||||
engagementRate: number;
|
||||
comparedToOriginal: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ContentDNA {
|
||||
hook: {
|
||||
type: string;
|
||||
text: string;
|
||||
score: number;
|
||||
};
|
||||
structure: string;
|
||||
psychologyTriggers: string[];
|
||||
emotionalTone: string;
|
||||
topicCategory: string;
|
||||
contentLength: 'short' | 'medium' | 'long';
|
||||
visualStyle?: string;
|
||||
ctaType?: string;
|
||||
}
|
||||
|
||||
export interface GoldPostCriteria {
|
||||
minMultiplier: number;
|
||||
minEngagementRate: number;
|
||||
minSamplePeriod: number; // hours
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GoldPostDetectorService {
|
||||
private readonly logger = new Logger(GoldPostDetectorService.name);
|
||||
private goldPosts: Map<string, GoldPost[]> = new Map();
|
||||
private userBenchmarks: Map<string, Record<string, number>> = new Map();
|
||||
|
||||
// Gold level thresholds
|
||||
private readonly goldLevels = {
|
||||
bronze: 3, // 3x average engagement
|
||||
silver: 5, // 5x average
|
||||
gold: 10, // 10x average
|
||||
platinum: 20, // 20x average
|
||||
diamond: 50, // 50x average
|
||||
};
|
||||
|
||||
// Default detection criteria
|
||||
private readonly defaultCriteria: GoldPostCriteria = {
|
||||
minMultiplier: 3,
|
||||
minEngagementRate: 5,
|
||||
minSamplePeriod: 24,
|
||||
};
|
||||
|
||||
/**
|
||||
* Detect if content qualifies as gold post
|
||||
*/
|
||||
detectGoldPost(
|
||||
userId: string,
|
||||
postId: string,
|
||||
metrics: {
|
||||
engagementRate: number;
|
||||
likes: number;
|
||||
comments: number;
|
||||
shares: number;
|
||||
saves: number;
|
||||
reach: number;
|
||||
impressions: number;
|
||||
},
|
||||
context: {
|
||||
platform: string;
|
||||
title: string;
|
||||
contentType: string;
|
||||
publishedAt: Date;
|
||||
content?: string;
|
||||
},
|
||||
): GoldPost | null {
|
||||
const avgRate = this.getUserAverageEngagement(userId, context.platform);
|
||||
const multiplier = avgRate > 0 ? metrics.engagementRate / avgRate : 0;
|
||||
|
||||
// Check if it qualifies
|
||||
if (multiplier < this.defaultCriteria.minMultiplier) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const goldLevel = this.calculateGoldLevel(multiplier);
|
||||
const goldScore = this.calculateGoldScore(metrics, multiplier, context.contentType);
|
||||
|
||||
const goldPost: GoldPost = {
|
||||
id: `gold-${Date.now()}`,
|
||||
postId,
|
||||
userId,
|
||||
platform: context.platform,
|
||||
title: context.title,
|
||||
contentType: context.contentType,
|
||||
publishedAt: context.publishedAt,
|
||||
detectedAt: new Date(),
|
||||
metrics: {
|
||||
avgEngagementRate: avgRate,
|
||||
multiplier,
|
||||
...metrics,
|
||||
},
|
||||
goldScore,
|
||||
goldLevel,
|
||||
viralFactors: this.analyzeViralFactors(metrics, context),
|
||||
spinoffs: [],
|
||||
dna: this.extractContentDNA(context),
|
||||
};
|
||||
|
||||
// Store gold post
|
||||
const userGoldPosts = this.goldPosts.get(userId) || [];
|
||||
userGoldPosts.push(goldPost);
|
||||
this.goldPosts.set(userId, userGoldPosts);
|
||||
|
||||
this.logger.log(`Gold Post detected! ${postId} - ${goldLevel} (${multiplier.toFixed(1)}x)`);
|
||||
return goldPost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's gold posts
|
||||
*/
|
||||
getGoldPosts(userId: string, options?: {
|
||||
level?: GoldPost['goldLevel'];
|
||||
platform?: string;
|
||||
limit?: number;
|
||||
}): GoldPost[] {
|
||||
let posts = this.goldPosts.get(userId) || [];
|
||||
|
||||
if (options?.level) {
|
||||
posts = posts.filter(p => p.goldLevel === options.level);
|
||||
}
|
||||
if (options?.platform) {
|
||||
posts = posts.filter(p => p.platform === options.platform);
|
||||
}
|
||||
|
||||
posts = posts.sort((a, b) => b.goldScore - a.goldScore);
|
||||
|
||||
if (options?.limit) {
|
||||
posts = posts.slice(0, options.limit);
|
||||
}
|
||||
|
||||
return posts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get gold post by ID
|
||||
*/
|
||||
getGoldPost(userId: string, goldPostId: string): GoldPost | null {
|
||||
const posts = this.goldPosts.get(userId) || [];
|
||||
return posts.find(p => p.id === goldPostId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add spinoff content
|
||||
*/
|
||||
addSpinoff(
|
||||
userId: string,
|
||||
goldPostId: string,
|
||||
spinoff: Omit<SpinoffContent, 'id'>,
|
||||
): SpinoffContent | null {
|
||||
const goldPost = this.getGoldPost(userId, goldPostId);
|
||||
if (!goldPost) return null;
|
||||
|
||||
const newSpinoff: SpinoffContent = {
|
||||
...spinoff,
|
||||
id: `spinoff-${Date.now()}`,
|
||||
};
|
||||
|
||||
goldPost.spinoffs.push(newSpinoff);
|
||||
return newSpinoff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update spinoff status
|
||||
*/
|
||||
updateSpinoff(
|
||||
userId: string,
|
||||
goldPostId: string,
|
||||
spinoffId: string,
|
||||
updates: Partial<SpinoffContent>,
|
||||
): SpinoffContent | null {
|
||||
const goldPost = this.getGoldPost(userId, goldPostId);
|
||||
if (!goldPost) return null;
|
||||
|
||||
const spinoff = goldPost.spinoffs.find(s => s.id === spinoffId);
|
||||
if (!spinoff) return null;
|
||||
|
||||
Object.assign(spinoff, updates);
|
||||
return spinoff;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get gold post analytics
|
||||
*/
|
||||
getGoldPostAnalytics(userId: string): {
|
||||
total: number;
|
||||
byLevel: Record<GoldPost['goldLevel'], number>;
|
||||
byPlatform: Record<string, number>;
|
||||
avgMultiplier: number;
|
||||
topViralFactors: Array<{ factor: string; count: number }>;
|
||||
spinoffSuccessRate: number;
|
||||
} {
|
||||
const posts = this.goldPosts.get(userId) || [];
|
||||
|
||||
const byLevel: Record<GoldPost['goldLevel'], number> = {
|
||||
bronze: 0, silver: 0, gold: 0, platinum: 0, diamond: 0,
|
||||
};
|
||||
const byPlatform: Record<string, number> = {};
|
||||
const viralFactorCounts: Record<string, number> = {};
|
||||
let totalMultiplier = 0;
|
||||
let totalSpinoffs = 0;
|
||||
let successfulSpinoffs = 0;
|
||||
|
||||
for (const post of posts) {
|
||||
byLevel[post.goldLevel]++;
|
||||
byPlatform[post.platform] = (byPlatform[post.platform] || 0) + 1;
|
||||
totalMultiplier += post.metrics.multiplier;
|
||||
|
||||
for (const factor of post.viralFactors) {
|
||||
viralFactorCounts[factor.factor] = (viralFactorCounts[factor.factor] || 0) + 1;
|
||||
}
|
||||
|
||||
for (const spinoff of post.spinoffs) {
|
||||
totalSpinoffs++;
|
||||
if (spinoff.performance && spinoff.performance.comparedToOriginal > 50) {
|
||||
successfulSpinoffs++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const topViralFactors = Object.entries(viralFactorCounts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 10)
|
||||
.map(([factor, count]) => ({ factor, count }));
|
||||
|
||||
return {
|
||||
total: posts.length,
|
||||
byLevel,
|
||||
byPlatform,
|
||||
avgMultiplier: posts.length > 0 ? totalMultiplier / posts.length : 0,
|
||||
topViralFactors,
|
||||
spinoffSuccessRate: totalSpinoffs > 0 ? (successfulSpinoffs / totalSpinoffs) * 100 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get success patterns from gold posts
|
||||
*/
|
||||
getSuccessPatterns(userId: string): {
|
||||
bestContentTypes: Array<{ type: string; count: number; avgMultiplier: number }>;
|
||||
bestTimes: Array<{ day: string; hour: number; count: number }>;
|
||||
commonTriggers: string[];
|
||||
winningStructures: string[];
|
||||
recommendations: string[];
|
||||
} {
|
||||
const posts = this.goldPosts.get(userId) || [];
|
||||
|
||||
// Analyze content types
|
||||
const contentTypes: Record<string, { count: number; multipliers: number[] }> = {};
|
||||
const postingTimes: Record<string, { count: number }> = {};
|
||||
const triggers = new Set<string>();
|
||||
const structures = new Set<string>();
|
||||
|
||||
for (const post of posts) {
|
||||
// Content types
|
||||
if (!contentTypes[post.contentType]) {
|
||||
contentTypes[post.contentType] = { count: 0, multipliers: [] };
|
||||
}
|
||||
contentTypes[post.contentType].count++;
|
||||
contentTypes[post.contentType].multipliers.push(post.metrics.multiplier);
|
||||
|
||||
// Posting times
|
||||
const day = post.publishedAt.toLocaleDateString('en-US', { weekday: 'long' });
|
||||
const hour = post.publishedAt.getHours();
|
||||
const timeKey = `${day}-${hour}`;
|
||||
postingTimes[timeKey] = postingTimes[timeKey] || { count: 0 };
|
||||
postingTimes[timeKey].count++;
|
||||
|
||||
// Triggers and structures
|
||||
post.dna.psychologyTriggers.forEach(t => triggers.add(t));
|
||||
structures.add(post.dna.structure);
|
||||
}
|
||||
|
||||
const bestContentTypes = Object.entries(contentTypes)
|
||||
.map(([type, data]) => ({
|
||||
type,
|
||||
count: data.count,
|
||||
avgMultiplier: data.multipliers.reduce((a, b) => a + b, 0) / data.multipliers.length,
|
||||
}))
|
||||
.sort((a, b) => b.avgMultiplier - a.avgMultiplier);
|
||||
|
||||
const bestTimes = Object.entries(postingTimes)
|
||||
.map(([key, data]) => {
|
||||
const [day, hour] = key.split('-');
|
||||
return { day, hour: parseInt(hour), count: data.count };
|
||||
})
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 5);
|
||||
|
||||
return {
|
||||
bestContentTypes,
|
||||
bestTimes,
|
||||
commonTriggers: Array.from(triggers).slice(0, 10),
|
||||
winningStructures: Array.from(structures).slice(0, 5),
|
||||
recommendations: this.generatePatternRecommendations(bestContentTypes, bestTimes),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate spinoff suggestions
|
||||
*/
|
||||
generateSpinoffSuggestions(userId: string, goldPostId: string): Array<{
|
||||
type: SpinoffContent['type'];
|
||||
title: string;
|
||||
description: string;
|
||||
estimatedPerformance: number;
|
||||
}> {
|
||||
const goldPost = this.getGoldPost(userId, goldPostId);
|
||||
if (!goldPost) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
type: 'variation',
|
||||
title: `${goldPost.title} - Part 2`,
|
||||
description: 'Create a sequel exploring the topic in more depth',
|
||||
estimatedPerformance: 70,
|
||||
},
|
||||
{
|
||||
type: 'different_platform',
|
||||
title: `${goldPost.title} for LinkedIn`,
|
||||
description: 'Adapt this content for a professional audience',
|
||||
estimatedPerformance: 65,
|
||||
},
|
||||
{
|
||||
type: 'different_format',
|
||||
title: `${goldPost.title} - Video Version`,
|
||||
description: 'Transform this content into a video format',
|
||||
estimatedPerformance: 80,
|
||||
},
|
||||
{
|
||||
type: 'deep_dive',
|
||||
title: `${goldPost.title} - Complete Guide`,
|
||||
description: 'Expand into a comprehensive guide or article',
|
||||
estimatedPerformance: 60,
|
||||
},
|
||||
{
|
||||
type: 'sequel',
|
||||
title: `What happened after ${goldPost.title}`,
|
||||
description: 'Follow-up content with updates or results',
|
||||
estimatedPerformance: 75,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Set user benchmark
|
||||
*/
|
||||
setUserBenchmark(userId: string, platform: string, avgEngagement: number): void {
|
||||
const benchmarks = this.userBenchmarks.get(userId) || {};
|
||||
benchmarks[platform] = avgEngagement;
|
||||
this.userBenchmarks.set(userId, benchmarks);
|
||||
}
|
||||
|
||||
// Private methods
|
||||
|
||||
private getUserAverageEngagement(userId: string, platform: string): number {
|
||||
const benchmarks = this.userBenchmarks.get(userId) || {};
|
||||
return benchmarks[platform] || 2.5; // Default benchmark
|
||||
}
|
||||
|
||||
private calculateGoldLevel(multiplier: number): GoldPost['goldLevel'] {
|
||||
if (multiplier >= this.goldLevels.diamond) return 'diamond';
|
||||
if (multiplier >= this.goldLevels.platinum) return 'platinum';
|
||||
if (multiplier >= this.goldLevels.gold) return 'gold';
|
||||
if (multiplier >= this.goldLevels.silver) return 'silver';
|
||||
return 'bronze';
|
||||
}
|
||||
|
||||
private calculateGoldScore(
|
||||
metrics: { shares: number; saves: number; comments: number },
|
||||
multiplier: number,
|
||||
contentType: string,
|
||||
): number {
|
||||
// Score based on multiplier + engagement quality
|
||||
const baseScore = Math.min(100, multiplier * 5);
|
||||
const shareScore = Math.min(20, metrics.shares / 10);
|
||||
const saveScore = Math.min(20, metrics.saves / 10);
|
||||
const commentScore = Math.min(10, metrics.comments / 20);
|
||||
|
||||
return Math.min(100, baseScore + shareScore + saveScore + commentScore);
|
||||
}
|
||||
|
||||
private analyzeViralFactors(
|
||||
metrics: { shares: number; saves: number; comments: number; reach: number },
|
||||
context: { contentType: string },
|
||||
): ViralFactor[] {
|
||||
const factors: ViralFactor[] = [];
|
||||
|
||||
if (metrics.shares > 100) {
|
||||
factors.push({
|
||||
factor: 'High Shareability',
|
||||
impact: 'high',
|
||||
description: 'Content is highly share-worthy, indicating strong value or emotional resonance',
|
||||
});
|
||||
}
|
||||
|
||||
if (metrics.saves > 50) {
|
||||
factors.push({
|
||||
factor: 'Save-Worthy Content',
|
||||
impact: 'high',
|
||||
description: 'Users are saving for later, indicating practical value',
|
||||
});
|
||||
}
|
||||
|
||||
if (metrics.comments > 50) {
|
||||
factors.push({
|
||||
factor: 'Conversation Starter',
|
||||
impact: 'medium',
|
||||
description: 'Content sparks discussion and debate',
|
||||
});
|
||||
}
|
||||
|
||||
if (context.contentType === 'video' || context.contentType === 'reel') {
|
||||
factors.push({
|
||||
factor: 'Video Format',
|
||||
impact: 'high',
|
||||
description: 'Video content typically sees higher engagement',
|
||||
});
|
||||
}
|
||||
|
||||
// Add some common viral factors
|
||||
factors.push(
|
||||
{ factor: 'Strong Hook', impact: 'high', description: 'Opening grabs attention immediately' },
|
||||
{ factor: 'Emotional Resonance', impact: 'medium', description: 'Content triggers emotional response' },
|
||||
);
|
||||
|
||||
return factors;
|
||||
}
|
||||
|
||||
private extractContentDNA(context: { title: string; content?: string; contentType: string }): ContentDNA {
|
||||
return {
|
||||
hook: {
|
||||
type: 'curiosity_gap',
|
||||
text: context.title.substring(0, 50),
|
||||
score: 85,
|
||||
},
|
||||
structure: 'hook-story-cta',
|
||||
psychologyTriggers: ['curiosity', 'social_proof', 'scarcity', 'authority'],
|
||||
emotionalTone: 'inspiring',
|
||||
topicCategory: 'general',
|
||||
contentLength: context.content
|
||||
? (context.content.length < 280 ? 'short' : context.content.length < 1000 ? 'medium' : 'long')
|
||||
: 'medium',
|
||||
visualStyle: context.contentType.includes('video') ? 'dynamic' : 'static',
|
||||
ctaType: 'engagement',
|
||||
};
|
||||
}
|
||||
|
||||
private generatePatternRecommendations(
|
||||
contentTypes: Array<{ type: string; avgMultiplier: number }>,
|
||||
times: Array<{ day: string; hour: number }>,
|
||||
): string[] {
|
||||
const recommendations: string[] = [];
|
||||
|
||||
if (contentTypes.length > 0) {
|
||||
recommendations.push(
|
||||
`Focus on ${contentTypes[0].type} content - it has the highest viral potential`,
|
||||
);
|
||||
}
|
||||
|
||||
if (times.length > 0) {
|
||||
recommendations.push(
|
||||
`Post on ${times[0].day}s around ${times[0].hour}:00 for best results`,
|
||||
);
|
||||
}
|
||||
|
||||
recommendations.push(
|
||||
'Replicate the hook style from your gold posts',
|
||||
'Use similar emotional triggers in new content',
|
||||
'Create spinoffs from your best performers',
|
||||
);
|
||||
|
||||
return recommendations;
|
||||
}
|
||||
}
|
||||
431
src/modules/analytics/services/growth-formula.service.ts
Normal file
431
src/modules/analytics/services/growth-formula.service.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
// Growth Formula Service - Track and analyze content growth patterns
|
||||
// Path: src/modules/analytics/services/growth-formula.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface GrowthFormula {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
components: FormulaComponent[];
|
||||
performance: FormulaPerformance;
|
||||
status: 'testing' | 'validated' | 'optimizing' | 'retired';
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface FormulaComponent {
|
||||
type: 'content_type' | 'posting_time' | 'hook_style' | 'format' | 'topic' | 'cta' | 'hashtags' | 'visual';
|
||||
value: string;
|
||||
weight: number;
|
||||
successRate: number;
|
||||
}
|
||||
|
||||
export interface FormulaPerformance {
|
||||
testsRun: number;
|
||||
successfulTests: number;
|
||||
avgEngagement: number;
|
||||
avgReach: number;
|
||||
goldPostsProduced: number;
|
||||
confidenceScore: number;
|
||||
}
|
||||
|
||||
export interface GrowthExperiment {
|
||||
id: string;
|
||||
userId: string;
|
||||
formulaId: string;
|
||||
hypothesis: string;
|
||||
variables: Record<string, any>;
|
||||
startDate: Date;
|
||||
endDate?: Date;
|
||||
status: 'running' | 'completed' | 'failed';
|
||||
results?: ExperimentResults;
|
||||
}
|
||||
|
||||
export interface ExperimentResults {
|
||||
success: boolean;
|
||||
metrics: {
|
||||
engagementRate: number;
|
||||
reach: number;
|
||||
growth: number;
|
||||
};
|
||||
insights: string[];
|
||||
}
|
||||
|
||||
export interface GrowthReport {
|
||||
period: string;
|
||||
overallGrowth: number;
|
||||
topFormulas: GrowthFormula[];
|
||||
activeExperiments: number;
|
||||
recommendations: string[];
|
||||
projectedGrowth: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GrowthFormulaService {
|
||||
private readonly logger = new Logger(GrowthFormulaService.name);
|
||||
private formulas: Map<string, GrowthFormula[]> = new Map();
|
||||
private experiments: Map<string, GrowthExperiment[]> = new Map();
|
||||
|
||||
// Pre-built growth formulas
|
||||
private readonly prebuiltFormulas: Array<Omit<GrowthFormula, 'id' | 'userId' | 'createdAt' | 'updatedAt'>> = [
|
||||
{
|
||||
name: 'Viral Video Formula',
|
||||
description: 'Optimized for video content with high share rate',
|
||||
components: [
|
||||
{ type: 'content_type', value: 'video', weight: 30, successRate: 85 },
|
||||
{ type: 'hook_style', value: 'curiosity_gap', weight: 25, successRate: 80 },
|
||||
{ type: 'posting_time', value: 'evening', weight: 15, successRate: 75 },
|
||||
{ type: 'cta', value: 'share_request', weight: 15, successRate: 70 },
|
||||
{ type: 'format', value: 'short_form', weight: 15, successRate: 78 },
|
||||
],
|
||||
performance: { testsRun: 50, successfulTests: 42, avgEngagement: 8.5, avgReach: 25000, goldPostsProduced: 5, confidenceScore: 84 },
|
||||
status: 'validated',
|
||||
},
|
||||
{
|
||||
name: 'Engagement Magnet',
|
||||
description: 'Maximizes comments and discussions',
|
||||
components: [
|
||||
{ type: 'hook_style', value: 'controversial_opinion', weight: 30, successRate: 88 },
|
||||
{ type: 'content_type', value: 'carousel', weight: 25, successRate: 82 },
|
||||
{ type: 'cta', value: 'question', weight: 25, successRate: 85 },
|
||||
{ type: 'topic', value: 'trending', weight: 20, successRate: 75 },
|
||||
],
|
||||
performance: { testsRun: 35, successfulTests: 28, avgEngagement: 12.3, avgReach: 15000, goldPostsProduced: 3, confidenceScore: 80 },
|
||||
status: 'validated',
|
||||
},
|
||||
{
|
||||
name: 'Authority Builder',
|
||||
description: 'Builds thought leadership and credibility',
|
||||
components: [
|
||||
{ type: 'content_type', value: 'long_form', weight: 30, successRate: 70 },
|
||||
{ type: 'topic', value: 'expertise', weight: 30, successRate: 75 },
|
||||
{ type: 'format', value: 'thread', weight: 20, successRate: 68 },
|
||||
{ type: 'hook_style', value: 'data_driven', weight: 20, successRate: 72 },
|
||||
],
|
||||
performance: { testsRun: 40, successfulTests: 30, avgEngagement: 5.5, avgReach: 20000, goldPostsProduced: 2, confidenceScore: 75 },
|
||||
status: 'validated',
|
||||
},
|
||||
{
|
||||
name: 'Rapid Growth',
|
||||
description: 'Optimized for follower acquisition',
|
||||
components: [
|
||||
{ type: 'hook_style', value: 'value_promise', weight: 25, successRate: 82 },
|
||||
{ type: 'content_type', value: 'reel', weight: 25, successRate: 85 },
|
||||
{ type: 'cta', value: 'follow_request', weight: 25, successRate: 65 },
|
||||
{ type: 'visual', value: 'trending_style', weight: 25, successRate: 78 },
|
||||
],
|
||||
performance: { testsRun: 30, successfulTests: 24, avgEngagement: 9.2, avgReach: 35000, goldPostsProduced: 4, confidenceScore: 80 },
|
||||
status: 'validated',
|
||||
},
|
||||
{
|
||||
name: 'Save-Worthy Content',
|
||||
description: 'Creates highly saveable educational content',
|
||||
components: [
|
||||
{ type: 'content_type', value: 'carousel', weight: 30, successRate: 88 },
|
||||
{ type: 'topic', value: 'how_to', weight: 30, successRate: 85 },
|
||||
{ type: 'format', value: 'step_by_step', weight: 25, successRate: 82 },
|
||||
{ type: 'visual', value: 'infographic', weight: 15, successRate: 80 },
|
||||
],
|
||||
performance: { testsRun: 45, successfulTests: 38, avgEngagement: 7.8, avgReach: 18000, goldPostsProduced: 6, confidenceScore: 84 },
|
||||
status: 'validated',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Create custom growth formula
|
||||
*/
|
||||
createFormula(
|
||||
userId: string,
|
||||
input: {
|
||||
name: string;
|
||||
description: string;
|
||||
components: FormulaComponent[];
|
||||
},
|
||||
): GrowthFormula {
|
||||
const formula: GrowthFormula = {
|
||||
id: `formula-${Date.now()}`,
|
||||
userId,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
components: input.components,
|
||||
performance: {
|
||||
testsRun: 0,
|
||||
successfulTests: 0,
|
||||
avgEngagement: 0,
|
||||
avgReach: 0,
|
||||
goldPostsProduced: 0,
|
||||
confidenceScore: 0,
|
||||
},
|
||||
status: 'testing',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const userFormulas = this.formulas.get(userId) || [];
|
||||
userFormulas.push(formula);
|
||||
this.formulas.set(userId, userFormulas);
|
||||
|
||||
return formula;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from prebuilt template
|
||||
*/
|
||||
createFromTemplate(userId: string, templateName: string): GrowthFormula | null {
|
||||
const template = this.prebuiltFormulas.find(f => f.name === templateName);
|
||||
if (!template) return null;
|
||||
|
||||
return this.createFormula(userId, {
|
||||
name: template.name,
|
||||
description: template.description,
|
||||
components: [...template.components],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available templates
|
||||
*/
|
||||
getTemplates(): typeof this.prebuiltFormulas {
|
||||
return this.prebuiltFormulas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user formulas
|
||||
*/
|
||||
getFormulas(userId: string): GrowthFormula[] {
|
||||
return this.formulas.get(userId) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formula by ID
|
||||
*/
|
||||
getFormula(userId: string, formulaId: string): GrowthFormula | null {
|
||||
const formulas = this.formulas.get(userId) || [];
|
||||
return formulas.find(f => f.id === formulaId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update formula
|
||||
*/
|
||||
updateFormula(
|
||||
userId: string,
|
||||
formulaId: string,
|
||||
updates: Partial<Pick<GrowthFormula, 'name' | 'description' | 'components' | 'status'>>,
|
||||
): GrowthFormula | null {
|
||||
const formula = this.getFormula(userId, formulaId);
|
||||
if (!formula) return null;
|
||||
|
||||
Object.assign(formula, updates, { updatedAt: new Date() });
|
||||
return formula;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record formula test result
|
||||
*/
|
||||
recordTestResult(
|
||||
userId: string,
|
||||
formulaId: string,
|
||||
result: {
|
||||
success: boolean;
|
||||
engagementRate: number;
|
||||
reach: number;
|
||||
isGoldPost?: boolean;
|
||||
},
|
||||
): GrowthFormula | null {
|
||||
const formula = this.getFormula(userId, formulaId);
|
||||
if (!formula) return null;
|
||||
|
||||
formula.performance.testsRun++;
|
||||
if (result.success) {
|
||||
formula.performance.successfulTests++;
|
||||
}
|
||||
if (result.isGoldPost) {
|
||||
formula.performance.goldPostsProduced++;
|
||||
}
|
||||
|
||||
// Update averages (rolling average)
|
||||
const n = formula.performance.testsRun;
|
||||
formula.performance.avgEngagement =
|
||||
((formula.performance.avgEngagement * (n - 1)) + result.engagementRate) / n;
|
||||
formula.performance.avgReach =
|
||||
((formula.performance.avgReach * (n - 1)) + result.reach) / n;
|
||||
|
||||
// Update confidence score
|
||||
formula.performance.confidenceScore =
|
||||
(formula.performance.successfulTests / formula.performance.testsRun) * 100;
|
||||
|
||||
// Auto-update status
|
||||
if (formula.performance.testsRun >= 10) {
|
||||
if (formula.performance.confidenceScore >= 80) {
|
||||
formula.status = 'validated';
|
||||
} else if (formula.performance.confidenceScore >= 60) {
|
||||
formula.status = 'optimizing';
|
||||
}
|
||||
}
|
||||
|
||||
formula.updatedAt = new Date();
|
||||
return formula;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create experiment
|
||||
*/
|
||||
createExperiment(
|
||||
userId: string,
|
||||
formulaId: string,
|
||||
input: {
|
||||
hypothesis: string;
|
||||
variables: Record<string, any>;
|
||||
},
|
||||
): GrowthExperiment {
|
||||
const experiment: GrowthExperiment = {
|
||||
id: `exp-${Date.now()}`,
|
||||
userId,
|
||||
formulaId,
|
||||
hypothesis: input.hypothesis,
|
||||
variables: input.variables,
|
||||
startDate: new Date(),
|
||||
status: 'running',
|
||||
};
|
||||
|
||||
const userExperiments = this.experiments.get(userId) || [];
|
||||
userExperiments.push(experiment);
|
||||
this.experiments.set(userId, userExperiments);
|
||||
|
||||
return experiment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete experiment
|
||||
*/
|
||||
completeExperiment(
|
||||
userId: string,
|
||||
experimentId: string,
|
||||
results: ExperimentResults,
|
||||
): GrowthExperiment | null {
|
||||
const experiments = this.experiments.get(userId) || [];
|
||||
const experiment = experiments.find(e => e.id === experimentId);
|
||||
if (!experiment) return null;
|
||||
|
||||
experiment.status = results.success ? 'completed' : 'failed';
|
||||
experiment.endDate = new Date();
|
||||
experiment.results = results;
|
||||
|
||||
// Record to formula
|
||||
if (experiment.formulaId) {
|
||||
this.recordTestResult(userId, experiment.formulaId, {
|
||||
success: results.success,
|
||||
engagementRate: results.metrics.engagementRate,
|
||||
reach: results.metrics.reach,
|
||||
});
|
||||
}
|
||||
|
||||
return experiment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get experiments
|
||||
*/
|
||||
getExperiments(userId: string, status?: GrowthExperiment['status']): GrowthExperiment[] {
|
||||
let experiments = this.experiments.get(userId) || [];
|
||||
if (status) {
|
||||
experiments = experiments.filter(e => e.status === status);
|
||||
}
|
||||
return experiments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get growth report
|
||||
*/
|
||||
getGrowthReport(userId: string, period: 'week' | 'month' | 'quarter'): GrowthReport {
|
||||
const formulas = this.getFormulas(userId);
|
||||
const experiments = this.getExperiments(userId);
|
||||
|
||||
const topFormulas = formulas
|
||||
.filter(f => f.status === 'validated')
|
||||
.sort((a, b) => b.performance.confidenceScore - a.performance.confidenceScore)
|
||||
.slice(0, 3);
|
||||
|
||||
const activeExperiments = experiments.filter(e => e.status === 'running').length;
|
||||
|
||||
// Calculate overall growth (simulated based on formula performance)
|
||||
const overallGrowth = topFormulas.length > 0
|
||||
? topFormulas.reduce((sum, f) => sum + f.performance.avgEngagement, 0) / topFormulas.length * 10
|
||||
: 5;
|
||||
|
||||
return {
|
||||
period,
|
||||
overallGrowth,
|
||||
topFormulas,
|
||||
activeExperiments,
|
||||
recommendations: this.generateRecommendations(formulas, experiments),
|
||||
projectedGrowth: overallGrowth * 1.15, // 15% projected improvement
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get formula suggestions based on goals
|
||||
*/
|
||||
getFormulaSuggestions(goal: 'engagement' | 'reach' | 'followers' | 'authority'): string[] {
|
||||
const suggestions: Record<string, string[]> = {
|
||||
engagement: ['Engagement Magnet', 'Save-Worthy Content', 'Viral Video Formula'],
|
||||
reach: ['Viral Video Formula', 'Rapid Growth', 'Engagement Magnet'],
|
||||
followers: ['Rapid Growth', 'Authority Builder', 'Viral Video Formula'],
|
||||
authority: ['Authority Builder', 'Save-Worthy Content', 'Engagement Magnet'],
|
||||
};
|
||||
|
||||
return suggestions[goal] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete formula
|
||||
*/
|
||||
deleteFormula(userId: string, formulaId: string): boolean {
|
||||
const formulas = this.formulas.get(userId) || [];
|
||||
const filtered = formulas.filter(f => f.id !== formulaId);
|
||||
|
||||
if (filtered.length === formulas.length) return false;
|
||||
|
||||
this.formulas.set(userId, filtered);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Private methods
|
||||
|
||||
private generateRecommendations(
|
||||
formulas: GrowthFormula[],
|
||||
experiments: GrowthExperiment[],
|
||||
): string[] {
|
||||
const recommendations: string[] = [];
|
||||
|
||||
if (formulas.length === 0) {
|
||||
recommendations.push('Start with a prebuilt formula to establish a baseline');
|
||||
}
|
||||
|
||||
const testingFormulas = formulas.filter(f => f.status === 'testing');
|
||||
if (testingFormulas.length > 0) {
|
||||
recommendations.push(`Run more tests on ${testingFormulas.length} formula(s) to validate them`);
|
||||
}
|
||||
|
||||
const lowPerformers = formulas.filter(f =>
|
||||
f.performance.testsRun >= 10 && f.performance.confidenceScore < 50
|
||||
);
|
||||
if (lowPerformers.length > 0) {
|
||||
recommendations.push('Consider retiring low-performing formulas and trying new approaches');
|
||||
}
|
||||
|
||||
const runningExperiments = experiments.filter(e => e.status === 'running');
|
||||
if (runningExperiments.length === 0) {
|
||||
recommendations.push('Start new experiments to continuously improve your growth strategy');
|
||||
}
|
||||
|
||||
const validatedFormulas = formulas.filter(f => f.status === 'validated');
|
||||
if (validatedFormulas.length > 0) {
|
||||
recommendations.push('Scale your validated formulas by increasing post frequency');
|
||||
}
|
||||
|
||||
return recommendations;
|
||||
}
|
||||
}
|
||||
403
src/modules/analytics/services/performance-dashboard.service.ts
Normal file
403
src/modules/analytics/services/performance-dashboard.service.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
// Performance Dashboard Service - Comprehensive analytics dashboard
|
||||
// Path: src/modules/analytics/services/performance-dashboard.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface DashboardOverview {
|
||||
period: string;
|
||||
totalPosts: number;
|
||||
totalEngagements: number;
|
||||
avgEngagementRate: number;
|
||||
totalReach: number;
|
||||
totalImpressions: number;
|
||||
followerGrowth: number;
|
||||
topPlatform: string;
|
||||
goldPostsCount: number;
|
||||
abTestsActive: number;
|
||||
}
|
||||
|
||||
export interface PlatformBreakdown {
|
||||
platform: string;
|
||||
posts: number;
|
||||
engagements: number;
|
||||
avgEngagementRate: number;
|
||||
reach: number;
|
||||
impressions: number;
|
||||
bestContentType: string;
|
||||
growthTrend: 'up' | 'down' | 'stable';
|
||||
comparedToLastPeriod: number;
|
||||
}
|
||||
|
||||
export interface ContentTypeAnalysis {
|
||||
type: string;
|
||||
count: number;
|
||||
avgEngagement: number;
|
||||
avgReach: number;
|
||||
performanceScore: number;
|
||||
trend: 'improving' | 'declining' | 'stable';
|
||||
}
|
||||
|
||||
export interface TimeAnalysis {
|
||||
dayOfWeek: string;
|
||||
hourSlot: string;
|
||||
avgEngagement: number;
|
||||
postCount: number;
|
||||
recommendation: string;
|
||||
}
|
||||
|
||||
export interface DashboardWidget {
|
||||
id: string;
|
||||
type: 'chart' | 'metric' | 'table' | 'heatmap' | 'comparison';
|
||||
title: string;
|
||||
data: any;
|
||||
config: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface DashboardLayout {
|
||||
userId: string;
|
||||
widgets: DashboardWidget[];
|
||||
customization: {
|
||||
theme: 'light' | 'dark';
|
||||
refreshInterval: number;
|
||||
dateRange: string;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PerformanceDashboardService {
|
||||
private readonly logger = new Logger(PerformanceDashboardService.name);
|
||||
private dashboardData: Map<string, any> = new Map();
|
||||
private userLayouts: Map<string, DashboardLayout> = new Map();
|
||||
|
||||
/**
|
||||
* Get dashboard overview
|
||||
*/
|
||||
getDashboardOverview(userId: string, period: 'day' | 'week' | 'month' | 'quarter' | 'year'): DashboardOverview {
|
||||
// Generate realistic dashboard data
|
||||
const multipliers = { day: 1, week: 7, month: 30, quarter: 90, year: 365 };
|
||||
const m = multipliers[period];
|
||||
|
||||
return {
|
||||
period,
|
||||
totalPosts: Math.floor(3 * m + Math.random() * 2 * m),
|
||||
totalEngagements: Math.floor(1500 * m + Math.random() * 1000 * m),
|
||||
avgEngagementRate: 2.5 + Math.random() * 3,
|
||||
totalReach: Math.floor(15000 * m + Math.random() * 10000 * m),
|
||||
totalImpressions: Math.floor(25000 * m + Math.random() * 15000 * m),
|
||||
followerGrowth: Math.floor(50 * m + Math.random() * 30 * m),
|
||||
topPlatform: ['instagram', 'twitter', 'linkedin', 'tiktok'][Math.floor(Math.random() * 4)],
|
||||
goldPostsCount: Math.floor(m / 10) + 1,
|
||||
abTestsActive: Math.floor(Math.random() * 5) + 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get platform breakdown
|
||||
*/
|
||||
getPlatformBreakdown(userId: string, period: string): PlatformBreakdown[] {
|
||||
const platforms = ['twitter', 'instagram', 'linkedin', 'facebook', 'tiktok', 'youtube'];
|
||||
const contentTypes = ['video', 'carousel', 'single_image', 'text', 'story', 'reel'];
|
||||
|
||||
return platforms.map(platform => ({
|
||||
platform,
|
||||
posts: Math.floor(10 + Math.random() * 40),
|
||||
engagements: Math.floor(500 + Math.random() * 2000),
|
||||
avgEngagementRate: 1.5 + Math.random() * 4,
|
||||
reach: Math.floor(5000 + Math.random() * 20000),
|
||||
impressions: Math.floor(10000 + Math.random() * 40000),
|
||||
bestContentType: contentTypes[Math.floor(Math.random() * contentTypes.length)],
|
||||
growthTrend: ['up', 'down', 'stable'][Math.floor(Math.random() * 3)] as 'up' | 'down' | 'stable',
|
||||
comparedToLastPeriod: -15 + Math.random() * 40,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content type analysis
|
||||
*/
|
||||
getContentTypeAnalysis(userId: string): ContentTypeAnalysis[] {
|
||||
const types = [
|
||||
{ type: 'video', base: 4.5 },
|
||||
{ type: 'carousel', base: 3.8 },
|
||||
{ type: 'single_image', base: 2.5 },
|
||||
{ type: 'text', base: 1.8 },
|
||||
{ type: 'story', base: 3.2 },
|
||||
{ type: 'reel', base: 5.5 },
|
||||
{ type: 'live', base: 6.0 },
|
||||
{ type: 'poll', base: 4.0 },
|
||||
];
|
||||
|
||||
return types.map(t => ({
|
||||
type: t.type,
|
||||
count: Math.floor(5 + Math.random() * 30),
|
||||
avgEngagement: t.base + Math.random() * 2,
|
||||
avgReach: Math.floor(1000 + Math.random() * 5000),
|
||||
performanceScore: Math.floor(50 + Math.random() * 50),
|
||||
trend: ['improving', 'declining', 'stable'][Math.floor(Math.random() * 3)] as 'improving' | 'declining' | 'stable',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get time-based analysis (best times to post)
|
||||
*/
|
||||
getTimeAnalysis(userId: string, platform?: string): TimeAnalysis[] {
|
||||
const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||
const hours = ['6-9 AM', '9-12 PM', '12-3 PM', '3-6 PM', '6-9 PM', '9-12 AM'];
|
||||
|
||||
const analysis: TimeAnalysis[] = [];
|
||||
|
||||
for (const day of days) {
|
||||
for (const hour of hours) {
|
||||
const engagement = 1 + Math.random() * 6;
|
||||
const postCount = Math.floor(Math.random() * 10);
|
||||
|
||||
let recommendation = 'Good time to post';
|
||||
if (engagement > 4) recommendation = 'Excellent time - high engagement expected';
|
||||
else if (engagement < 2) recommendation = 'Avoid posting - low engagement expected';
|
||||
|
||||
analysis.push({
|
||||
dayOfWeek: day,
|
||||
hourSlot: hour,
|
||||
avgEngagement: engagement,
|
||||
postCount,
|
||||
recommendation,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return analysis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get engagement heatmap data
|
||||
*/
|
||||
getEngagementHeatmap(userId: string): number[][] {
|
||||
// 7 days x 24 hours matrix of engagement scores (0-100)
|
||||
return Array.from({ length: 7 }, () =>
|
||||
Array.from({ length: 24 }, () => Math.floor(Math.random() * 100))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get comparison data (vs previous period)
|
||||
*/
|
||||
getComparisonData(userId: string, currentPeriod: string): {
|
||||
current: DashboardOverview;
|
||||
previous: DashboardOverview;
|
||||
changes: Record<string, { value: number; percentage: number; trend: 'up' | 'down' | 'stable' }>;
|
||||
} {
|
||||
const current = this.getDashboardOverview(userId, currentPeriod as any);
|
||||
const previous = this.getDashboardOverview(userId, currentPeriod as any);
|
||||
|
||||
const calculate = (curr: number, prev: number) => {
|
||||
const change = curr - prev;
|
||||
const percentage = prev > 0 ? (change / prev) * 100 : 0;
|
||||
const trend: 'up' | 'down' | 'stable' =
|
||||
percentage > 5 ? 'up' : percentage < -5 ? 'down' : 'stable';
|
||||
return { value: change, percentage, trend };
|
||||
};
|
||||
|
||||
return {
|
||||
current,
|
||||
previous,
|
||||
changes: {
|
||||
posts: calculate(current.totalPosts, previous.totalPosts),
|
||||
engagements: calculate(current.totalEngagements, previous.totalEngagements),
|
||||
engagementRate: calculate(current.avgEngagementRate, previous.avgEngagementRate),
|
||||
reach: calculate(current.totalReach, previous.totalReach),
|
||||
followers: calculate(current.followerGrowth, previous.followerGrowth),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dashboard widgets
|
||||
*/
|
||||
getDefaultWidgets(): DashboardWidget[] {
|
||||
return [
|
||||
{
|
||||
id: 'overview',
|
||||
type: 'metric',
|
||||
title: 'Performance Overview',
|
||||
data: null,
|
||||
config: { size: 'large', position: { row: 0, col: 0 } },
|
||||
},
|
||||
{
|
||||
id: 'engagement-trend',
|
||||
type: 'chart',
|
||||
title: 'Engagement Trend',
|
||||
data: null,
|
||||
config: { chartType: 'line', size: 'medium', position: { row: 0, col: 1 } },
|
||||
},
|
||||
{
|
||||
id: 'platform-breakdown',
|
||||
type: 'chart',
|
||||
title: 'Platform Performance',
|
||||
data: null,
|
||||
config: { chartType: 'bar', size: 'medium', position: { row: 1, col: 0 } },
|
||||
},
|
||||
{
|
||||
id: 'content-types',
|
||||
type: 'chart',
|
||||
title: 'Content Type Analysis',
|
||||
data: null,
|
||||
config: { chartType: 'pie', size: 'small', position: { row: 1, col: 1 } },
|
||||
},
|
||||
{
|
||||
id: 'best-times',
|
||||
type: 'heatmap',
|
||||
title: 'Best Times to Post',
|
||||
data: null,
|
||||
config: { size: 'large', position: { row: 2, col: 0 } },
|
||||
},
|
||||
{
|
||||
id: 'top-posts',
|
||||
type: 'table',
|
||||
title: 'Top Performing Posts',
|
||||
data: null,
|
||||
config: { columns: ['title', 'platform', 'engagement', 'reach'], position: { row: 2, col: 1 } },
|
||||
},
|
||||
{
|
||||
id: 'growth-metrics',
|
||||
type: 'comparison',
|
||||
title: 'Growth vs Last Period',
|
||||
data: null,
|
||||
config: { size: 'medium', position: { row: 3, col: 0 } },
|
||||
},
|
||||
{
|
||||
id: 'gold-posts',
|
||||
type: 'table',
|
||||
title: 'Gold Posts',
|
||||
data: null,
|
||||
config: { highlight: true, position: { row: 3, col: 1 } },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get/create user dashboard layout
|
||||
*/
|
||||
getDashboardLayout(userId: string): DashboardLayout {
|
||||
let layout = this.userLayouts.get(userId);
|
||||
|
||||
if (!layout) {
|
||||
layout = {
|
||||
userId,
|
||||
widgets: this.getDefaultWidgets(),
|
||||
customization: {
|
||||
theme: 'dark',
|
||||
refreshInterval: 60000,
|
||||
dateRange: 'week',
|
||||
},
|
||||
};
|
||||
this.userLayouts.set(userId, layout);
|
||||
}
|
||||
|
||||
return layout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update dashboard layout
|
||||
*/
|
||||
updateDashboardLayout(userId: string, updates: Partial<DashboardLayout>): DashboardLayout {
|
||||
const current = this.getDashboardLayout(userId);
|
||||
const updated = { ...current, ...updates };
|
||||
this.userLayouts.set(userId, updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add custom widget
|
||||
*/
|
||||
addWidget(userId: string, widget: Omit<DashboardWidget, 'id'>): DashboardWidget {
|
||||
const layout = this.getDashboardLayout(userId);
|
||||
const newWidget: DashboardWidget = {
|
||||
...widget,
|
||||
id: `widget-${Date.now()}`,
|
||||
};
|
||||
layout.widgets.push(newWidget);
|
||||
this.userLayouts.set(userId, layout);
|
||||
return newWidget;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove widget
|
||||
*/
|
||||
removeWidget(userId: string, widgetId: string): boolean {
|
||||
const layout = this.getDashboardLayout(userId);
|
||||
const index = layout.widgets.findIndex(w => w.id === widgetId);
|
||||
if (index === -1) return false;
|
||||
|
||||
layout.widgets.splice(index, 1);
|
||||
this.userLayouts.set(userId, layout);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export dashboard data
|
||||
*/
|
||||
exportDashboardData(userId: string, format: 'json' | 'csv'): string {
|
||||
const overview = this.getDashboardOverview(userId, 'month');
|
||||
const platforms = this.getPlatformBreakdown(userId, 'month');
|
||||
const contentTypes = this.getContentTypeAnalysis(userId);
|
||||
|
||||
if (format === 'json') {
|
||||
return JSON.stringify({ overview, platforms, contentTypes }, null, 2);
|
||||
}
|
||||
|
||||
// CSV format
|
||||
let csv = 'Metric,Value\n';
|
||||
csv += `Total Posts,${overview.totalPosts}\n`;
|
||||
csv += `Total Engagements,${overview.totalEngagements}\n`;
|
||||
csv += `Avg Engagement Rate,${overview.avgEngagementRate.toFixed(2)}%\n`;
|
||||
csv += `Total Reach,${overview.totalReach}\n`;
|
||||
csv += `Follower Growth,${overview.followerGrowth}\n`;
|
||||
csv += '\nPlatform,Posts,Engagements,Avg Rate\n';
|
||||
for (const p of platforms) {
|
||||
csv += `${p.platform},${p.posts},${p.engagements},${p.avgEngagementRate.toFixed(2)}%\n`;
|
||||
}
|
||||
return csv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get insights and recommendations
|
||||
*/
|
||||
getInsights(userId: string): Array<{
|
||||
type: 'success' | 'warning' | 'info' | 'action';
|
||||
title: string;
|
||||
description: string;
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
}> {
|
||||
return [
|
||||
{
|
||||
type: 'success',
|
||||
title: 'Strong Video Performance',
|
||||
description: 'Your video content is outperforming other formats by 45%. Consider increasing video production.',
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
type: 'warning',
|
||||
title: 'Engagement Drop on Weekends',
|
||||
description: 'Weekend posts show 30% lower engagement. Consider rescheduling to weekday evenings.',
|
||||
priority: 'medium',
|
||||
},
|
||||
{
|
||||
type: 'action',
|
||||
title: 'Untapped LinkedIn Potential',
|
||||
description: 'LinkedIn shows high engagement but low posting frequency. Increase LinkedIn content.',
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
type: 'info',
|
||||
title: 'Optimal Posting Time Detected',
|
||||
description: 'Best engagement window: Tuesday-Thursday, 6-9 PM in your timezone.',
|
||||
priority: 'medium',
|
||||
},
|
||||
{
|
||||
type: 'success',
|
||||
title: 'Gold Post Streak',
|
||||
description: 'You\'ve had 3 Gold Posts this week! Your viral content formula is working.',
|
||||
priority: 'low',
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
482
src/modules/analytics/services/webhook.service.ts
Normal file
482
src/modules/analytics/services/webhook.service.ts
Normal file
@@ -0,0 +1,482 @@
|
||||
// Webhook Service - External API integrations
|
||||
// Path: src/modules/analytics/services/webhook.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
export interface Webhook {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
url: string;
|
||||
secret?: string;
|
||||
events: WebhookEvent[];
|
||||
headers?: Record<string, string>;
|
||||
isActive: boolean;
|
||||
retryPolicy: RetryPolicy;
|
||||
lastTriggered?: Date;
|
||||
stats: WebhookStats;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export type WebhookEvent =
|
||||
| 'post.published'
|
||||
| 'post.scheduled'
|
||||
| 'engagement.threshold'
|
||||
| 'gold_post.detected'
|
||||
| 'ab_test.completed'
|
||||
| 'automation.triggered'
|
||||
| 'queue.empty'
|
||||
| 'report.generated'
|
||||
| 'error.occurred';
|
||||
|
||||
export interface RetryPolicy {
|
||||
maxRetries: number;
|
||||
backoffMs: number;
|
||||
backoffMultiplier: number;
|
||||
}
|
||||
|
||||
export interface WebhookStats {
|
||||
totalCalls: number;
|
||||
successfulCalls: number;
|
||||
failedCalls: number;
|
||||
lastSuccess?: Date;
|
||||
lastFailure?: Date;
|
||||
avgResponseTime: number;
|
||||
}
|
||||
|
||||
export interface WebhookDelivery {
|
||||
id: string;
|
||||
webhookId: string;
|
||||
event: WebhookEvent;
|
||||
payload: Record<string, any>;
|
||||
status: 'pending' | 'success' | 'failed' | 'retrying';
|
||||
attempts: number;
|
||||
response?: {
|
||||
statusCode: number;
|
||||
body?: string;
|
||||
time: number;
|
||||
};
|
||||
error?: string;
|
||||
createdAt: Date;
|
||||
completedAt?: Date;
|
||||
}
|
||||
|
||||
export interface ExternalApiConfig {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
type: 'zapier' | 'make' | 'n8n' | 'custom' | 'slack' | 'discord' | 'notion';
|
||||
config: Record<string, any>;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WebhookService {
|
||||
private readonly logger = new Logger(WebhookService.name);
|
||||
private webhooks: Map<string, Webhook[]> = new Map();
|
||||
private deliveries: Map<string, WebhookDelivery[]> = new Map();
|
||||
private externalApis: Map<string, ExternalApiConfig[]> = new Map();
|
||||
|
||||
// Event descriptions
|
||||
private readonly eventDescriptions: Record<WebhookEvent, string> = {
|
||||
'post.published': 'Triggered when a post is successfully published',
|
||||
'post.scheduled': 'Triggered when a post is added to the schedule',
|
||||
'engagement.threshold': 'Triggered when engagement exceeds a threshold',
|
||||
'gold_post.detected': 'Triggered when a Gold Post is detected',
|
||||
'ab_test.completed': 'Triggered when an A/B test completes',
|
||||
'automation.triggered': 'Triggered when an automation rule executes',
|
||||
'queue.empty': 'Triggered when the content queue is empty',
|
||||
'report.generated': 'Triggered when an analytics report is ready',
|
||||
'error.occurred': 'Triggered when an error occurs',
|
||||
};
|
||||
|
||||
/**
|
||||
* Create webhook
|
||||
*/
|
||||
createWebhook(
|
||||
userId: string,
|
||||
input: {
|
||||
name: string;
|
||||
url: string;
|
||||
secret?: string;
|
||||
events: WebhookEvent[];
|
||||
headers?: Record<string, string>;
|
||||
retryPolicy?: Partial<RetryPolicy>;
|
||||
},
|
||||
): Webhook {
|
||||
const webhook: Webhook = {
|
||||
id: `webhook-${Date.now()}`,
|
||||
userId,
|
||||
name: input.name,
|
||||
url: input.url,
|
||||
secret: input.secret,
|
||||
events: input.events,
|
||||
headers: input.headers,
|
||||
isActive: true,
|
||||
retryPolicy: {
|
||||
maxRetries: input.retryPolicy?.maxRetries ?? 3,
|
||||
backoffMs: input.retryPolicy?.backoffMs ?? 1000,
|
||||
backoffMultiplier: input.retryPolicy?.backoffMultiplier ?? 2,
|
||||
},
|
||||
stats: {
|
||||
totalCalls: 0,
|
||||
successfulCalls: 0,
|
||||
failedCalls: 0,
|
||||
avgResponseTime: 0,
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const userWebhooks = this.webhooks.get(userId) || [];
|
||||
userWebhooks.push(webhook);
|
||||
this.webhooks.set(userId, userWebhooks);
|
||||
|
||||
this.logger.log(`Created webhook: ${webhook.id} for user ${userId}`);
|
||||
return webhook;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get webhooks
|
||||
*/
|
||||
getWebhooks(userId: string): Webhook[] {
|
||||
return this.webhooks.get(userId) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get webhook by ID
|
||||
*/
|
||||
getWebhook(userId: string, webhookId: string): Webhook | null {
|
||||
const webhooks = this.webhooks.get(userId) || [];
|
||||
return webhooks.find(w => w.id === webhookId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update webhook
|
||||
*/
|
||||
updateWebhook(
|
||||
userId: string,
|
||||
webhookId: string,
|
||||
updates: Partial<Pick<Webhook, 'name' | 'url' | 'secret' | 'events' | 'headers' | 'isActive' | 'retryPolicy'>>,
|
||||
): Webhook | null {
|
||||
const webhook = this.getWebhook(userId, webhookId);
|
||||
if (!webhook) return null;
|
||||
|
||||
Object.assign(webhook, updates, { updatedAt: new Date() });
|
||||
return webhook;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle webhook
|
||||
*/
|
||||
toggleWebhook(userId: string, webhookId: string): Webhook | null {
|
||||
const webhook = this.getWebhook(userId, webhookId);
|
||||
if (!webhook) return null;
|
||||
|
||||
webhook.isActive = !webhook.isActive;
|
||||
webhook.updatedAt = new Date();
|
||||
return webhook;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete webhook
|
||||
*/
|
||||
deleteWebhook(userId: string, webhookId: string): boolean {
|
||||
const webhooks = this.webhooks.get(userId) || [];
|
||||
const filtered = webhooks.filter(w => w.id !== webhookId);
|
||||
|
||||
if (filtered.length === webhooks.length) return false;
|
||||
|
||||
this.webhooks.set(userId, filtered);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger webhook event
|
||||
*/
|
||||
async triggerEvent(
|
||||
userId: string,
|
||||
event: WebhookEvent,
|
||||
payload: Record<string, any>,
|
||||
): Promise<WebhookDelivery[]> {
|
||||
const webhooks = this.getWebhooks(userId).filter(
|
||||
w => w.isActive && w.events.includes(event)
|
||||
);
|
||||
|
||||
const deliveries: WebhookDelivery[] = [];
|
||||
|
||||
for (const webhook of webhooks) {
|
||||
const delivery = await this.deliverWebhook(webhook, event, payload);
|
||||
deliveries.push(delivery);
|
||||
}
|
||||
|
||||
return deliveries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get webhook deliveries
|
||||
*/
|
||||
getDeliveries(userId: string, webhookId?: string, limit?: number): WebhookDelivery[] {
|
||||
let deliveries = this.deliveries.get(userId) || [];
|
||||
|
||||
if (webhookId) {
|
||||
deliveries = deliveries.filter(d => d.webhookId === webhookId);
|
||||
}
|
||||
|
||||
deliveries = deliveries.sort((a, b) =>
|
||||
b.createdAt.getTime() - a.createdAt.getTime()
|
||||
);
|
||||
|
||||
if (limit) {
|
||||
deliveries = deliveries.slice(0, limit);
|
||||
}
|
||||
|
||||
return deliveries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry failed delivery
|
||||
*/
|
||||
async retryDelivery(userId: string, deliveryId: string): Promise<WebhookDelivery | null> {
|
||||
const deliveries = this.deliveries.get(userId) || [];
|
||||
const delivery = deliveries.find(d => d.id === deliveryId);
|
||||
|
||||
if (!delivery || delivery.status !== 'failed') return null;
|
||||
|
||||
const webhook = this.getWebhook(userId, delivery.webhookId);
|
||||
if (!webhook) return null;
|
||||
|
||||
delivery.status = 'retrying';
|
||||
delivery.attempts++;
|
||||
|
||||
// Simulate retry
|
||||
return this.executeDelivery(webhook, delivery);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available events
|
||||
*/
|
||||
getAvailableEvents(): Array<{ event: WebhookEvent; description: string }> {
|
||||
return Object.entries(this.eventDescriptions).map(([event, description]) => ({
|
||||
event: event as WebhookEvent,
|
||||
description,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test webhook
|
||||
*/
|
||||
async testWebhook(userId: string, webhookId: string): Promise<WebhookDelivery> {
|
||||
const webhook = this.getWebhook(userId, webhookId);
|
||||
if (!webhook) {
|
||||
throw new Error('Webhook not found');
|
||||
}
|
||||
|
||||
const testPayload = {
|
||||
test: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
message: 'This is a test webhook delivery',
|
||||
};
|
||||
|
||||
return this.deliverWebhook(webhook, 'post.published', testPayload);
|
||||
}
|
||||
|
||||
// ========== External API Integrations ==========
|
||||
|
||||
/**
|
||||
* Configure external API
|
||||
*/
|
||||
configureExternalApi(
|
||||
userId: string,
|
||||
input: {
|
||||
name: string;
|
||||
type: ExternalApiConfig['type'];
|
||||
config: Record<string, any>;
|
||||
},
|
||||
): ExternalApiConfig {
|
||||
const apiConfig: ExternalApiConfig = {
|
||||
id: `api-${Date.now()}`,
|
||||
userId,
|
||||
name: input.name,
|
||||
type: input.type,
|
||||
config: input.config,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
const userApis = this.externalApis.get(userId) || [];
|
||||
userApis.push(apiConfig);
|
||||
this.externalApis.set(userId, userApis);
|
||||
|
||||
return apiConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get external APIs
|
||||
*/
|
||||
getExternalApis(userId: string): ExternalApiConfig[] {
|
||||
return this.externalApis.get(userId) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get integrations templates
|
||||
*/
|
||||
getIntegrationTemplates(): Array<{
|
||||
type: ExternalApiConfig['type'];
|
||||
name: string;
|
||||
description: string;
|
||||
requiredFields: string[];
|
||||
}> {
|
||||
return [
|
||||
{
|
||||
type: 'zapier',
|
||||
name: 'Zapier',
|
||||
description: 'Connect to 5000+ apps via Zapier',
|
||||
requiredFields: ['webhookUrl'],
|
||||
},
|
||||
{
|
||||
type: 'make',
|
||||
name: 'Make (Integromat)',
|
||||
description: 'Advanced workflow automation',
|
||||
requiredFields: ['webhookUrl', 'scenarioId'],
|
||||
},
|
||||
{
|
||||
type: 'n8n',
|
||||
name: 'n8n',
|
||||
description: 'Self-hosted workflow automation',
|
||||
requiredFields: ['webhookUrl', 'instanceUrl'],
|
||||
},
|
||||
{
|
||||
type: 'slack',
|
||||
name: 'Slack',
|
||||
description: 'Send notifications to Slack',
|
||||
requiredFields: ['webhookUrl', 'channel'],
|
||||
},
|
||||
{
|
||||
type: 'discord',
|
||||
name: 'Discord',
|
||||
description: 'Send notifications to Discord',
|
||||
requiredFields: ['webhookUrl'],
|
||||
},
|
||||
{
|
||||
type: 'notion',
|
||||
name: 'Notion',
|
||||
description: 'Sync data with Notion databases',
|
||||
requiredFields: ['apiKey', 'databaseId'],
|
||||
},
|
||||
{
|
||||
type: 'custom',
|
||||
name: 'Custom API',
|
||||
description: 'Connect to any REST API',
|
||||
requiredFields: ['baseUrl', 'authType'],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update external API config
|
||||
*/
|
||||
updateExternalApi(
|
||||
userId: string,
|
||||
apiId: string,
|
||||
updates: Partial<Pick<ExternalApiConfig, 'name' | 'config' | 'isActive'>>,
|
||||
): ExternalApiConfig | null {
|
||||
const apis = this.externalApis.get(userId) || [];
|
||||
const api = apis.find(a => a.id === apiId);
|
||||
|
||||
if (!api) return null;
|
||||
|
||||
Object.assign(api, updates);
|
||||
return api;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete external API
|
||||
*/
|
||||
deleteExternalApi(userId: string, apiId: string): boolean {
|
||||
const apis = this.externalApis.get(userId) || [];
|
||||
const filtered = apis.filter(a => a.id !== apiId);
|
||||
|
||||
if (filtered.length === apis.length) return false;
|
||||
|
||||
this.externalApis.set(userId, filtered);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Private methods
|
||||
|
||||
private async deliverWebhook(
|
||||
webhook: Webhook,
|
||||
event: WebhookEvent,
|
||||
payload: Record<string, any>,
|
||||
): Promise<WebhookDelivery> {
|
||||
const delivery: WebhookDelivery = {
|
||||
id: `delivery-${Date.now()}`,
|
||||
webhookId: webhook.id,
|
||||
event,
|
||||
payload,
|
||||
status: 'pending',
|
||||
attempts: 1,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
// Store delivery
|
||||
const userId = webhook.userId;
|
||||
const deliveries = this.deliveries.get(userId) || [];
|
||||
deliveries.push(delivery);
|
||||
this.deliveries.set(userId, deliveries);
|
||||
|
||||
return this.executeDelivery(webhook, delivery);
|
||||
}
|
||||
|
||||
private async executeDelivery(webhook: Webhook, delivery: WebhookDelivery): Promise<WebhookDelivery> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Simulate HTTP call (in production, use actual HTTP client)
|
||||
await this.simulateHttpCall(webhook.url);
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
delivery.status = 'success';
|
||||
delivery.response = {
|
||||
statusCode: 200,
|
||||
body: '{"success": true}',
|
||||
time: responseTime,
|
||||
};
|
||||
delivery.completedAt = new Date();
|
||||
|
||||
// Update webhook stats
|
||||
webhook.stats.totalCalls++;
|
||||
webhook.stats.successfulCalls++;
|
||||
webhook.stats.lastSuccess = new Date();
|
||||
webhook.stats.avgResponseTime =
|
||||
((webhook.stats.avgResponseTime * (webhook.stats.totalCalls - 1)) + responseTime) / webhook.stats.totalCalls;
|
||||
webhook.lastTriggered = new Date();
|
||||
|
||||
this.logger.log(`Webhook delivered: ${webhook.id} - ${delivery.event}`);
|
||||
} catch (error) {
|
||||
delivery.status = 'failed';
|
||||
delivery.error = error.message;
|
||||
delivery.completedAt = new Date();
|
||||
|
||||
webhook.stats.totalCalls++;
|
||||
webhook.stats.failedCalls++;
|
||||
webhook.stats.lastFailure = new Date();
|
||||
|
||||
this.logger.error(`Webhook failed: ${webhook.id} - ${error.message}`);
|
||||
}
|
||||
|
||||
return delivery;
|
||||
}
|
||||
|
||||
private async simulateHttpCall(url: string): Promise<void> {
|
||||
// Simulate network latency
|
||||
await new Promise(resolve => setTimeout(resolve, 50 + Math.random() * 100));
|
||||
|
||||
// Simulate occasional failures (5% chance)
|
||||
if (Math.random() < 0.05) {
|
||||
throw new Error('Connection timeout');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user