From b521a405bb06f7aab2eab5e9ae24a55ef644fd38 Mon Sep 17 00:00:00 2001 From: Dries Augustyns Date: Mon, 1 Dec 2025 19:08:02 +0100 Subject: [PATCH] Add events to workflows --- apps/api/src/app.ts | 17 +-- apps/api/src/controllers/Workflows.ts | 20 +-- .../controllers/__tests__/Workflows.test.ts | 138 ++++++------------ apps/api/src/services/WorkflowService.ts | 24 +++ 4 files changed, 80 insertions(+), 119 deletions(-) diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 7447a03..f7b2d33 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,5 +1,3 @@ -import {STATUS_CODES} from 'node:http'; - import {Server} from '@overnightjs/core'; import cookies from 'cookie-parser'; import cors from 'cors'; @@ -12,16 +10,16 @@ import {ZodError} from 'zod'; import { DASHBOARD_URI, + GITHUB_OAUTH_ENABLED, + GOOGLE_OAUTH_ENABLED, LANDING_URI, NODE_ENV, PORT, - STRIPE_ENABLED, - WIKI_URI, S3_ENABLED, - GITHUB_OAUTH_ENABLED, - GOOGLE_OAUTH_ENABLED, - TRACKING_TOGGLE_ENABLED, SMTP_ENABLED, + STRIPE_ENABLED, + TRACKING_TOGGLE_ENABLED, + WIKI_URI } from './app/constants.js'; import {Actions} from './controllers/Actions.js'; import {Activity} from './controllers/Activity.js'; @@ -41,7 +39,7 @@ import {Webhooks} from './controllers/Webhooks.js'; import {Workflows} from './controllers/Workflows.js'; import {Config} from './controllers/Config.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 * as S3Service from './services/S3Service.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', statusCode, requestId, - suggestion: - 'This is an internal server error. Please contact support with the request ID if the issue persists.', + suggestion: 'This is an internal server error. Please contact support with the request ID if the issue persists.', }, timestamp: new Date().toISOString(), }; diff --git a/apps/api/src/controllers/Workflows.ts b/apps/api/src/controllers/Workflows.ts index 74a1de1..00fb10c 100644 --- a/apps/api/src/controllers/Workflows.ts +++ b/apps/api/src/controllers/Workflows.ts @@ -4,8 +4,6 @@ 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'; @@ -43,23 +41,9 @@ export class Workflows { const eventName = req.query.eventName as string | undefined; try { - // Get contact fields (standard + custom data fields) - const contactFields = await ContactService.getAvailableFields(auth.projectId!); + const result = await WorkflowService.getAvailableFields(auth.projectId!, eventName); - // 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, - }); + return res.status(200).json(result); } catch (error) { console.error('[WORKFLOWS] Failed to get available fields:', error); return res.status(500).json({ diff --git a/apps/api/src/controllers/__tests__/Workflows.test.ts b/apps/api/src/controllers/__tests__/Workflows.test.ts index e236d31..4398c5b 100644 --- a/apps/api/src/controllers/__tests__/Workflows.test.ts +++ b/apps/api/src/controllers/__tests__/Workflows.test.ts @@ -1,23 +1,17 @@ -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 {beforeEach, describe, expect, it} from 'vitest'; import {factories, getPrismaClient} from '../../../../../test/helpers'; import {EventService} from '../../services/EventService'; +import {WorkflowService} from '../../services/WorkflowService'; 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); }); // ======================================== @@ -42,28 +36,25 @@ describe('Workflows Controller', () => { isFirstOpen: true, }); - const response = await request(app) - .get('/workflows/fields') - .set('Authorization', `Bearer ${authToken}`) - .expect(200); + const responseBody = await WorkflowService.getAvailableFields(projectId); - expect(response.body.fields).toBeInstanceOf(Array); - expect(response.body.count).toBeGreaterThan(0); + expect(responseBody.fields).toBeInstanceOf(Array); + expect(responseBody.count).toBeGreaterThan(0); // Should include standard contact fields - expect(response.body.fields).toContain('contact.email'); - expect(response.body.fields).toContain('contact.subscribed'); + expect(responseBody.fields).toContain('contact.email'); + expect(responseBody.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'); + expect(responseBody.fields).toContain('data.firstName'); + expect(responseBody.fields).toContain('data.lastName'); + expect(responseBody.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'); + expect(responseBody.fields).toContain('event.subject'); + expect(responseBody.fields).toContain('event.from'); + expect(responseBody.fields).toContain('event.openedAt'); + expect(responseBody.fields).toContain('event.isFirstOpen'); }); it('should filter event fields by eventName query param', async () => { @@ -83,21 +74,16 @@ describe('Workflows Controller', () => { 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); + const responseBody = await WorkflowService.getAvailableFields(projectId, 'email.opened'); // 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'); + expect(responseBody.fields).toContain('event.subject'); + expect(responseBody.fields).toContain('event.openedAt'); + expect(responseBody.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'); + expect(responseBody.fields).not.toContain('event.link'); + expect(responseBody.fields).not.toContain('event.clickedAt'); }); it('should return fields in sorted order', async () => { @@ -114,12 +100,9 @@ describe('Workflows Controller', () => { aardvark: 'value', }); - const response = await request(app) - .get('/workflows/fields') - .set('Authorization', `Bearer ${authToken}`) - .expect(200); + const responseBody = await WorkflowService.getAvailableFields(projectId); - const fields = response.body.fields as string[]; + const fields = responseBody.fields as string[]; // Verify fields are sorted const sortedFields = [...fields].sort(); @@ -127,24 +110,17 @@ describe('Workflows Controller', () => { }); 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); + const responseBody = await WorkflowService.getAvailableFields(projectId); // Should still have contact fields - expect(response.body.fields).toContain('contact.email'); - expect(response.body.fields).toContain('contact.subscribed'); + expect(responseBody.fields).toContain('contact.email'); + expect(responseBody.fields).toContain('contact.subscribed'); // 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); }); - 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(); @@ -154,14 +130,10 @@ describe('Workflows Controller', () => { 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); + const responseBody = await WorkflowService.getAvailableFields(projectId); // 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 () => { @@ -171,13 +143,9 @@ describe('Workflows Controller', () => { field1: 'value', }); - const response = await request(app) - .get('/workflows/fields') - .query({eventName: 'email.opened'}) - .set('Authorization', `Bearer ${authToken}`) - .expect(200); + const responseBody = await WorkflowService.getAvailableFields(projectId, 'email.opened'); - expect(response.body.fields).toContain('event.field1'); + expect(responseBody.fields).toContain('event.field1'); }); }); @@ -201,19 +169,15 @@ describe('Workflows Controller', () => { sentAt: new Date().toISOString(), }); - const response = await request(app) - .get('/workflows/fields') - .query({eventName: 'email.sent'}) - .set('Authorization', `Bearer ${authToken}`) - .expect(200); + const responseBody = await WorkflowService.getAvailableFields(projectId, 'email.sent'); - 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'); + expect(responseBody.fields).toContain('event.subject'); + expect(responseBody.fields).toContain('event.from'); + expect(responseBody.fields).toContain('event.fromName'); + expect(responseBody.fields).toContain('event.messageId'); + expect(responseBody.fields).toContain('event.templateId'); + expect(responseBody.fields).toContain('event.sourceType'); + expect(responseBody.fields).toContain('event.sentAt'); }); it('should discover correct fields for email.opened events', async () => { @@ -230,15 +194,11 @@ describe('Workflows Controller', () => { isFirstOpen: true, }); - const response = await request(app) - .get('/workflows/fields') - .query({eventName: 'email.opened'}) - .set('Authorization', `Bearer ${authToken}`) - .expect(200); + const responseBody = await WorkflowService.getAvailableFields(projectId, 'email.opened'); - expect(response.body.fields).toContain('event.openedAt'); - expect(response.body.fields).toContain('event.opens'); - expect(response.body.fields).toContain('event.isFirstOpen'); + expect(responseBody.fields).toContain('event.openedAt'); + expect(responseBody.fields).toContain('event.opens'); + expect(responseBody.fields).toContain('event.isFirstOpen'); }); it('should discover correct fields for email.clicked events', async () => { @@ -254,16 +214,12 @@ describe('Workflows Controller', () => { isFirstClick: true, }); - const response = await request(app) - .get('/workflows/fields') - .query({eventName: 'email.clicked'}) - .set('Authorization', `Bearer ${authToken}`) - .expect(200); + const responseBody = await WorkflowService.getAvailableFields(projectId, 'email.clicked'); - 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'); + expect(responseBody.fields).toContain('event.link'); + expect(responseBody.fields).toContain('event.clickedAt'); + expect(responseBody.fields).toContain('event.clicks'); + expect(responseBody.fields).toContain('event.isFirstClick'); }); }); }); diff --git a/apps/api/src/services/WorkflowService.ts b/apps/api/src/services/WorkflowService.ts index fd50dcb..cb518b7 100644 --- a/apps/api/src/services/WorkflowService.ts +++ b/apps/api/src/services/WorkflowService.ts @@ -5,6 +5,7 @@ import {prisma} from '../database/prisma.js'; import {HttpException} from '../exceptions/index.js'; import {EventService} from './EventService.js'; +import {ContactService} from './ContactService.js'; export interface PaginatedWorkflows { 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, + }; + } }