Add events to workflows

This commit is contained in:
Dries Augustyns
2025-12-01 19:08:02 +01:00
parent b1f0043940
commit b521a405bb
4 changed files with 80 additions and 119 deletions
+7 -10
View File
@@ -1,5 +1,3 @@
import {STATUS_CODES} from 'node:http';
import {Server} from '@overnightjs/core'; import {Server} from '@overnightjs/core';
import cookies from 'cookie-parser'; import cookies from 'cookie-parser';
import cors from 'cors'; import cors from 'cors';
@@ -12,16 +10,16 @@ import {ZodError} from 'zod';
import { import {
DASHBOARD_URI, DASHBOARD_URI,
GITHUB_OAUTH_ENABLED,
GOOGLE_OAUTH_ENABLED,
LANDING_URI, LANDING_URI,
NODE_ENV, NODE_ENV,
PORT, PORT,
STRIPE_ENABLED,
WIKI_URI,
S3_ENABLED, S3_ENABLED,
GITHUB_OAUTH_ENABLED,
GOOGLE_OAUTH_ENABLED,
TRACKING_TOGGLE_ENABLED,
SMTP_ENABLED, SMTP_ENABLED,
STRIPE_ENABLED,
TRACKING_TOGGLE_ENABLED,
WIKI_URI
} from './app/constants.js'; } from './app/constants.js';
import {Actions} from './controllers/Actions.js'; import {Actions} from './controllers/Actions.js';
import {Activity} from './controllers/Activity.js'; import {Activity} from './controllers/Activity.js';
@@ -41,7 +39,7 @@ import {Webhooks} from './controllers/Webhooks.js';
import {Workflows} from './controllers/Workflows.js'; import {Workflows} from './controllers/Workflows.js';
import {Config} from './controllers/Config.js'; import {Config} from './controllers/Config.js';
import {prisma} from './database/prisma.js'; import {prisma} from './database/prisma.js';
import {ErrorCode, HttpException, type FieldError, ValidationError} from './exceptions/index.js'; import {ErrorCode, type FieldError, HttpException, ValidationError} from './exceptions/index.js';
import {apiRequestCleanupQueue, domainVerificationQueue, segmentCountQueue} from './services/QueueService.js'; import {apiRequestCleanupQueue, domainVerificationQueue, segmentCountQueue} from './services/QueueService.js';
import * as S3Service from './services/S3Service.js'; import * as S3Service from './services/S3Service.js';
import {requestIdMiddleware} from './middleware/requestId.js'; import {requestIdMiddleware} from './middleware/requestId.js';
@@ -300,8 +298,7 @@ server.app.use((error: Error, req: Request, res: Response, _next: NextFunction)
message: 'An unexpected error occurred', message: 'An unexpected error occurred',
statusCode, statusCode,
requestId, requestId,
suggestion: suggestion: 'This is an internal server error. Please contact support with the request ID if the issue persists.',
'This is an internal server error. Please contact support with the request ID if the issue persists.',
}, },
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };
+2 -18
View File
@@ -4,8 +4,6 @@ import type {NextFunction, Request, Response} from 'express';
import type {AuthResponse} from '../middleware/auth.js'; import type {AuthResponse} from '../middleware/auth.js';
import {requireAuth} 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 {WorkflowService} from '../services/WorkflowService.js';
import {CatchAsync} from '../utils/asyncHandler.js'; import {CatchAsync} from '../utils/asyncHandler.js';
@@ -43,23 +41,9 @@ export class Workflows {
const eventName = req.query.eventName as string | undefined; const eventName = req.query.eventName as string | undefined;
try { try {
// Get contact fields (standard + custom data fields) const result = await WorkflowService.getAvailableFields(auth.projectId!, eventName);
const contactFields = await ContactService.getAvailableFields(auth.projectId!);
// Add standard contact fields return res.status(200).json(result);
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) { } catch (error) {
console.error('[WORKFLOWS] Failed to get available fields:', error); console.error('[WORKFLOWS] Failed to get available fields:', error);
return res.status(500).json({ return res.status(500).json({
@@ -1,23 +1,17 @@
import {describe, it, expect, beforeEach} from 'vitest'; import {beforeEach, describe, expect, it} 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 {factories, getPrismaClient} from '../../../../../test/helpers';
import {EventService} from '../../services/EventService'; import {EventService} from '../../services/EventService';
import {WorkflowService} from '../../services/WorkflowService';
describe('Workflows Controller', () => { describe('Workflows Controller', () => {
let projectId: string; let projectId: string;
let userId: string; let userId: string;
let authToken: string;
const prisma = getPrismaClient(); const prisma = getPrismaClient();
beforeEach(async () => { beforeEach(async () => {
const {project, user} = await factories.createUserWithProject(); const {project, user} = await factories.createUserWithProject();
projectId = project.id; projectId = project.id;
userId = user.id; userId = user.id;
authToken = jwt.sign({userId: user.id, projectId: project.id}, JWT_SECRET);
}); });
// ======================================== // ========================================
@@ -42,28 +36,25 @@ describe('Workflows Controller', () => {
isFirstOpen: true, isFirstOpen: true,
}); });
const response = await request(app) const responseBody = await WorkflowService.getAvailableFields(projectId);
.get('/workflows/fields')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.fields).toBeInstanceOf(Array); expect(responseBody.fields).toBeInstanceOf(Array);
expect(response.body.count).toBeGreaterThan(0); expect(responseBody.count).toBeGreaterThan(0);
// Should include standard contact fields // Should include standard contact fields
expect(response.body.fields).toContain('contact.email'); expect(responseBody.fields).toContain('contact.email');
expect(response.body.fields).toContain('contact.subscribed'); expect(responseBody.fields).toContain('contact.subscribed');
// Should include custom contact data fields // Should include custom contact data fields
expect(response.body.fields).toContain('data.firstName'); expect(responseBody.fields).toContain('data.firstName');
expect(response.body.fields).toContain('data.lastName'); expect(responseBody.fields).toContain('data.lastName');
expect(response.body.fields).toContain('data.plan'); expect(responseBody.fields).toContain('data.plan');
// Should include event fields // Should include event fields
expect(response.body.fields).toContain('event.subject'); expect(responseBody.fields).toContain('event.subject');
expect(response.body.fields).toContain('event.from'); expect(responseBody.fields).toContain('event.from');
expect(response.body.fields).toContain('event.openedAt'); expect(responseBody.fields).toContain('event.openedAt');
expect(response.body.fields).toContain('event.isFirstOpen'); expect(responseBody.fields).toContain('event.isFirstOpen');
}); });
it('should filter event fields by eventName query param', async () => { it('should filter event fields by eventName query param', async () => {
@@ -83,21 +74,16 @@ describe('Workflows Controller', () => {
clickedAt: new Date().toISOString(), clickedAt: new Date().toISOString(),
}); });
// Request fields for email.opened only const responseBody = await WorkflowService.getAvailableFields(projectId, 'email.opened');
const response = await request(app)
.get('/workflows/fields')
.query({eventName: 'email.opened'})
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
// Should include email.opened fields // Should include email.opened fields
expect(response.body.fields).toContain('event.subject'); expect(responseBody.fields).toContain('event.subject');
expect(response.body.fields).toContain('event.openedAt'); expect(responseBody.fields).toContain('event.openedAt');
expect(response.body.fields).toContain('event.isFirstOpen'); expect(responseBody.fields).toContain('event.isFirstOpen');
// Should NOT include email.clicked fields // Should NOT include email.clicked fields
expect(response.body.fields).not.toContain('event.link'); expect(responseBody.fields).not.toContain('event.link');
expect(response.body.fields).not.toContain('event.clickedAt'); expect(responseBody.fields).not.toContain('event.clickedAt');
}); });
it('should return fields in sorted order', async () => { it('should return fields in sorted order', async () => {
@@ -114,12 +100,9 @@ describe('Workflows Controller', () => {
aardvark: 'value', aardvark: 'value',
}); });
const response = await request(app) const responseBody = await WorkflowService.getAvailableFields(projectId);
.get('/workflows/fields')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
const fields = response.body.fields as string[]; const fields = responseBody.fields as string[];
// Verify fields are sorted // Verify fields are sorted
const sortedFields = [...fields].sort(); const sortedFields = [...fields].sort();
@@ -127,24 +110,17 @@ describe('Workflows Controller', () => {
}); });
it('should return empty event fields when no events exist', async () => { it('should return empty event fields when no events exist', async () => {
const response = await request(app) const responseBody = await WorkflowService.getAvailableFields(projectId);
.get('/workflows/fields')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
// Should still have contact fields // Should still have contact fields
expect(response.body.fields).toContain('contact.email'); expect(responseBody.fields).toContain('contact.email');
expect(response.body.fields).toContain('contact.subscribed'); expect(responseBody.fields).toContain('contact.subscribed');
// But no event fields // But no event fields
const eventFields = response.body.fields.filter((f: string) => f.startsWith('event.')); const eventFields = responseBody.fields.filter((f: string) => f.startsWith('event.'));
expect(eventFields).toHaveLength(0); 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 () => { it('should only return fields for authenticated project', async () => {
// Create another project with events // Create another project with events
const {project: otherProject} = await factories.createUserWithProject(); const {project: otherProject} = await factories.createUserWithProject();
@@ -154,14 +130,10 @@ describe('Workflows Controller', () => {
secretField: 'secret value', secretField: 'secret value',
}); });
// Request fields with our auth token (different project) const responseBody = await WorkflowService.getAvailableFields(projectId);
const response = await request(app)
.get('/workflows/fields')
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
// Should not include fields from other project // Should not include fields from other project
expect(response.body.fields).not.toContain('event.secretField'); expect(responseBody.fields).not.toContain('event.secretField');
}); });
it('should handle URL encoded event names', async () => { it('should handle URL encoded event names', async () => {
@@ -171,13 +143,9 @@ describe('Workflows Controller', () => {
field1: 'value', field1: 'value',
}); });
const response = await request(app) const responseBody = await WorkflowService.getAvailableFields(projectId, 'email.opened');
.get('/workflows/fields')
.query({eventName: 'email.opened'})
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.fields).toContain('event.field1'); expect(responseBody.fields).toContain('event.field1');
}); });
}); });
@@ -201,19 +169,15 @@ describe('Workflows Controller', () => {
sentAt: new Date().toISOString(), sentAt: new Date().toISOString(),
}); });
const response = await request(app) const responseBody = await WorkflowService.getAvailableFields(projectId, 'email.sent');
.get('/workflows/fields')
.query({eventName: 'email.sent'})
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.fields).toContain('event.subject'); expect(responseBody.fields).toContain('event.subject');
expect(response.body.fields).toContain('event.from'); expect(responseBody.fields).toContain('event.from');
expect(response.body.fields).toContain('event.fromName'); expect(responseBody.fields).toContain('event.fromName');
expect(response.body.fields).toContain('event.messageId'); expect(responseBody.fields).toContain('event.messageId');
expect(response.body.fields).toContain('event.templateId'); expect(responseBody.fields).toContain('event.templateId');
expect(response.body.fields).toContain('event.sourceType'); expect(responseBody.fields).toContain('event.sourceType');
expect(response.body.fields).toContain('event.sentAt'); expect(responseBody.fields).toContain('event.sentAt');
}); });
it('should discover correct fields for email.opened events', async () => { it('should discover correct fields for email.opened events', async () => {
@@ -230,15 +194,11 @@ describe('Workflows Controller', () => {
isFirstOpen: true, isFirstOpen: true,
}); });
const response = await request(app) const responseBody = await WorkflowService.getAvailableFields(projectId, 'email.opened');
.get('/workflows/fields')
.query({eventName: 'email.opened'})
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.fields).toContain('event.openedAt'); expect(responseBody.fields).toContain('event.openedAt');
expect(response.body.fields).toContain('event.opens'); expect(responseBody.fields).toContain('event.opens');
expect(response.body.fields).toContain('event.isFirstOpen'); expect(responseBody.fields).toContain('event.isFirstOpen');
}); });
it('should discover correct fields for email.clicked events', async () => { it('should discover correct fields for email.clicked events', async () => {
@@ -254,16 +214,12 @@ describe('Workflows Controller', () => {
isFirstClick: true, isFirstClick: true,
}); });
const response = await request(app) const responseBody = await WorkflowService.getAvailableFields(projectId, 'email.clicked');
.get('/workflows/fields')
.query({eventName: 'email.clicked'})
.set('Authorization', `Bearer ${authToken}`)
.expect(200);
expect(response.body.fields).toContain('event.link'); expect(responseBody.fields).toContain('event.link');
expect(response.body.fields).toContain('event.clickedAt'); expect(responseBody.fields).toContain('event.clickedAt');
expect(response.body.fields).toContain('event.clicks'); expect(responseBody.fields).toContain('event.clicks');
expect(response.body.fields).toContain('event.isFirstClick'); expect(responseBody.fields).toContain('event.isFirstClick');
}); });
}); });
}); });
+24
View File
@@ -5,6 +5,7 @@ import {prisma} from '../database/prisma.js';
import {HttpException} from '../exceptions/index.js'; import {HttpException} from '../exceptions/index.js';
import {EventService} from './EventService.js'; import {EventService} from './EventService.js';
import {ContactService} from './ContactService.js';
export interface PaginatedWorkflows { export interface PaginatedWorkflows {
workflows: Workflow[]; workflows: Workflow[];
@@ -648,4 +649,27 @@ export class WorkflowService {
}, },
}); });
} }
/**
* Get all available fields for workflow conditions (contact fields + event fields)
*/
public static async getAvailableFields(projectId: string, eventName?: string) {
// Get contact fields (standard + custom data fields)
const contactFields = await ContactService.getAvailableFields(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(projectId, eventName);
// Combine all fields
const allFields = [...standardContactFields, ...contactFields, ...eventFields].sort();
return {
fields: allFields,
count: allFields.length,
};
}
} }