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

537 lines
15 KiB
TypeScript

import type {Event} from '@plunk/db';
import {Prisma} from '@plunk/db';
import type {FilterCondition, FilterGroup} from '@plunk/types';
import {toPrismaJson} from '@plunk/types';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js';
import {Keys} from './keys.js';
import {WorkflowExecutionService} from './WorkflowExecutionService.js';
/**
* Event Service
* Handles event tracking and workflow triggering
*/
export class EventService {
/**
* Track an event
* This can trigger workflows that are listening for this event
*/
public static async trackEvent(
projectId: string,
eventName: string,
contactId?: string,
emailId?: string,
data?: Record<string, unknown>,
): Promise<Event> {
// Create event record
const event = await prisma.event.create({
data: {
projectId,
contactId,
emailId,
name: eventName,
data: data ? toPrismaJson(data) : undefined,
},
});
// Trigger workflows that are listening for this event
await this.triggerWorkflows(projectId, eventName, contactId, data);
// Resume workflows waiting for this event
await WorkflowExecutionService.handleEvent(projectId, eventName, contactId, data);
return event;
}
/**
* Invalidate the workflow cache for a project
* Should be called when workflows are enabled/disabled or updated
*/
public static async invalidateWorkflowCache(projectId: string): Promise<void> {
const cacheKey = Keys.Workflow.enabled(projectId);
try {
await redis.del(cacheKey);
} catch (error) {
signale.warn('[EVENT] Failed to invalidate workflow cache:', error);
}
}
/**
* Get events for a contact
*/
public static async getContactEvents(projectId: string, contactId: string, limit = 50): Promise<Event[]> {
return prisma.event.findMany({
where: {
projectId,
contactId,
},
orderBy: {createdAt: 'desc'},
take: limit,
});
}
/**
* Get events for a project
*/
public static async getProjectEvents(projectId: string, eventName?: string, limit = 100): Promise<Event[]> {
return prisma.event.findMany({
where: {
projectId,
...(eventName ? {name: eventName} : {}),
},
orderBy: {createdAt: 'desc'},
take: limit,
include: {
contact: {
select: {
email: true,
},
},
},
});
}
/**
* Get event counts by type
*/
public static async getEventStats(projectId: string, startDate?: Date, endDate?: Date) {
const where: Prisma.EventWhereInput = {
projectId,
...(startDate || endDate
? {
createdAt: {
...(startDate ? {gte: startDate} : {}),
...(endDate ? {lte: endDate} : {}),
},
}
: {}),
};
const events = await prisma.event.groupBy({
by: ['name'],
where,
_count: true,
orderBy: {
_count: {
name: 'desc',
},
},
});
return events.map(e => ({
name: e.name,
count: e._count,
}));
}
/**
* Get unique event names for a project
*/
public static async getUniqueEventNames(projectId: string): Promise<string[]> {
const events = await prisma.event.groupBy({
by: ['name'],
where: {projectId},
orderBy: {
_count: {
name: 'desc',
},
},
});
return events.map(e => e.name);
}
/**
* Get available event data fields for a specific event name
* Analyzes actual event data to discover which fields are present
* This is optimized for large datasets - only samples recent events
*/
public static async getAvailableEventFields(projectId: string, eventName?: string): Promise<string[]> {
// Query recent events to discover data fields (limit to 100 for performance)
const events = await prisma.event.findMany({
where: {
projectId,
...(eventName ? {name: eventName} : {}),
data: {
not: Prisma.DbNull, // Only events with data (not null)
},
},
select: {
data: true,
},
orderBy: {createdAt: 'desc'},
take: 100, // Sample recent events for performance
});
// Extract all unique keys from event data
const fieldSet = new Set<string>();
for (const event of events) {
if (event.data && typeof event.data === 'object' && !Array.isArray(event.data)) {
const data = event.data as Record<string, unknown>;
for (const key of Object.keys(data)) {
fieldSet.add(`event.${key}`);
}
}
}
return Array.from(fieldSet).sort();
}
/**
* Check if an event is used in any segments or workflows
* Returns usage information including which segments/workflows use the event
*
* @param projectId - The project ID
* @param eventName - The event name to check (e.g., "purchase.completed", "user.signup")
* @returns Usage information
*/
public static async getEventUsage(
projectId: string,
eventName: string,
): Promise<{
usedInSegments: Array<{id: string; name: string}>;
usedInWorkflows: Array<{id: string; name: string}>;
totalCount: number;
uniqueContacts: number;
canDelete: boolean;
}> {
// Get all segments for the project
const segments = await prisma.segment.findMany({
where: {projectId},
select: {id: true, name: true, condition: true},
});
// Check which segments use this event
const usedInSegments = segments.filter(segment => {
const condition = segment.condition as FilterCondition | null;
return this.eventUsedInCondition(eventName, condition);
});
// Get workflows that use this event as a trigger or wait condition
const workflows = await prisma.workflow.findMany({
where: {
projectId,
OR: [
// Event as trigger
{
triggerType: 'EVENT',
triggerConfig: {
path: ['eventName'],
equals: eventName,
},
},
],
},
select: {id: true, name: true},
});
// Also check workflow steps that wait for events
const workflowStepsWithEvent = await prisma.workflowStep.findMany({
where: {
workflow: {projectId},
type: 'WAIT_FOR_EVENT',
config: {
path: ['eventName'],
equals: eventName,
},
},
include: {
workflow: {
select: {id: true, name: true},
},
},
});
const usedInWorkflows = [...workflows, ...workflowStepsWithEvent.map(step => step.workflow)].reduce(
(acc, workflow) => {
// Deduplicate by id
if (!acc.find((w: {id: string; name: string}) => w.id === workflow.id)) {
acc.push(workflow);
}
return acc;
},
[] as Array<{id: string; name: string}>,
);
// Get event statistics
const [totalCount, uniqueContacts] = await Promise.all([
prisma.event.count({
where: {projectId, name: eventName},
}),
prisma.event
.groupBy({
by: ['contactId'],
where: {projectId, name: eventName, contactId: {not: null}},
})
.then(results => results.length),
]);
const canDelete = usedInSegments.length === 0 && usedInWorkflows.length === 0;
return {
usedInSegments,
usedInWorkflows,
totalCount,
uniqueContacts,
canDelete,
};
}
/**
* Delete all events with a specific name
* WARNING: This is destructive and cannot be undone
* Should only be called after verifying the event is not in use
*
* @param projectId - The project ID
* @param eventName - The event name to delete
*/
public static async deleteEvent(projectId: string, eventName: string): Promise<{deletedCount: number}> {
// Prevent deletion of reserved system events
if (this.isReservedEvent(eventName)) {
throw new Error(`Cannot delete reserved system event: ${eventName}`);
}
// Check if event is in use
const usage = await this.getEventUsage(projectId, eventName);
if (!usage.canDelete) {
throw new Error(
`Cannot delete event: used in ${usage.usedInSegments.length} segment(s) and ${usage.usedInWorkflows.length} workflow(s)`,
);
}
// Delete all events with this name
const result = await prisma.event.deleteMany({
where: {
projectId,
name: eventName,
},
});
return {deletedCount: result.count};
}
/**
* Check if an event name is reserved for system use
* Reserved patterns:
* - email.* (email.sent, email.delivery, email.open, email.click, email.bounce, email.complaint)
* - contact.subscribed, contact.unsubscribed
* - segment.*.entry, segment.*.exit
*
* @param eventName - The event name to check
* @returns true if the event is reserved, false otherwise
*/
public static isReservedEvent(eventName: string): boolean {
// Email events: email.*
if (eventName.startsWith('email.')) {
return true;
}
// Contact events: contact.subscribed, contact.unsubscribed
if (eventName === 'contact.subscribed' || eventName === 'contact.unsubscribed') {
return true;
}
// Segment events: segment.*.entry, segment.*.exit
// Pattern: segment.<slug>.entry or segment.<slug>.exit
if (eventName.startsWith('segment.') && (eventName.endsWith('.entry') || eventName.endsWith('.exit'))) {
return true;
}
return false;
}
/**
* Trigger workflows based on an event
* Uses Redis caching for enabled workflows to improve performance
*/
private static async triggerWorkflows(
projectId: string,
eventName: string,
contactId?: string,
data?: Record<string, unknown>,
): Promise<void> {
// Try to get workflows from cache
const cacheKey = Keys.Workflow.enabled(projectId);
let workflows;
try {
const cached = await redis.get(cacheKey);
if (cached) {
workflows = JSON.parse(cached);
}
} catch (error) {
signale.warn('[EVENT] Failed to get workflows from cache:', error);
}
// If not in cache, fetch from database
if (!workflows) {
workflows = await prisma.workflow.findMany({
where: {
projectId,
enabled: true,
triggerType: 'EVENT',
},
include: {
steps: {
where: {type: 'TRIGGER'},
},
},
});
// Cache for 5 minutes
try {
await redis.setex(cacheKey, 300, JSON.stringify(workflows));
} catch (error) {
signale.warn('[EVENT] Failed to cache workflows:', error);
}
}
for (const workflow of workflows) {
const triggerConfig = workflow.triggerConfig;
// Check if this workflow is triggered by this event
if (triggerConfig?.eventName === eventName) {
// If event is for a specific contact, start workflow for that contact
if (contactId) {
await this.startWorkflowForContact(workflow.id, contactId, data);
} else {
// If event is not contact-specific, you might want different logic
// For example, trigger for all contacts, or skip
signale.info(`[EVENT] Event ${eventName} triggered workflow ${workflow.id}, but no contact specified`);
}
}
}
}
/**
* Start a workflow execution for a contact
*/
private static async startWorkflowForContact(
workflowId: string,
contactId: string,
context?: Record<string, unknown>,
): Promise<void> {
try {
// Get workflow with steps and configuration
const workflow = await prisma.workflow.findUnique({
where: {id: workflowId},
include: {
steps: {
where: {type: 'TRIGGER'},
},
},
});
if (!workflow || workflow.steps.length === 0) {
signale.error(`[EVENT] Workflow ${workflowId} has no trigger step`);
return;
}
// Check re-entry rules
if (!workflow.allowReentry) {
// If re-entry is not allowed, check if contact has ANY execution (regardless of status)
const existingExecution = await prisma.workflowExecution.findFirst({
where: {
workflowId,
contactId,
},
});
if (existingExecution) {
return;
}
} else {
// If re-entry is allowed, only check if there's a currently RUNNING execution
const runningExecution = await prisma.workflowExecution.findFirst({
where: {
workflowId,
contactId,
status: 'RUNNING',
},
});
if (runningExecution) {
return;
}
}
const triggerStep = workflow.steps[0];
if (!triggerStep) {
signale.error(`[EVENT] Workflow ${workflowId} trigger step not found`);
return;
}
// Create workflow execution
const execution = await prisma.workflowExecution.create({
data: {
workflowId,
contactId,
status: 'RUNNING',
currentStepId: triggerStep.id,
context: context ? toPrismaJson(context) : undefined,
},
});
signale.info(
`[EVENT] Started workflow ${workflowId} execution ${execution.id} for contact ${contactId}${workflow.allowReentry ? ' (re-entry allowed)' : ''}`,
);
// Start executing the workflow
await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id);
} catch (error) {
signale.error(`[EVENT] Error starting workflow ${workflowId}:`, error);
}
}
/**
* Helper: Check if an event is used in a filter condition (recursive)
*/
private static eventUsedInCondition(eventName: string, condition: FilterCondition | null): boolean {
if (!condition || typeof condition !== 'object') {
return false;
}
// Check groups in the condition
if (Array.isArray(condition.groups)) {
for (const group of condition.groups) {
if (this.eventUsedInGroup(eventName, group)) {
return true;
}
}
}
return false;
}
/**
* Helper: Check if an event is used in a filter group (recursive)
*/
private static eventUsedInGroup(eventName: string, group: FilterGroup): boolean {
if (!group || typeof group !== 'object') {
return false;
}
// Check filters in the group
if (Array.isArray(group.filters)) {
for (const filter of group.filters) {
// Event filters use field name like "event.eventName"
if (filter.field === `event.${eventName}`) {
return true;
}
}
}
// Check nested conditions
if (group.conditions) {
return this.eventUsedInCondition(eventName, group.conditions);
}
return false;
}
}