feat: Ability to change subscription status in workflows
This commit is contained in:
@@ -979,7 +979,7 @@ export class WorkflowExecutionService {
|
|||||||
_stepExecution: WorkflowStepExecution,
|
_stepExecution: WorkflowStepExecution,
|
||||||
config: StepConfig,
|
config: StepConfig,
|
||||||
): Promise<StepResult> {
|
): Promise<StepResult> {
|
||||||
const {updates} = WorkflowStepConfigSchemas.updateContact.parse(config);
|
const {updates, subscriptionAction} = WorkflowStepConfigSchemas.updateContact.parse(config);
|
||||||
|
|
||||||
const contact = execution.contact;
|
const contact = execution.contact;
|
||||||
const currentData =
|
const currentData =
|
||||||
@@ -987,24 +987,43 @@ export class WorkflowExecutionService {
|
|||||||
? (contact.data as Record<string, unknown>)
|
? (contact.data as Record<string, unknown>)
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
// Merge updates with current data
|
const hasDataUpdates = updates && Object.keys(updates).length > 0;
|
||||||
const newData = {
|
const newData = hasDataUpdates ? {...currentData, ...updates} : currentData;
|
||||||
...currentData,
|
|
||||||
...updates,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update contact in database
|
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({
|
await prisma.contact.update({
|
||||||
where: {id: contact.id},
|
where: {id: contact.id},
|
||||||
data: {
|
data: updateData,
|
||||||
data: newData ? toPrismaJson(newData) : undefined,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscriptionChanging) {
|
||||||
|
const {EventService} = await import('./EventService.js');
|
||||||
|
await EventService.trackEvent(
|
||||||
|
execution.workflow.projectId,
|
||||||
|
desiredSubscribed ? 'contact.subscribed' : 'contact.unsubscribed',
|
||||||
|
contact.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
updated: true,
|
updated: hasDataUpdates || subscriptionChanging,
|
||||||
updates,
|
updates,
|
||||||
newData,
|
newData,
|
||||||
|
subscriptionAction,
|
||||||
|
subscribed: desiredSubscribed ?? contact.subscribed,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import {Label, RadioGroup, RadioGroupItem} from '@plunk/ui';
|
||||||
import {useState} from 'react';
|
import {useState} from 'react';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
|
|
||||||
@@ -5,31 +6,50 @@ import {KeyValueEditor} from '../KeyValueEditor';
|
|||||||
|
|
||||||
import {type EditStepDialogProps, getStepConfig, StepDialogShell, useStepUpdate} from './shared';
|
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) {
|
export function UpdateContactStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditStepDialogProps) {
|
||||||
const config = getStepConfig(step);
|
const config = getStepConfig(step);
|
||||||
const initialUpdates =
|
const initialUpdates =
|
||||||
config.updates && typeof config.updates === 'object'
|
config.updates && typeof config.updates === 'object'
|
||||||
? (config.updates as Record<string, string | number | boolean>)
|
? (config.updates as Record<string, string | number | boolean>)
|
||||||
: null;
|
: null;
|
||||||
|
const initialSubscriptionAction: SubscriptionAction =
|
||||||
|
config.subscriptionAction === 'subscribe' || config.subscriptionAction === 'unsubscribe'
|
||||||
|
? config.subscriptionAction
|
||||||
|
: 'none';
|
||||||
|
|
||||||
const [name, setName] = useState(step.name);
|
const [name, setName] = useState(step.name);
|
||||||
const [contactUpdateData, setContactUpdateData] = useState<Record<string, string | number | boolean> | null>(
|
const [contactUpdateData, setContactUpdateData] = useState<Record<string, string | number | boolean> | null>(
|
||||||
initialUpdates,
|
initialUpdates,
|
||||||
);
|
);
|
||||||
|
const [subscriptionAction, setSubscriptionAction] = useState<SubscriptionAction>(initialSubscriptionAction);
|
||||||
|
|
||||||
const {update, isSubmitting} = useStepUpdate(workflowId, step.id);
|
const {update, isSubmitting} = useStepUpdate(workflowId, step.id);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!contactUpdateData || Object.keys(contactUpdateData).length === 0) {
|
const hasUpdates = contactUpdateData && Object.keys(contactUpdateData).length > 0;
|
||||||
toast.error('At least one field to update is required');
|
const hasSubscriptionAction = subscriptionAction !== 'none';
|
||||||
|
|
||||||
|
if (!hasUpdates && !hasSubscriptionAction) {
|
||||||
|
toast.error('Add at least one field to update or choose a subscription action');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ok = await update({
|
const ok = await update({
|
||||||
name,
|
name,
|
||||||
config: {updates: contactUpdateData},
|
config: {
|
||||||
|
updates: hasUpdates ? contactUpdateData : {},
|
||||||
|
subscriptionAction,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (ok) {
|
if (ok) {
|
||||||
@@ -48,7 +68,32 @@ export function UpdateContactStepDialog({step, workflowId, open, onOpenChange, o
|
|||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
isSubmitting={isSubmitting}
|
isSubmitting={isSubmitting}
|
||||||
>
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Subscription state</Label>
|
||||||
|
<RadioGroup
|
||||||
|
value={subscriptionAction}
|
||||||
|
onValueChange={value => setSubscriptionAction(value as SubscriptionAction)}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{SUBSCRIPTION_OPTIONS.map(option => (
|
||||||
|
<label
|
||||||
|
key={option.value}
|
||||||
|
htmlFor={`subscriptionAction-${option.value}`}
|
||||||
|
className="flex items-start gap-3 rounded-md border border-neutral-200 p-3 cursor-pointer hover:bg-neutral-50"
|
||||||
|
>
|
||||||
|
<RadioGroupItem id={`subscriptionAction-${option.value}`} value={option.value} className="mt-0.5" />
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="text-sm font-medium text-neutral-900">{option.label}</div>
|
||||||
|
<div className="text-xs text-neutral-500">{option.description}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
<KeyValueEditor key={`edit-${step.id}`} initialData={contactUpdateData} onChange={setContactUpdateData} />
|
<KeyValueEditor key={`edit-${step.id}`} initialData={contactUpdateData} onChange={setContactUpdateData} />
|
||||||
|
</div>
|
||||||
</StepDialogShell>
|
</StepDialogShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,12 +206,19 @@ export default function WorkflowEditorPage() {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'UPDATE_CONTACT':
|
case 'UPDATE_CONTACT': {
|
||||||
if (!config.updates || (typeof config.updates === 'object' && Object.keys(config.updates).length === 0)) {
|
const hasUpdates =
|
||||||
errors.push(`"${step.name}" step is missing contact updates`);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check for orphaned steps (steps with no incoming or outgoing transitions, except TRIGGER and EXIT)
|
// Check for orphaned steps (steps with no incoming or outgoing transitions, except TRIGGER and EXIT)
|
||||||
|
|||||||
@@ -334,9 +334,17 @@ export const WorkflowStepConfigSchemas = {
|
|||||||
headers: z.record(z.string()).optional(),
|
headers: z.record(z.string()).optional(),
|
||||||
body: jsonSchema.optional(),
|
body: jsonSchema.optional(),
|
||||||
}),
|
}),
|
||||||
updateContact: z.object({
|
updateContact: z
|
||||||
updates: z.record(z.any()),
|
.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 = {
|
export const DomainSchemas = {
|
||||||
|
|||||||
Reference in New Issue
Block a user