diff --git a/apps/api/src/services/WorkflowExecutionService.ts b/apps/api/src/services/WorkflowExecutionService.ts index a418c81..ca34026 100644 --- a/apps/api/src/services/WorkflowExecutionService.ts +++ b/apps/api/src/services/WorkflowExecutionService.ts @@ -979,7 +979,7 @@ export class WorkflowExecutionService { _stepExecution: WorkflowStepExecution, config: StepConfig, ): Promise { - const {updates} = WorkflowStepConfigSchemas.updateContact.parse(config); + const {updates, subscriptionAction} = WorkflowStepConfigSchemas.updateContact.parse(config); const contact = execution.contact; const currentData = @@ -987,24 +987,43 @@ export class WorkflowExecutionService { ? (contact.data as Record) : {}; - // Merge updates with current data - const newData = { - ...currentData, - ...updates, - }; + const hasDataUpdates = updates && Object.keys(updates).length > 0; + const newData = hasDataUpdates ? {...currentData, ...updates} : currentData; - // Update contact in database - await prisma.contact.update({ - where: {id: contact.id}, - data: { - data: newData ? toPrismaJson(newData) : undefined, - }, - }); + const desiredSubscribed = + subscriptionAction === 'subscribe' ? true : subscriptionAction === 'unsubscribe' ? false : undefined; + const subscriptionChanging = desiredSubscribed !== undefined && desiredSubscribed !== contact.subscribed; + + const updateData: Prisma.ContactUpdateInput = {}; + if (hasDataUpdates) { + updateData.data = toPrismaJson(newData); + } + if (subscriptionChanging) { + updateData.subscribed = desiredSubscribed; + } + + if (Object.keys(updateData).length > 0) { + await prisma.contact.update({ + where: {id: contact.id}, + data: updateData, + }); + } + + if (subscriptionChanging) { + const {EventService} = await import('./EventService.js'); + await EventService.trackEvent( + execution.workflow.projectId, + desiredSubscribed ? 'contact.subscribed' : 'contact.unsubscribed', + contact.id, + ); + } return { - updated: true, + updated: hasDataUpdates || subscriptionChanging, updates, newData, + subscriptionAction, + subscribed: desiredSubscribed ?? contact.subscribed, }; } diff --git a/apps/web/src/components/workflow-steps/UpdateContactStepDialog.tsx b/apps/web/src/components/workflow-steps/UpdateContactStepDialog.tsx index 897efde..f2559d4 100644 --- a/apps/web/src/components/workflow-steps/UpdateContactStepDialog.tsx +++ b/apps/web/src/components/workflow-steps/UpdateContactStepDialog.tsx @@ -1,3 +1,4 @@ +import {Label, RadioGroup, RadioGroupItem} from '@plunk/ui'; import {useState} from 'react'; import {toast} from 'sonner'; @@ -5,31 +6,50 @@ import {KeyValueEditor} from '../KeyValueEditor'; import {type EditStepDialogProps, getStepConfig, StepDialogShell, useStepUpdate} from './shared'; +type SubscriptionAction = 'none' | 'subscribe' | 'unsubscribe'; + +const SUBSCRIPTION_OPTIONS: Array<{value: SubscriptionAction; label: string; description: string}> = [ + {value: 'none', label: 'Leave as is', description: "Don't change the contact's subscription state."}, + {value: 'subscribe', label: 'Subscribe', description: 'Mark the contact as subscribed.'}, + {value: 'unsubscribe', label: 'Unsubscribe', description: 'Mark the contact as unsubscribed.'}, +]; + export function UpdateContactStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditStepDialogProps) { const config = getStepConfig(step); const initialUpdates = config.updates && typeof config.updates === 'object' ? (config.updates as Record) : null; + const initialSubscriptionAction: SubscriptionAction = + config.subscriptionAction === 'subscribe' || config.subscriptionAction === 'unsubscribe' + ? config.subscriptionAction + : 'none'; const [name, setName] = useState(step.name); const [contactUpdateData, setContactUpdateData] = useState | null>( initialUpdates, ); + const [subscriptionAction, setSubscriptionAction] = useState(initialSubscriptionAction); const {update, isSubmitting} = useStepUpdate(workflowId, step.id); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!contactUpdateData || Object.keys(contactUpdateData).length === 0) { - toast.error('At least one field to update is required'); + const hasUpdates = contactUpdateData && Object.keys(contactUpdateData).length > 0; + const hasSubscriptionAction = subscriptionAction !== 'none'; + + if (!hasUpdates && !hasSubscriptionAction) { + toast.error('Add at least one field to update or choose a subscription action'); return; } const ok = await update({ name, - config: {updates: contactUpdateData}, + config: { + updates: hasUpdates ? contactUpdateData : {}, + subscriptionAction, + }, }); if (ok) { @@ -48,7 +68,32 @@ export function UpdateContactStepDialog({step, workflowId, open, onOpenChange, o onSubmit={handleSubmit} isSubmitting={isSubmitting} > - +
+ + setSubscriptionAction(value as SubscriptionAction)} + className="gap-2" + > + {SUBSCRIPTION_OPTIONS.map(option => ( + + ))} + +
+ +
+ +
); } diff --git a/apps/web/src/pages/workflows/[id].tsx b/apps/web/src/pages/workflows/[id].tsx index 5698e93..fd970f2 100644 --- a/apps/web/src/pages/workflows/[id].tsx +++ b/apps/web/src/pages/workflows/[id].tsx @@ -206,11 +206,18 @@ export default function WorkflowEditorPage() { } break; - case 'UPDATE_CONTACT': - if (!config.updates || (typeof config.updates === 'object' && Object.keys(config.updates).length === 0)) { - errors.push(`"${step.name}" step is missing contact updates`); + case 'UPDATE_CONTACT': { + const hasUpdates = + config.updates && typeof config.updates === 'object' && Object.keys(config.updates).length > 0; + const hasSubscriptionAction = + typeof config.subscriptionAction === 'string' && + config.subscriptionAction !== 'none' && + config.subscriptionAction !== ''; + if (!hasUpdates && !hasSubscriptionAction) { + errors.push(`"${step.name}" step is missing contact updates or a subscription action`); } break; + } } }); diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index f9ec863..bbe2cb1 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -334,9 +334,17 @@ export const WorkflowStepConfigSchemas = { headers: z.record(z.string()).optional(), body: jsonSchema.optional(), }), - updateContact: z.object({ - updates: z.record(z.any()), - }), + updateContact: z + .object({ + updates: z.record(z.any()).optional(), + subscriptionAction: z.enum(['none', 'subscribe', 'unsubscribe']).optional(), + }) + .refine( + value => + (value.updates && Object.keys(value.updates).length > 0) || + (value.subscriptionAction && value.subscriptionAction !== 'none'), + {message: 'Provide at least one field to update or a subscription action'}, + ), }; export const DomainSchemas = {