Add events to workflows

This commit is contained in:
Dries Augustyns
2025-12-01 18:37:53 +01:00
parent 5158756650
commit b1f0043940
11 changed files with 927 additions and 51 deletions
+43 -15
View File
@@ -8,6 +8,7 @@ import type Stripe from 'stripe';
import {STRIPE_ENABLED, STRIPE_WEBHOOK_SECRET} from '../app/constants.js';
import {stripe} from '../app/stripe.js';
import {prisma} from '../database/prisma.js';
import {EventService} from '../services/EventService.js';
import {SecurityService} from '../services/SecurityService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@@ -23,7 +24,7 @@ export class Webhooks {
*/
@Post('sns')
@CatchAsync
public async receiveSNSWebhook(req: Request, res: Response, next: NextFunction) {
public async receiveSNSWebhook(req: Request, res: Response) {
try {
// Handle SNS subscription confirmation FIRST (before parsing Message field)
if (req.body.Type === 'SubscriptionConfirmation') {
@@ -90,7 +91,18 @@ export class Webhooks {
const now = new Date();
const updateData: Prisma.EmailUpdateInput = {};
const eventName = `email.${eventType.toLowerCase()}`;
let eventData: Record<string, unknown> = {};
// Base event data with email metadata
const baseEventData = {
subject: email.subject,
from: email.from,
fromName: email.fromName,
messageId: email.messageId,
templateId: email.templateId,
campaignId: email.campaignId,
sourceType: email.sourceType,
};
let eventData: Record<string, unknown> = baseEventData;
// Process event based on type
switch (eventType) {
@@ -98,6 +110,10 @@ export class Webhooks {
signale.success(`[WEBHOOK] Delivery confirmed for ${email.contact.email} from ${email.project.name}`);
updateData.status = EmailStatus.DELIVERED;
updateData.deliveredAt = now;
eventData = {
...baseEventData,
deliveredAt: now.toISOString(),
};
break;
case 'Open':
@@ -108,6 +124,12 @@ export class Webhooks {
}
updateData.opens = (email.opens || 0) + 1;
updateData.status = EmailStatus.OPENED;
eventData = {
...baseEventData,
openedAt: email.openedAt?.toISOString() || now.toISOString(),
opens: (email.opens || 0) + 1,
isFirstOpen: !email.openedAt,
};
break;
case 'Click': {
@@ -119,7 +141,13 @@ export class Webhooks {
}
updateData.clicks = (email.clicks || 0) + 1;
updateData.status = EmailStatus.CLICKED;
eventData = {link: clickedLink};
eventData = {
...baseEventData,
link: clickedLink,
clickedAt: email.clickedAt?.toISOString() || now.toISOString(),
clicks: (email.clicks || 0) + 1,
isFirstClick: !email.clickedAt,
};
break;
}
@@ -132,7 +160,11 @@ export class Webhooks {
where: {id: email.contactId},
data: {subscribed: false},
});
eventData = body.bounce ? {bounceType: body.bounce.bounceType} : {};
eventData = {
...baseEventData,
bounceType: body.bounce?.bounceType,
bouncedAt: now.toISOString(),
};
break;
case 'Complaint':
@@ -144,6 +176,10 @@ export class Webhooks {
where: {id: email.contactId},
data: {subscribed: false},
});
eventData = {
...baseEventData,
complainedAt: now.toISOString(),
};
break;
default:
@@ -157,16 +193,8 @@ export class Webhooks {
data: updateData,
});
// Track event
await prisma.event.create({
data: {
projectId: email.projectId,
contactId: email.contactId,
emailId: email.id,
name: eventName,
data: eventData as Prisma.InputJsonValue,
},
});
// Track event (this will trigger workflows)
await EventService.trackEvent(email.projectId, eventName, email.contactId, email.id, eventData);
// Check security limits for bounce and complaint events
if (eventType === 'Bounce' || eventType === 'Complaint') {
@@ -188,7 +216,7 @@ export class Webhooks {
*/
@Post('incoming/stripe')
@CatchAsync
public async receiveStripeWebhook(req: Request, res: Response, next: NextFunction) {
public async receiveStripeWebhook(req: Request, res: Response) {
// Return 404 if billing is disabled
if (!STRIPE_ENABLED || !stripe) {
signale.warn('[WEBHOOK] Stripe webhook received but billing is disabled');
+41
View File
@@ -4,6 +4,8 @@ import type {NextFunction, Request, Response} from 'express';
import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth} from '../middleware/auth.js';
import {ContactService} from '../services/ContactService.js';
import {EventService} from '../services/EventService.js';
import {WorkflowService} from '../services/WorkflowService.js';
import {CatchAsync} from '../utils/asyncHandler.js';
@@ -27,6 +29,45 @@ export class Workflows {
return res.status(200).json(result);
}
/**
* GET /workflows/fields
* Get all available fields for workflow conditions (contact fields + event fields)
* Query param: eventName - Optional event name to filter event fields
* NOTE: This must be defined BEFORE the :id route to avoid conflicts
*/
@Get('fields')
@Middleware([requireAuth])
@CatchAsync
public async getAvailableFields(req: Request, res: Response, next: NextFunction) {
const auth = res.locals.auth as AuthResponse;
const eventName = req.query.eventName as string | undefined;
try {
// Get contact fields (standard + custom data fields)
const contactFields = await ContactService.getAvailableFields(auth.projectId!);
// Add standard contact fields
const standardContactFields = ['contact.email', 'contact.subscribed'];
// Get event fields by analyzing actual event data
// This will only show fields that have been seen in actual events
const eventFields = await EventService.getAvailableEventFields(auth.projectId!, eventName);
// Combine all fields
const allFields = [...standardContactFields, ...contactFields, ...eventFields].sort();
return res.status(200).json({
fields: allFields,
count: allFields.length,
});
} catch (error) {
console.error('[WORKFLOWS] Failed to get available fields:', error);
return res.status(500).json({
error: error instanceof Error ? error.message : 'Failed to get available fields',
});
}
}
/**
* GET /workflows/:id
* Get a specific workflow with all steps and transitions
@@ -0,0 +1,269 @@
import {describe, it, expect, beforeEach} from 'vitest';
import {WorkflowTriggerType} from '@plunk/db';
import request from 'supertest';
import jwt from 'jsonwebtoken';
import {app} from '../../app';
import {JWT_SECRET} from '../../app/constants';
import {factories, getPrismaClient} from '../../../../../test/helpers';
import {EventService} from '../../services/EventService';
describe('Workflows Controller', () => {
let projectId: string;
let userId: string;
let authToken: string;
const prisma = getPrismaClient();
beforeEach(async () => {
const {project, user} = await factories.createUserWithProject();
projectId = project.id;
userId = user.id;
authToken = jwt.sign({userId: user.id, projectId: project.id}, JWT_SECRET);
});
// ========================================
// GET /workflows/fields
// ========================================
describe('GET /workflows/fields', () => {
it('should return contact and event fields', async () => {
const contact = await factories.createContact({
projectId,
data: {
firstName: 'John',
lastName: 'Doe',
plan: 'premium',
},
});
// Create email events with data
await EventService.trackEvent(projectId, 'email.opened', contact.id, undefined, {
subject: 'Welcome',
from: 'hello@example.com',
openedAt: new Date().toISOString(),
isFirstOpen: true,
});
const response = await request(app)
.get('/workflows/fields')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.fields).toBeInstanceOf(Array);
expect(response.body.count).toBeGreaterThan(0);
// Should include standard contact fields
expect(response.body.fields).toContain('contact.email');
expect(response.body.fields).toContain('contact.subscribed');
// Should include custom contact data fields
expect(response.body.fields).toContain('data.firstName');
expect(response.body.fields).toContain('data.lastName');
expect(response.body.fields).toContain('data.plan');
// Should include event fields
expect(response.body.fields).toContain('event.subject');
expect(response.body.fields).toContain('event.from');
expect(response.body.fields).toContain('event.openedAt');
expect(response.body.fields).toContain('event.isFirstOpen');
});
it('should filter event fields by eventName query param', async () => {
const contact = await factories.createContact({projectId});
// Create email.opened events
await EventService.trackEvent(projectId, 'email.opened', contact.id, undefined, {
subject: 'Test',
openedAt: new Date().toISOString(),
isFirstOpen: true,
});
// Create email.clicked events
await EventService.trackEvent(projectId, 'email.clicked', contact.id, undefined, {
subject: 'Test',
link: 'https://example.com',
clickedAt: new Date().toISOString(),
});
// Request fields for email.opened only
const response = await request(app)
.get('/workflows/fields')
.query({eventName: 'email.opened'})
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
// Should include email.opened fields
expect(response.body.fields).toContain('event.subject');
expect(response.body.fields).toContain('event.openedAt');
expect(response.body.fields).toContain('event.isFirstOpen');
// Should NOT include email.clicked fields
expect(response.body.fields).not.toContain('event.link');
expect(response.body.fields).not.toContain('event.clickedAt');
});
it('should return fields in sorted order', async () => {
const contact = await factories.createContact({
projectId,
data: {
zebra: 'last',
apple: 'first',
},
});
await EventService.trackEvent(projectId, 'test.event', contact.id, undefined, {
zoo: 'value',
aardvark: 'value',
});
const response = await request(app)
.get('/workflows/fields')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
const fields = response.body.fields as string[];
// Verify fields are sorted
const sortedFields = [...fields].sort();
expect(fields).toEqual(sortedFields);
});
it('should return empty event fields when no events exist', async () => {
const response = await request(app)
.get('/workflows/fields')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
// Should still have contact fields
expect(response.body.fields).toContain('contact.email');
expect(response.body.fields).toContain('contact.subscribed');
// But no event fields
const eventFields = response.body.fields.filter((f: string) => f.startsWith('event.'));
expect(eventFields).toHaveLength(0);
});
it('should require authentication', async () => {
await request(app).get('/workflows/fields').expect(401);
});
it('should only return fields for authenticated project', async () => {
// Create another project with events
const {project: otherProject} = await factories.createUserWithProject();
const otherContact = await factories.createContact({projectId: otherProject.id});
await EventService.trackEvent(otherProject.id, 'other.event', otherContact.id, undefined, {
secretField: 'secret value',
});
// Request fields with our auth token (different project)
const response = await request(app)
.get('/workflows/fields')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
// Should not include fields from other project
expect(response.body.fields).not.toContain('event.secretField');
});
it('should handle URL encoded event names', async () => {
const contact = await factories.createContact({projectId});
await EventService.trackEvent(projectId, 'email.opened', contact.id, undefined, {
field1: 'value',
});
const response = await request(app)
.get('/workflows/fields')
.query({eventName: 'email.opened'})
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.fields).toContain('event.field1');
});
});
// ========================================
// Integration Tests
// ========================================
describe('Workflow field discovery integration', () => {
it('should discover correct fields for email.sent events', async () => {
const contact = await factories.createContact({projectId});
const email = await factories.createEmail(projectId, contact.id);
// Create email.sent event with actual structure
await EventService.trackEvent(projectId, 'email.sent', contact.id, email.id, {
subject: 'Welcome Email',
from: 'hello@example.com',
fromName: 'Example Team',
messageId: 'msg-123',
templateId: 'tpl-456',
campaignId: null,
sourceType: 'TRANSACTIONAL',
sentAt: new Date().toISOString(),
});
const response = await request(app)
.get('/workflows/fields')
.query({eventName: 'email.sent'})
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.fields).toContain('event.subject');
expect(response.body.fields).toContain('event.from');
expect(response.body.fields).toContain('event.fromName');
expect(response.body.fields).toContain('event.messageId');
expect(response.body.fields).toContain('event.templateId');
expect(response.body.fields).toContain('event.sourceType');
expect(response.body.fields).toContain('event.sentAt');
});
it('should discover correct fields for email.opened events', async () => {
const contact = await factories.createContact({projectId});
const email = await factories.createEmail(projectId, contact.id);
await EventService.trackEvent(projectId, 'email.opened', contact.id, email.id, {
subject: 'Newsletter',
from: 'news@example.com',
fromName: 'Newsletter Team',
messageId: 'msg-456',
openedAt: new Date().toISOString(),
opens: 1,
isFirstOpen: true,
});
const response = await request(app)
.get('/workflows/fields')
.query({eventName: 'email.opened'})
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.fields).toContain('event.openedAt');
expect(response.body.fields).toContain('event.opens');
expect(response.body.fields).toContain('event.isFirstOpen');
});
it('should discover correct fields for email.clicked events', async () => {
const contact = await factories.createContact({projectId});
const email = await factories.createEmail(projectId, contact.id);
await EventService.trackEvent(projectId, 'email.clicked', contact.id, email.id, {
subject: 'Sale Announcement',
from: 'sales@example.com',
link: 'https://example.com/sale',
clickedAt: new Date().toISOString(),
clicks: 1,
isFirstClick: true,
});
const response = await request(app)
.get('/workflows/fields')
.query({eventName: 'email.clicked'})
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.fields).toContain('event.link');
expect(response.body.fields).toContain('event.clickedAt');
expect(response.body.fields).toContain('event.clicks');
expect(response.body.fields).toContain('event.isFirstClick');
});
});
});