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, ): Promise { // 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 { 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 { 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 { 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 { 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 { // 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(); for (const event of events) { if (event.data && typeof event.data === 'object' && !Array.isArray(event.data)) { const data = event.data as Record; 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..entry or segment..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, ): Promise { // 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, ): Promise { 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; } }