725 lines
21 KiB
TypeScript
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,
|
|
},
|
|
}));
|
|
}
|
|
}
|