427 lines
12 KiB
TypeScript
427 lines
12 KiB
TypeScript
import {prisma} from '../database/prisma.js';
|
|
import {redis} from '../database/redis.js';
|
|
import {Keys} from './keys.js';
|
|
|
|
/**
|
|
* Time series data point for analytics
|
|
*/
|
|
export interface TimeSeriesDataPoint {
|
|
date: string;
|
|
emails: number;
|
|
opens: number;
|
|
clicks: number;
|
|
bounces: number;
|
|
delivered: number;
|
|
}
|
|
|
|
/**
|
|
* Analytics Service
|
|
*
|
|
* PERFORMANCE CONSIDERATIONS:
|
|
* - Aggregates data by day to reduce row count
|
|
* - Uses Redis caching with 15-minute TTL
|
|
* - Limits date ranges to prevent expensive queries
|
|
* - Uses indexed fields (createdAt, projectId) for efficient filtering
|
|
* - For 1M+ emails, consider background jobs for pre-aggregation
|
|
*/
|
|
export class AnalyticsService {
|
|
private static readonly DEFAULT_DAYS_BACK = 30;
|
|
private static readonly MAX_DAYS_BACK = 90;
|
|
private static readonly TIMESERIES_CACHE_TTL = 900; // 15 minutes
|
|
|
|
/**
|
|
* Get time series data for email analytics
|
|
*
|
|
* Performance: O(n) where n = number of emails in date range
|
|
* - Groups emails by day using SQL aggregation
|
|
* - Cached in Redis for 15 minutes
|
|
* - Limited to 90 days max to prevent performance issues
|
|
*
|
|
* For higher scale (1M+ emails/day), consider:
|
|
* - Pre-aggregated daily stats table updated by background job
|
|
* - Materialized view with daily refresh
|
|
* - Time-series database (TimescaleDB, InfluxDB)
|
|
*/
|
|
public static async getTimeSeriesData(
|
|
projectId: string,
|
|
startDate?: Date,
|
|
endDate?: Date,
|
|
): Promise<TimeSeriesDataPoint[]> {
|
|
// Calculate date range with limits
|
|
const now = new Date();
|
|
const effectiveEndDate = endDate || now;
|
|
const defaultStartDate = new Date(now.getTime() - this.DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000);
|
|
const effectiveStartDate = startDate || defaultStartDate;
|
|
|
|
// Enforce max date range
|
|
const maxStartDate = new Date(now.getTime() - this.MAX_DAYS_BACK * 24 * 60 * 60 * 1000);
|
|
const limitedStartDate = effectiveStartDate < maxStartDate ? maxStartDate : effectiveStartDate;
|
|
|
|
// Check cache first
|
|
const cacheKey = Keys.Analytics.timeseries(
|
|
projectId,
|
|
limitedStartDate.toISOString(),
|
|
effectiveEndDate.toISOString(),
|
|
);
|
|
const cached = await redis.get(cacheKey);
|
|
if (cached) {
|
|
return JSON.parse(cached);
|
|
}
|
|
|
|
const result = await prisma.$queryRaw<
|
|
{
|
|
date: Date;
|
|
total_emails: bigint;
|
|
total_opens: bigint;
|
|
total_clicks: bigint;
|
|
total_bounces: bigint;
|
|
total_delivered: bigint;
|
|
}[]
|
|
>`
|
|
SELECT
|
|
DATE_TRUNC('day', "createdAt") as date,
|
|
COUNT(*) as total_emails,
|
|
COUNT(CASE WHEN "openedAt" IS NOT NULL THEN 1 END) as total_opens,
|
|
COUNT(CASE WHEN "clickedAt" IS NOT NULL THEN 1 END) as total_clicks,
|
|
COUNT(CASE WHEN "bouncedAt" IS NOT NULL THEN 1 END) as total_bounces,
|
|
COUNT(CASE WHEN "deliveredAt" IS NOT NULL THEN 1 END) as total_delivered
|
|
FROM "emails"
|
|
WHERE "projectId" = ${projectId}
|
|
AND "createdAt" >= ${limitedStartDate}
|
|
AND "createdAt" <= ${effectiveEndDate}
|
|
GROUP BY DATE_TRUNC('day', "createdAt")
|
|
ORDER BY date ASC
|
|
`;
|
|
|
|
// Convert to TimeSeriesDataPoint format
|
|
const timeSeries: TimeSeriesDataPoint[] = result.map(row => ({
|
|
date: row.date.toISOString(),
|
|
emails: Number(row.total_emails),
|
|
opens: Number(row.total_opens),
|
|
clicks: Number(row.total_clicks),
|
|
bounces: Number(row.total_bounces),
|
|
delivered: Number(row.total_delivered),
|
|
}));
|
|
|
|
// Fill in missing dates with zero values
|
|
const filledTimeSeries = this.fillMissingDates(timeSeries, limitedStartDate, effectiveEndDate);
|
|
|
|
// Cache for 15 minutes
|
|
await redis.setex(cacheKey, this.TIMESERIES_CACHE_TTL, JSON.stringify(filledTimeSeries));
|
|
|
|
return filledTimeSeries;
|
|
}
|
|
|
|
/**
|
|
* Get campaign performance metrics
|
|
* Returns top performing campaigns by open rate
|
|
*/
|
|
public static async getTopCampaigns(
|
|
projectId: string,
|
|
limit = 10,
|
|
startDate?: Date,
|
|
endDate?: Date,
|
|
): Promise<
|
|
{
|
|
id: string;
|
|
subject: string;
|
|
sentCount: number;
|
|
openedCount: number;
|
|
clickedCount: number;
|
|
openRate: number;
|
|
clickRate: number;
|
|
}[]
|
|
> {
|
|
const now = new Date();
|
|
const defaultStartDate = new Date(now.getTime() - this.DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000);
|
|
|
|
const campaigns = await prisma.campaign.findMany({
|
|
where: {
|
|
projectId,
|
|
sentAt: {
|
|
gte: startDate || defaultStartDate,
|
|
...(endDate ? {lte: endDate} : {}),
|
|
},
|
|
status: 'SENT',
|
|
},
|
|
select: {
|
|
id: true,
|
|
subject: true,
|
|
sentCount: true,
|
|
openedCount: true,
|
|
clickedCount: true,
|
|
},
|
|
orderBy: {
|
|
openedCount: 'desc',
|
|
},
|
|
take: limit,
|
|
});
|
|
|
|
return campaigns.map(campaign => ({
|
|
id: campaign.id,
|
|
subject: campaign.subject || 'No subject',
|
|
sentCount: campaign.sentCount || 0,
|
|
openedCount: campaign.openedCount || 0,
|
|
clickedCount: campaign.clickedCount || 0,
|
|
openRate: campaign.sentCount ? ((campaign.openedCount || 0) / campaign.sentCount) * 100 : 0,
|
|
clickRate: campaign.sentCount ? ((campaign.clickedCount || 0) / campaign.sentCount) * 100 : 0,
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Get campaign statistics overview
|
|
* Returns aggregate stats for all campaigns in date range
|
|
*
|
|
* Performance: O(1) with indexed queries
|
|
* - Uses aggregation on indexed fields
|
|
* - Cached in Redis for 15 minutes
|
|
*/
|
|
public static async getCampaignStats(
|
|
projectId: string,
|
|
startDate?: Date,
|
|
endDate?: Date,
|
|
): Promise<{
|
|
total: number;
|
|
active: number;
|
|
completed: number;
|
|
averageOpenRate: number;
|
|
averageClickRate: number;
|
|
}> {
|
|
const now = new Date();
|
|
const defaultStartDate = new Date(now.getTime() - this.DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000);
|
|
const effectiveStartDate = startDate || defaultStartDate;
|
|
const effectiveEndDate = endDate || now;
|
|
|
|
// Check cache
|
|
const cacheKey = Keys.Analytics.campaignStats(
|
|
projectId,
|
|
effectiveStartDate.toISOString(),
|
|
effectiveEndDate.toISOString(),
|
|
);
|
|
const cached = await redis.get(cacheKey);
|
|
if (cached) {
|
|
return JSON.parse(cached);
|
|
}
|
|
|
|
// Get all campaigns in date range
|
|
const [totalCampaigns, activeCampaigns, sentCampaigns] = await Promise.all([
|
|
// Total campaigns created in date range
|
|
prisma.campaign.count({
|
|
where: {
|
|
projectId,
|
|
createdAt: {
|
|
gte: effectiveStartDate,
|
|
lte: effectiveEndDate,
|
|
},
|
|
},
|
|
}),
|
|
// Active campaigns (not sent yet)
|
|
prisma.campaign.count({
|
|
where: {
|
|
projectId,
|
|
createdAt: {
|
|
gte: effectiveStartDate,
|
|
lte: effectiveEndDate,
|
|
},
|
|
status: {
|
|
in: ['DRAFT', 'SCHEDULED'],
|
|
},
|
|
},
|
|
}),
|
|
// Sent campaigns with stats
|
|
prisma.campaign.findMany({
|
|
where: {
|
|
projectId,
|
|
sentAt: {
|
|
gte: effectiveStartDate,
|
|
lte: effectiveEndDate,
|
|
},
|
|
status: 'SENT',
|
|
},
|
|
select: {
|
|
sentCount: true,
|
|
openedCount: true,
|
|
clickedCount: true,
|
|
},
|
|
}),
|
|
]);
|
|
|
|
// Calculate averages
|
|
let totalSent = 0;
|
|
let totalOpened = 0;
|
|
let totalClicked = 0;
|
|
|
|
sentCampaigns.forEach(campaign => {
|
|
totalSent += campaign.sentCount || 0;
|
|
totalOpened += campaign.openedCount || 0;
|
|
totalClicked += campaign.clickedCount || 0;
|
|
});
|
|
|
|
const completed = sentCampaigns.length;
|
|
const averageOpenRate = totalSent > 0 ? (totalOpened / totalSent) * 100 : 0;
|
|
const averageClickRate = totalSent > 0 ? (totalClicked / totalSent) * 100 : 0;
|
|
|
|
const stats = {
|
|
total: totalCampaigns,
|
|
active: activeCampaigns,
|
|
completed,
|
|
averageOpenRate: Math.round(averageOpenRate * 10) / 10, // Round to 1 decimal
|
|
averageClickRate: Math.round(averageClickRate * 10) / 10,
|
|
};
|
|
|
|
// Cache for 15 minutes
|
|
await redis.setex(cacheKey, this.TIMESERIES_CACHE_TTL, JSON.stringify(stats));
|
|
|
|
return stats;
|
|
}
|
|
|
|
/**
|
|
* Get top events by frequency with trend data
|
|
*
|
|
* Performance: O(n log n) where n = number of unique events
|
|
* - Groups events and counts occurrences
|
|
* - Compares with previous period for trend calculation
|
|
* - Cached in Redis for 15 minutes
|
|
*/
|
|
public static async getTopEvents(
|
|
projectId: string,
|
|
limit = 5,
|
|
startDate?: Date,
|
|
endDate?: Date,
|
|
): Promise<
|
|
{
|
|
name: string;
|
|
count: number;
|
|
trend: number;
|
|
}[]
|
|
> {
|
|
const now = new Date();
|
|
const defaultStartDate = new Date(now.getTime() - this.DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000);
|
|
const effectiveStartDate = startDate || defaultStartDate;
|
|
const effectiveEndDate = endDate || now;
|
|
|
|
// Check cache
|
|
const cacheKey = Keys.Analytics.topEvents(
|
|
projectId,
|
|
limit,
|
|
effectiveStartDate.toISOString(),
|
|
effectiveEndDate.toISOString(),
|
|
);
|
|
const cached = await redis.get(cacheKey);
|
|
if (cached) {
|
|
return JSON.parse(cached);
|
|
}
|
|
|
|
// Calculate previous period for trend comparison
|
|
const periodLength = effectiveEndDate.getTime() - effectiveStartDate.getTime();
|
|
const previousStartDate = new Date(effectiveStartDate.getTime() - periodLength);
|
|
const previousEndDate = new Date(effectiveStartDate.getTime());
|
|
|
|
// Get current period events
|
|
const currentEvents = await prisma.event.groupBy({
|
|
by: ['name'],
|
|
where: {
|
|
projectId,
|
|
createdAt: {
|
|
gte: effectiveStartDate,
|
|
lte: effectiveEndDate,
|
|
},
|
|
},
|
|
_count: {
|
|
id: true,
|
|
},
|
|
orderBy: {
|
|
_count: {
|
|
id: 'desc',
|
|
},
|
|
},
|
|
take: limit,
|
|
});
|
|
|
|
// Get previous period events for trend calculation
|
|
const previousEvents = await prisma.event.groupBy({
|
|
by: ['name'],
|
|
where: {
|
|
projectId,
|
|
name: {
|
|
in: currentEvents.map(e => e.name),
|
|
},
|
|
createdAt: {
|
|
gte: previousStartDate,
|
|
lte: previousEndDate,
|
|
},
|
|
},
|
|
_count: {
|
|
id: true,
|
|
},
|
|
});
|
|
|
|
// Create map of previous counts
|
|
const previousCountMap = new Map(previousEvents.map(e => [e.name, e._count.id]));
|
|
|
|
// Calculate trends
|
|
const topEvents = currentEvents.map(event => {
|
|
const currentCount = event._count.id;
|
|
const previousCount = previousCountMap.get(event.name) || 0;
|
|
|
|
// Calculate percentage change
|
|
let trend = 0;
|
|
if (previousCount > 0) {
|
|
trend = Math.round(((currentCount - previousCount) / previousCount) * 100);
|
|
} else if (currentCount > 0) {
|
|
trend = 100; // New event, 100% increase
|
|
}
|
|
|
|
return {
|
|
name: event.name,
|
|
count: currentCount,
|
|
trend,
|
|
};
|
|
});
|
|
|
|
// Cache for 15 minutes
|
|
await redis.setex(cacheKey, this.TIMESERIES_CACHE_TTL, JSON.stringify(topEvents));
|
|
|
|
return topEvents;
|
|
}
|
|
|
|
/**
|
|
* Fill in missing dates in time series with zero values
|
|
* Ensures consistent daily data points even when no emails were sent
|
|
*/
|
|
private static fillMissingDates(data: TimeSeriesDataPoint[], startDate: Date, endDate: Date): TimeSeriesDataPoint[] {
|
|
const result: TimeSeriesDataPoint[] = [];
|
|
const dataMap = new Map(data.map(point => [new Date(point.date).toDateString(), point]));
|
|
|
|
// Iterate through each day in range
|
|
const currentDate = new Date(startDate);
|
|
currentDate.setHours(0, 0, 0, 0);
|
|
const end = new Date(endDate);
|
|
end.setHours(23, 59, 59, 999);
|
|
|
|
while (currentDate <= end) {
|
|
const dateKey = currentDate.toDateString();
|
|
const existingData = dataMap.get(dateKey);
|
|
|
|
if (existingData) {
|
|
result.push(existingData);
|
|
} else {
|
|
// Fill with zeros for days with no data
|
|
result.push({
|
|
date: new Date(currentDate).toISOString(),
|
|
emails: 0,
|
|
opens: 0,
|
|
clicks: 0,
|
|
bounces: 0,
|
|
delivered: 0,
|
|
});
|
|
}
|
|
|
|
// Move to next day
|
|
currentDate.setDate(currentDate.getDate() + 1);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
}
|