Files
plunk/apps/api/src/services/ActivityService.ts
T

725 lines
21 KiB
TypeScript

import type {Prisma} from '@plunk/db';
import {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js';
/**
* Activity types that can be tracked
*/
export enum ActivityType {
EVENT_TRIGGERED = 'event.triggered',
EMAIL_SENT = 'email.sent',
EMAIL_DELIVERED = 'email.delivered',
EMAIL_OPENED = 'email.opened',
EMAIL_CLICKED = 'email.clicked',
EMAIL_BOUNCED = 'email.bounced',
CAMPAIGN_SENT = 'campaign.sent',
CAMPAIGN_SCHEDULED = 'campaign.scheduled',
WORKFLOW_STARTED = 'workflow.started',
WORKFLOW_COMPLETED = 'workflow.completed',
WORKFLOW_EMAIL_SCHEDULED = 'workflow.email.scheduled',
}
/**
* Unified activity item
*/
export interface Activity {
id: string;
type: ActivityType;
timestamp: Date;
contactEmail?: string;
contactId?: string;
metadata: Record<string, unknown>;
}
/**
* Paginated activity response
*/
export interface PaginatedActivities {
activities: Activity[];
nextCursor?: string;
hasMore: boolean;
}
/**
* Activity stats for dashboard
*/
export interface ActivityStats {
totalEvents: number;
totalEmailsSent: number;
totalEmailsOpened: number;
totalEmailsClicked: number;
totalWorkflowsStarted: number;
openRate: number;
clickRate: number;
}
/**
* Activity Service
*
* PERFORMANCE CONSIDERATIONS:
* - Uses cursor-based pagination for efficient large dataset handling
* - Limits date range to prevent expensive queries (default 30 days)
* - Caches statistics in Redis with 5-minute TTL
* - Uses indexed fields (createdAt) for sorting
* - Batch processes and merges results from multiple tables
*/
export class ActivityService {
private static readonly DEFAULT_LIMIT = 50;
private static readonly MAX_LIMIT = 100;
private static readonly DEFAULT_DAYS_BACK = 30;
private static readonly STATS_CACHE_TTL = 300; // 5 minutes
/**
* Get unified activity feed for a project
*
* Performance: O(n log n) where n = limit
* - Fetches up to `limit` items from 3 tables in parallel
* - Merges and sorts by timestamp
* - Returns top `limit` items
*
* PAGINATION APPROACH:
* This implementation fetches `limit` items from each source (Events, Emails, Workflows),
* then merges and returns the top `limit` results by timestamp. This ensures a proper
* chronological timeline but has tradeoffs:
*
* Pros:
* - Proper time-based ordering across all activity types
* - Simple cursor-based pagination
* - Efficient for typical use cases
*
* Cons:
* - May fetch more items from DB than returned to client (up to 3x limit)
* - Cursor pagination across sources can miss items in rare edge cases
*
* For higher scale (10M+ activities), consider:
* - Materialized view or unified activity table
* - Event sourcing pattern with proper indexing
* - Separate pagination per activity type
*/
public static async getActivities(
projectId: string,
limit = this.DEFAULT_LIMIT,
cursor?: string,
types?: ActivityType[],
contactId?: string,
startDate?: Date,
endDate?: Date,
): Promise<PaginatedActivities> {
// Cap limit to prevent abuse
const effectiveLimit = Math.min(limit, this.MAX_LIMIT);
// Fetch extra items from each source to ensure we have enough after merging
// We fetch limit items from each, then take top limit after sorting
const fetchLimit = effectiveLimit;
// Default date range to last 30 days if not specified
const now = new Date();
const defaultStartDate = new Date(now.getTime() - this.DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000);
const dateFilter: Prisma.DateTimeFilter = {
gte: startDate || defaultStartDate,
...(endDate ? {lte: endDate} : {}),
};
// Parse cursor if provided (format: timestamp_id)
let cursorTimestamp: Date | undefined;
let cursorId: string | undefined;
if (cursor) {
const [timestamp, id] = cursor.split('_');
cursorTimestamp = timestamp ? new Date(parseInt(timestamp)) : undefined;
cursorId = id;
}
// Fetch activities from different sources in parallel
// Each source fetches up to fetchLimit items
const [events, emails, workflows] = await Promise.all([
this.fetchEvents(projectId, fetchLimit, dateFilter, cursorTimestamp, cursorId, contactId, types),
this.fetchEmailActivities(projectId, fetchLimit, dateFilter, cursorTimestamp, cursorId, contactId, types),
this.fetchWorkflowActivities(projectId, fetchLimit, dateFilter, cursorTimestamp, cursorId, contactId, types),
]);
// Merge all activities
const allActivities = [...events, ...emails, ...workflows];
// Sort by timestamp descending (most recent first)
allActivities.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
// Take only the requested limit + 1 (to check if there are more)
const paginatedActivities = allActivities.slice(0, effectiveLimit + 1);
// Check if there are more results
const hasMore = paginatedActivities.length > effectiveLimit;
const results = hasMore ? paginatedActivities.slice(0, effectiveLimit) : paginatedActivities;
// Generate cursor from the last item
const lastActivity = results[results.length - 1];
const nextCursor = hasMore && lastActivity ? `${lastActivity.timestamp.getTime()}_${lastActivity.id}` : undefined;
return {
activities: results,
nextCursor,
hasMore,
};
}
/**
* Get activity statistics for a project
*
* Performance: Uses Redis cache with 5-minute TTL
* Falls back to database aggregation if cache miss
*/
public static async getStats(projectId: string, startDate?: Date, endDate?: Date): Promise<ActivityStats> {
// Try to get from cache
const cacheKey = `activity:stats:${projectId}:${startDate?.getTime() || 'all'}:${endDate?.getTime() || 'now'}`;
try {
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
} catch (error) {
console.warn('[ACTIVITY] Failed to get stats from cache:', error);
}
// Default date range to last 30 days if not specified
const now = new Date();
const defaultStartDate = new Date(now.getTime() - this.DEFAULT_DAYS_BACK * 24 * 60 * 60 * 1000);
const dateFilter: Prisma.DateTimeFilter = {
gte: startDate || defaultStartDate,
...(endDate ? {lte: endDate} : {}),
};
// Compute stats from database (in parallel for performance)
const [totalEvents, emailStats] = await Promise.all([
// Count total events
prisma.event.count({
where: {
projectId,
createdAt: dateFilter,
},
}),
// Aggregate email stats
prisma.email.aggregate({
where: {
projectId,
createdAt: dateFilter,
},
_count: {
id: true,
openedAt: true,
clickedAt: true,
},
}),
]);
// Count workflow executions
const totalWorkflowsStarted = await prisma.workflowExecution.count({
where: {
workflow: {
projectId,
},
startedAt: dateFilter,
},
});
const totalEmailsSent = emailStats._count.id;
const totalEmailsOpened = emailStats._count.openedAt || 0;
const totalEmailsClicked = emailStats._count.clickedAt || 0;
const stats: ActivityStats = {
totalEvents,
totalEmailsSent,
totalEmailsOpened,
totalEmailsClicked,
totalWorkflowsStarted,
openRate: totalEmailsSent > 0 ? (totalEmailsOpened / totalEmailsSent) * 100 : 0,
clickRate: totalEmailsSent > 0 ? (totalEmailsClicked / totalEmailsSent) * 100 : 0,
};
// Cache for 5 minutes
try {
await redis.setex(cacheKey, this.STATS_CACHE_TTL, JSON.stringify(stats));
} catch (error) {
console.warn('[ACTIVITY] Failed to cache stats:', error);
}
return stats;
}
/**
* Invalidate activity stats cache for a project
* Should be called when new activities are created
*/
public static async invalidateStatsCache(projectId: string): Promise<void> {
try {
// Delete all cache keys for this project
const pattern = `activity:stats:${projectId}:*`;
const keys = await redis.keys(pattern);
if (keys.length > 0) {
await redis.del(...keys);
}
} catch (error) {
console.warn('[ACTIVITY] Failed to invalidate stats cache:', error);
}
}
/**
* Get recent activity count (for real-time updates)
* Returns count of activities in the last N minutes
*/
public static async getRecentActivityCount(projectId: string, minutes = 5): Promise<number> {
const since = new Date(Date.now() - minutes * 60 * 1000);
const dateFilter: Prisma.DateTimeFilter = {gte: since};
const [eventCount, emailCount, workflowCount] = await Promise.all([
prisma.event.count({
where: {projectId, createdAt: dateFilter},
}),
prisma.email.count({
where: {projectId, createdAt: dateFilter},
}),
prisma.workflowExecution.count({
where: {
workflow: {projectId},
startedAt: dateFilter,
},
}),
]);
return eventCount + emailCount + workflowCount;
}
/**
* Get upcoming scheduled activities for a project
*
* Fetches scheduled campaigns and workflow step executions that are
* scheduled to send emails in the future.
*
* @param projectId - Project ID
* @param limit - Max number of items to return (default 50)
* @param daysAhead - How many days into the future to look (default 30)
*/
public static async getUpcomingActivities(
projectId: string,
limit = this.DEFAULT_LIMIT,
daysAhead = this.DEFAULT_DAYS_BACK,
): Promise<Activity[]> {
const effectiveLimit = Math.min(limit, this.MAX_LIMIT);
const now = new Date();
const futureDate = new Date(now.getTime() + daysAhead * 24 * 60 * 60 * 1000);
const dateFilter: Prisma.DateTimeFilter = {
gte: now,
lte: futureDate,
};
// Fetch scheduled items in parallel
const [scheduledCampaigns, scheduledWorkflowSteps] = await Promise.all([
this.fetchScheduledCampaigns(projectId, effectiveLimit, dateFilter),
this.fetchScheduledWorkflowSteps(projectId, effectiveLimit, dateFilter),
]);
// Merge all scheduled activities
const allActivities = [...scheduledCampaigns, ...scheduledWorkflowSteps];
// Sort by timestamp ascending (earliest first for upcoming items)
allActivities.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
// Return up to the limit
return allActivities.slice(0, effectiveLimit);
}
/**
* Fetch event activities
*/
private static async fetchEvents(
projectId: string,
limit: number,
dateFilter: Prisma.DateTimeFilter,
cursorTimestamp?: Date,
cursorId?: string,
contactId?: string,
types?: ActivityType[],
): Promise<Activity[]> {
// Skip if filtering by types and event.triggered is not included
if (types && !types.includes(ActivityType.EVENT_TRIGGERED)) {
return [];
}
const where: Prisma.EventWhereInput = {
projectId,
createdAt: cursorTimestamp
? {
...dateFilter,
lt: cursorTimestamp,
}
: dateFilter,
...(contactId ? {contactId} : {}),
};
const events = await prisma.event.findMany({
where,
orderBy: {createdAt: 'desc'},
take: limit,
include: {
contact: {
select: {
email: true,
},
},
},
});
return events.map(event => ({
id: event.id,
type: ActivityType.EVENT_TRIGGERED,
timestamp: event.createdAt,
contactEmail: event.contact?.email,
contactId: event.contactId || undefined,
metadata: {
eventName: event.name,
eventData: event.data,
},
}));
}
/**
* Fetch email activities (sent, delivered, opened, clicked, bounced)
*/
private static async fetchEmailActivities(
projectId: string,
limit: number,
dateFilter: Prisma.DateTimeFilter,
cursorTimestamp?: Date,
cursorId?: string,
contactId?: string,
types?: ActivityType[],
): Promise<Activity[]> {
const activities: Activity[] = [];
// Determine which email statuses to fetch based on types filter
const emailTypes = types
? types.filter(t => t.startsWith('email.'))
: Object.values(ActivityType).filter(t => t.startsWith('email.'));
if (emailTypes.length === 0) {
return [];
}
const where: Prisma.EmailWhereInput = {
projectId,
...(contactId ? {contactId} : {}),
};
const emails = await prisma.email.findMany({
where: {
...where,
createdAt: cursorTimestamp
? {
...dateFilter,
lt: cursorTimestamp,
}
: dateFilter,
},
orderBy: {createdAt: 'desc'},
take: limit,
include: {
contact: {
select: {
email: true,
},
},
campaign: {
select: {
name: true,
},
},
workflowExecution: {
select: {
workflow: {
select: {
name: true,
},
},
},
},
},
});
// Convert each email into multiple activities based on its state
for (const email of emails) {
const baseMetadata = {
subject: email.subject,
sourceType: email.sourceType,
campaignName: email.campaign?.name,
workflowName: email.workflowExecution?.workflow?.name,
};
if (email.sentAt && (!types || types.includes(ActivityType.EMAIL_SENT))) {
activities.push({
id: `${email.id}_sent`,
type: ActivityType.EMAIL_SENT,
timestamp: email.sentAt,
contactEmail: email.contact.email,
contactId: email.contactId,
metadata: baseMetadata,
});
}
if (email.deliveredAt && (!types || types.includes(ActivityType.EMAIL_DELIVERED))) {
activities.push({
id: `${email.id}_delivered`,
type: ActivityType.EMAIL_DELIVERED,
timestamp: email.deliveredAt,
contactEmail: email.contact.email,
contactId: email.contactId,
metadata: baseMetadata,
});
}
if (email.openedAt && (!types || types.includes(ActivityType.EMAIL_OPENED))) {
activities.push({
id: `${email.id}_opened`,
type: ActivityType.EMAIL_OPENED,
timestamp: email.openedAt,
contactEmail: email.contact.email,
contactId: email.contactId,
metadata: {
...baseMetadata,
totalOpens: email.opens,
},
});
}
// Email clicked
if (email.clickedAt && (!types || types.includes(ActivityType.EMAIL_CLICKED))) {
activities.push({
id: `${email.id}_clicked`,
type: ActivityType.EMAIL_CLICKED,
timestamp: email.clickedAt,
contactEmail: email.contact.email,
contactId: email.contactId,
metadata: {
...baseMetadata,
totalClicks: email.clicks,
},
});
}
// Email bounced
if (email.bouncedAt && (!types || types.includes(ActivityType.EMAIL_BOUNCED))) {
activities.push({
id: `${email.id}_bounced`,
type: ActivityType.EMAIL_BOUNCED,
timestamp: email.bouncedAt,
contactEmail: email.contact.email,
contactId: email.contactId,
metadata: {
...baseMetadata,
error: email.error,
},
});
}
}
return activities;
}
/**
* Fetch workflow activities
*/
private static async fetchWorkflowActivities(
projectId: string,
limit: number,
dateFilter: Prisma.DateTimeFilter,
cursorTimestamp?: Date,
cursorId?: string,
contactId?: string,
types?: ActivityType[],
): Promise<Activity[]> {
// Skip if filtering by types and workflow types are not included
if (types && !types.some(t => t.startsWith('workflow.'))) {
return [];
}
const activities: Activity[] = [];
const where: Prisma.WorkflowExecutionWhereInput = {
workflow: {
projectId,
},
...(contactId ? {contactId} : {}),
};
const executions = await prisma.workflowExecution.findMany({
where: {
...where,
startedAt: cursorTimestamp
? {
...dateFilter,
lt: cursorTimestamp,
}
: dateFilter,
},
orderBy: {startedAt: 'desc'},
take: limit,
include: {
contact: {
select: {
email: true,
},
},
workflow: {
select: {
name: true,
},
},
},
});
for (const execution of executions) {
// Workflow started
if (!types || types.includes(ActivityType.WORKFLOW_STARTED)) {
activities.push({
id: `${execution.id}_started`,
type: ActivityType.WORKFLOW_STARTED,
timestamp: execution.startedAt,
contactEmail: execution.contact?.email,
contactId: execution.contactId,
metadata: {
workflowName: execution.workflow.name,
status: execution.status,
},
});
}
// Workflow completed
if (execution.completedAt && (!types || types.includes(ActivityType.WORKFLOW_COMPLETED))) {
activities.push({
id: `${execution.id}_completed`,
type: ActivityType.WORKFLOW_COMPLETED,
timestamp: execution.completedAt,
contactEmail: execution.contact?.email,
contactId: execution.contactId,
metadata: {
workflowName: execution.workflow.name,
status: execution.status,
exitReason: execution.exitReason,
},
});
}
}
return activities;
}
/**
* Fetch scheduled campaigns
*/
private static async fetchScheduledCampaigns(
projectId: string,
limit: number,
dateFilter: Prisma.DateTimeFilter,
): Promise<Activity[]> {
const campaigns = await prisma.campaign.findMany({
where: {
projectId,
status: 'SCHEDULED',
scheduledFor: dateFilter,
},
orderBy: {scheduledFor: 'asc'},
take: limit,
include: {
segment: {
select: {
name: true,
},
},
},
});
return campaigns.map(campaign => ({
id: `campaign_${campaign.id}_scheduled`,
type: ActivityType.CAMPAIGN_SCHEDULED,
timestamp: campaign.scheduledFor!,
metadata: {
campaignName: campaign.name,
subject: campaign.subject,
totalRecipients: campaign.totalRecipients,
segmentName: campaign.segment?.name,
audienceType: campaign.audienceType,
},
}));
}
/**
* Fetch scheduled workflow step executions (emails with delays)
*/
private static async fetchScheduledWorkflowSteps(
projectId: string,
limit: number,
dateFilter: Prisma.DateTimeFilter,
): Promise<Activity[]> {
const stepExecutions = await prisma.workflowStepExecution.findMany({
where: {
execution: {
workflow: {
projectId,
},
},
status: 'PENDING',
scheduledFor: dateFilter,
},
orderBy: {scheduledFor: 'asc'},
take: limit,
include: {
execution: {
include: {
contact: {
select: {
email: true,
},
},
workflow: {
select: {
name: true,
},
},
},
},
step: {
select: {
name: true,
type: true,
config: true,
},
},
},
});
return stepExecutions
.filter(stepExec => stepExec.step.type === 'SEND_EMAIL' && stepExec.scheduledFor)
.map(stepExec => ({
id: `workflow_step_${stepExec.id}_scheduled`,
type: ActivityType.WORKFLOW_EMAIL_SCHEDULED,
timestamp: stepExec.scheduledFor!,
contactEmail: stepExec.execution.contact?.email,
contactId: stepExec.execution.contactId,
metadata: {
workflowName: stepExec.execution.workflow.name,
stepName: stepExec.step.name,
subject:
stepExec.step.config &&
typeof stepExec.step.config === 'object' &&
stepExec.step.config !== null &&
'subject' in stepExec.step.config &&
typeof (stepExec.step.config as Record<string, unknown>).subject === 'string'
? (stepExec.step.config as Record<string, unknown>).subject
: undefined,
},
}));
}
}