From 4ceadd3f2bccb4c3deb3b829a428d200f217f63e Mon Sep 17 00:00:00 2001 From: Vlad Bisceanu <7993591+vladbisceanu@users.noreply.github.com> Date: Fri, 28 Aug 2026 00:14:32 +0000 Subject: [PATCH] fix(workflows): execute email steps from template relation --- ...rkflowExecutionService.integration.test.ts | 99 ++++++++++++++++++- packages/shared/src/schemas/index.ts | 4 +- packages/types/src/workflows/index.ts | 4 +- 3 files changed, 103 insertions(+), 4 deletions(-) diff --git a/apps/api/src/services/__tests__/WorkflowExecutionService.integration.test.ts b/apps/api/src/services/__tests__/WorkflowExecutionService.integration.test.ts index 5caedc15a..0e8dedcbd 100644 --- a/apps/api/src/services/__tests__/WorkflowExecutionService.integration.test.ts +++ b/apps/api/src/services/__tests__/WorkflowExecutionService.integration.test.ts @@ -1,7 +1,15 @@ import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {StepExecutionStatus, WorkflowExecutionStatus, WorkflowStepType} from '@plunk/db'; +import { + EmailSourceType, + StepExecutionStatus, + TemplateType, + WorkflowExecutionStatus, + WorkflowStepType, +} from '@plunk/db'; import {toPrismaJson} from '@plunk/types'; import {WorkflowExecutionService} from '../WorkflowExecutionService'; +import {QueueService} from '../QueueService'; +import {WorkflowService} from '../WorkflowService'; import {factories, getPrismaClient} from '../../../../../test/helpers'; vi.mock('node:dns/promises', () => ({ @@ -40,10 +48,99 @@ describe('WorkflowExecutionService - Integration Tests', () => { const prisma = getPrismaClient(); beforeEach(async () => { + vi.clearAllMocks(); const {project} = await factories.createUserWithProject(); projectId = project.id; }); + describe('Send email template binding', () => { + it('executes an API-created email step without a duplicate template ID in config', async () => { + const contact = await factories.createContact({projectId, subscribed: false}); + const template = await factories.createTemplate({ + projectId, + type: TemplateType.TRANSACTIONAL, + subject: 'Confirm your email', + }); + const workflow = await factories.createWorkflow({projectId}); + const triggerStep = await prisma.workflowStep.findFirstOrThrow({ + where: {workflowId: workflow.id, type: WorkflowStepType.TRIGGER}, + }); + const emailStep = await WorkflowService.addStep(projectId, workflow.id, { + type: WorkflowStepType.SEND_EMAIL, + name: 'Send confirmation', + position: {x: 100, y: 0}, + config: {recipient: {type: 'CONTACT'}}, + templateId: template.id, + }); + const execution = await factories.createWorkflowExecution(workflow.id, contact.id, { + status: WorkflowExecutionStatus.RUNNING, + }); + await prisma.workflowExecution.update({ + where: {id: execution.id}, + data: {currentStepId: triggerStep.id}, + }); + + await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id); + + const completed = await prisma.workflowExecution.findUniqueOrThrow({where: {id: execution.id}}); + const stepExecution = await prisma.workflowStepExecution.findFirstOrThrow({ + where: {executionId: execution.id, stepId: emailStep.id}, + }); + const email = await prisma.email.findFirstOrThrow({where: {workflowExecutionId: execution.id}}); + + expect(completed.status).toBe(WorkflowExecutionStatus.COMPLETED); + expect(stepExecution.status).toBe(StepExecutionStatus.COMPLETED); + expect(email).toMatchObject({ + contactId: contact.id, + templateId: template.id, + subject: template.subject, + sourceType: EmailSourceType.TRANSACTIONAL, + }); + expect(QueueService.queueEmail).toHaveBeenCalledWith(email.id, EmailSourceType.TRANSACTIONAL, undefined); + }); + + it('uses the template relation when legacy config contains a different template ID', async () => { + const contact = await factories.createContact({projectId, subscribed: false}); + const relationalTemplate = await factories.createTemplate({ + projectId, + type: TemplateType.TRANSACTIONAL, + subject: 'Relational template', + }); + const staleConfigTemplate = await factories.createTemplate({ + projectId, + type: TemplateType.TRANSACTIONAL, + subject: 'Stale config template', + }); + const workflow = await factories.createWorkflow({projectId}); + const triggerStep = await prisma.workflowStep.findFirstOrThrow({ + where: {workflowId: workflow.id, type: WorkflowStepType.TRIGGER}, + }); + await WorkflowService.addStep(projectId, workflow.id, { + type: WorkflowStepType.SEND_EMAIL, + name: 'Send confirmation', + position: {x: 100, y: 0}, + config: { + templateId: staleConfigTemplate.id, + recipient: {type: 'CONTACT'}, + }, + templateId: relationalTemplate.id, + }); + const execution = await factories.createWorkflowExecution(workflow.id, contact.id, { + status: WorkflowExecutionStatus.RUNNING, + }); + await prisma.workflowExecution.update({ + where: {id: execution.id}, + data: {currentStepId: triggerStep.id}, + }); + + await WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id); + + const email = await prisma.email.findFirstOrThrow({where: {workflowExecutionId: execution.id}}); + expect(email.templateId).toBe(relationalTemplate.id); + expect(email.subject).toBe(relationalTemplate.subject); + }); + }); + // ======================================== // CONDITIONAL BRANCHING (CONDITION STEPS) // ======================================== diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index 0087d8ffa..59e089c93 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -283,7 +283,9 @@ export const WorkflowSchemas = { export const WorkflowStepConfigSchemas = { sendEmail: z.object({ - templateId: uuid, + // The WorkflowStep.template relation is authoritative. This deprecated + // JSON copy is accepted when present for compatibility with older clients. + templateId: uuid.optional(), recipient: z .object({ type: z.enum(['CONTACT', 'CUSTOM']), diff --git a/packages/types/src/workflows/index.ts b/packages/types/src/workflows/index.ts index aafe82ead..dbe8146e5 100644 --- a/packages/types/src/workflows/index.ts +++ b/packages/types/src/workflows/index.ts @@ -26,8 +26,8 @@ export interface EmailRecipientConfig { * Configuration for SEND_EMAIL workflow step */ export interface SendEmailStepConfig { - /** Template ID to use for the email */ - templateId: string; + /** @deprecated The WorkflowStep.template relation is authoritative. */ + templateId?: string; /** Recipient configuration */ recipient?: EmailRecipientConfig; }