Merge pull request #240 from ArnasDon/feat/custom-fields-update-contact-action

feat(automations): support custom contact fields in Update Contact Field action
This commit is contained in:
Arnas Donauskas 2026-06-11 15:33:28 +03:00 committed by GitHub
commit 91e6375e61
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 486 additions and 15 deletions

View File

@ -40,10 +40,12 @@ import {
Users, Users,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
SlidersHorizontal,
} from 'lucide-react'; } from 'lucide-react';
import { ContactForm } from '@/components/contacts/contact-form'; import { ContactForm } from '@/components/contacts/contact-form';
import { ContactDetailView } from '@/components/contacts/contact-detail-view'; import { ContactDetailView } from '@/components/contacts/contact-detail-view';
import { ImportModal } from '@/components/contacts/import-modal'; import { ImportModal } from '@/components/contacts/import-modal';
import { CustomFieldsManager } from '@/components/contacts/custom-fields-manager';
import { useCan } from '@/hooks/use-can'; import { useCan } from '@/hooks/use-can';
import { GatedButton } from '@/components/ui/gated-button'; import { GatedButton } from '@/components/ui/gated-button';
@ -56,6 +58,7 @@ interface ContactWithTags extends Contact {
export default function ContactsPage() { export default function ContactsPage() {
const supabase = createClient(); const supabase = createClient();
const canEdit = useCan('send-messages'); const canEdit = useCan('send-messages');
const canEditSettings = useCan('edit-settings');
const [contacts, setContacts] = useState<ContactWithTags[]>([]); const [contacts, setContacts] = useState<ContactWithTags[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -70,6 +73,7 @@ export default function ContactsPage() {
const [detailOpen, setDetailOpen] = useState(false); const [detailOpen, setDetailOpen] = useState(false);
const [detailContactId, setDetailContactId] = useState<string | null>(null); const [detailContactId, setDetailContactId] = useState<string | null>(null);
const [importOpen, setImportOpen] = useState(false); const [importOpen, setImportOpen] = useState(false);
const [customFieldsOpen, setCustomFieldsOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<Contact | null>(null); const [deleteTarget, setDeleteTarget] = useState<Contact | null>(null);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
@ -219,6 +223,16 @@ export default function ContactsPage() {
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{canEditSettings && (
<Button
variant="outline"
onClick={() => setCustomFieldsOpen(true)}
className="border-slate-700 text-slate-300 hover:bg-slate-800"
>
<SlidersHorizontal className="size-4" />
Custom fields
</Button>
)}
<GatedButton <GatedButton
variant="outline" variant="outline"
canAct={canEdit} canAct={canEdit}
@ -467,6 +481,14 @@ export default function ContactsPage() {
onImported={fetchContacts} onImported={fetchContacts}
/> />
{/* Custom Fields Manager (admin+) */}
{canEditSettings && (
<CustomFieldsManager
open={customFieldsOpen}
onOpenChange={setCustomFieldsOpen}
/>
)}
{/* Delete Confirmation */} {/* Delete Confirmation */}
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}> <Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<DialogContent className="bg-slate-900 border-slate-700 text-slate-200 sm:max-w-sm"> <DialogContent className="bg-slate-900 border-slate-700 text-slate-200 sm:max-w-sm">

View File

@ -46,6 +46,7 @@ import type {
AccountMember, AccountMember,
AutomationStepType, AutomationStepType,
AutomationTriggerType, AutomationTriggerType,
CustomField,
KeywordMatchTriggerConfig, KeywordMatchTriggerConfig,
MessageTemplate, MessageTemplate,
Tag as TagRecord, Tag as TagRecord,
@ -179,12 +180,14 @@ interface AutomationResources {
tags: TagRecord[] tags: TagRecord[]
members: AccountMember[] members: AccountMember[]
templates: MessageTemplate[] templates: MessageTemplate[]
customFields: CustomField[]
} }
const ResourcesContext = createContext<AutomationResources>({ const ResourcesContext = createContext<AutomationResources>({
tags: [], tags: [],
members: [], members: [],
templates: [], templates: [],
customFields: [],
}) })
function useResources(): AutomationResources { function useResources(): AutomationResources {
@ -195,27 +198,30 @@ function ResourcesProvider({ children }: { children: ReactNode }) {
const [tags, setTags] = useState<TagRecord[]>([]) const [tags, setTags] = useState<TagRecord[]>([])
const [members, setMembers] = useState<AccountMember[]>([]) const [members, setMembers] = useState<AccountMember[]>([])
const [templates, setTemplates] = useState<MessageTemplate[]>([]) const [templates, setTemplates] = useState<MessageTemplate[]>([])
const [customFields, setCustomFields] = useState<CustomField[]>([])
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
const supabase = createClient() const supabase = createClient()
// Tags and templates come straight from the DB — RLS scopes both // Tags, templates and custom fields come straight from the DB — RLS
// to the caller's account. Only APPROVED templates can actually be // scopes them to the caller's account. Only APPROVED templates can
// sent (anything else 400s at send time), matching the broadcast // actually be sent (anything else 400s at send time), matching the
// picker. // broadcast picker.
void (async () => { void (async () => {
const [tagsRes, templatesRes] = await Promise.all([ const [tagsRes, templatesRes, customFieldsRes] = await Promise.all([
supabase.from("tags").select("*").order("name"), supabase.from("tags").select("*").order("name"),
supabase supabase
.from("message_templates") .from("message_templates")
.select("*") .select("*")
.eq("status", "APPROVED") .eq("status", "APPROVED")
.order("name"), .order("name"),
supabase.from("custom_fields").select("*").order("field_name"),
]) ])
if (cancelled) return if (cancelled) return
setTags((tagsRes.data as TagRecord[] | null) ?? []) setTags((tagsRes.data as TagRecord[] | null) ?? [])
setTemplates((templatesRes.data as MessageTemplate[] | null) ?? []) setTemplates((templatesRes.data as MessageTemplate[] | null) ?? [])
setCustomFields((customFieldsRes.data as CustomField[] | null) ?? [])
})() })()
// Members go through the API so we inherit its email-visibility // Members go through the API so we inherit its email-visibility
@ -238,7 +244,7 @@ function ResourcesProvider({ children }: { children: ReactNode }) {
}, []) }, [])
return ( return (
<ResourcesContext.Provider value={{ tags, members, templates }}> <ResourcesContext.Provider value={{ tags, members, templates, customFields }}>
{children} {children}
</ResourcesContext.Provider> </ResourcesContext.Provider>
) )
@ -296,6 +302,46 @@ function TagSelect({
) )
} }
/** Contact-field dropdown for "Update Contact Field": built-in columns plus
* any account custom fields (stored as `custom:<id>`). A saved custom field
* that's since been deleted is preserved as a labelled option so editing an
* existing automation doesn't silently drop it. */
function ContactFieldSelect({
value,
onChange,
}: {
value: string
onChange: (v: string) => void
}) {
const { customFields } = useResources()
const customValue = value.startsWith("custom:") ? value : ""
const knownCustom =
customValue && customFields.some((f) => `custom:${f.id}` === customValue)
return (
<select
value={value || "name"}
onChange={(e) => onChange(e.target.value)}
className={SELECT_CLASS}
>
<option value="name">Name</option>
<option value="email">Email</option>
<option value="company">Company</option>
{customFields.length > 0 && (
<optgroup label="Custom fields">
{customFields.map((f) => (
<option key={f.id} value={`custom:${f.id}`}>
{f.field_name}
</option>
))}
</optgroup>
)}
{customValue && !knownCustom && (
<option value={customValue}>{customValue} (unknown field)</option>
)}
</select>
)
}
/** Agent dropdown by name, storing the member's user_id. Falls back to /** Agent dropdown by name, storing the member's user_id. Falls back to
* a raw id input when the member list is unavailable. */ * a raw id input when the member list is unavailable. */
function AgentSelect({ function AgentSelect({
@ -1044,20 +1090,16 @@ function StepEditor({
return ( return (
<> <>
<FieldBlock label="Field"> <FieldBlock label="Field">
<select <ContactFieldSelect
value={(cfg.field as string) ?? "name"} value={(cfg.field as string) ?? "name"}
onChange={(e) => set({ field: e.target.value })} onChange={(v) => set({ field: v })}
className="w-full rounded-md border border-slate-700 bg-slate-800 px-2 py-1.5 text-sm text-white" />
>
<option value="name">Name</option>
<option value="email">Email</option>
<option value="company">Company</option>
</select>
</FieldBlock> </FieldBlock>
<FieldBlock label="Value"> <FieldBlock label="Value">
<Input <Input
value={(cfg.value as string) ?? ""} value={(cfg.value as string) ?? ""}
onChange={(e) => set({ value: e.target.value })} onChange={(e) => set({ value: e.target.value })}
placeholder="Text or {{ vars.x }} / {{ message.text }}"
className="bg-slate-800 text-white" className="bg-slate-800 text-white"
/> />
</FieldBlock> </FieldBlock>

View File

@ -0,0 +1,275 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { createClient } from '@/lib/supabase/client';
import { useAuth } from '@/hooks/use-auth';
import { toast } from 'sonner';
import type { CustomField } from '@/types';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Loader2, Plus, Trash2 } from 'lucide-react';
interface CustomFieldsManagerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
/**
* Create / rename / delete account-wide custom contact field definitions.
* Per-contact values are edited elsewhere (contact detail → Custom Fields);
* this only manages the field catalogue. Admin+ gated by the caller — the
* `custom_fields` RLS also rejects non-admin writes as defense in depth.
*/
export function CustomFieldsManager({
open,
onOpenChange,
}: CustomFieldsManagerProps) {
const supabase = createClient();
const { user, accountId } = useAuth();
const [fields, setFields] = useState<CustomField[]>([]);
const [loading, setLoading] = useState(true);
const [newName, setNewName] = useState('');
const [creating, setCreating] = useState(false);
const [busyId, setBusyId] = useState<string | null>(null);
const fetchFields = useCallback(async () => {
if (!accountId) return;
setLoading(true);
const { data } = await supabase
.from('custom_fields')
.select('*')
.order('field_name');
setFields((data as CustomField[] | null) ?? []);
setLoading(false);
}, [supabase, accountId]);
// Load the field list when the dialog opens. The setters inside
// fetchFields run after the Supabase await — not synchronously in the
// effect body — so the cascade the lint rule warns about doesn't apply.
useEffect(() => {
if (open && accountId) {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchFields();
}
}, [open, accountId, fetchFields]);
// Clear the draft on close (handler, not effect) so reopening starts fresh.
function handleOpenChange(next: boolean) {
if (!next) setNewName('');
onOpenChange(next);
}
/** Case-insensitive name clash within the loaded list. */
function isDuplicate(name: string, exceptId?: string): boolean {
const lower = name.toLowerCase();
return fields.some(
(f) => f.id !== exceptId && f.field_name.toLowerCase() === lower,
);
}
async function handleCreate() {
const name = newName.trim();
if (!name) return;
if (!accountId || !user) {
toast.error('Your profile is not linked to an account.');
return;
}
if (isDuplicate(name)) {
toast.error(`A field named "${name}" already exists.`);
return;
}
setCreating(true);
const { error } = await supabase.from('custom_fields').insert({
field_name: name,
field_type: 'text',
user_id: user.id,
account_id: accountId,
});
setCreating(false);
if (error) {
toast.error('Could not create field. You may not have permission.');
return;
}
toast.success(`Created "${name}".`);
setNewName('');
await fetchFields();
}
/** Returns true on success so the row can keep the new name, false so it
* reverts to the previous one. No-ops (blank / unchanged) count as success. */
async function handleRename(field: CustomField, nextName: string): Promise<boolean> {
const name = nextName.trim();
if (!name || name === field.field_name) return true;
if (isDuplicate(name, field.id)) {
toast.error(`A field named "${name}" already exists.`);
return false;
}
setBusyId(field.id);
const { error } = await supabase
.from('custom_fields')
.update({ field_name: name })
.eq('id', field.id);
setBusyId(null);
if (error) {
toast.error('Could not rename field.');
return false;
}
await fetchFields();
return true;
}
async function handleDelete(field: CustomField) {
if (
!window.confirm(
`Delete "${field.field_name}"? This also removes its stored value on every contact. This cannot be undone.`,
)
) {
return;
}
setBusyId(field.id);
const { error } = await supabase
.from('custom_fields')
.delete()
.eq('id', field.id);
setBusyId(null);
if (error) {
toast.error('Could not delete field.');
return;
}
toast.success(`Deleted "${field.field_name}".`);
await fetchFields();
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="bg-slate-900 border-slate-700 text-slate-200 sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-white">Custom fields</DialogTitle>
<DialogDescription className="text-slate-400">
Define extra contact fields (e.g. ZIP code, lead source). They appear
on every contact and in the “Update Contact Field” automation action.
</DialogDescription>
</DialogHeader>
{/* Create */}
<div className="flex items-center gap-2">
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void handleCreate();
}
}}
placeholder="New field name…"
className="bg-slate-800 text-white"
/>
<Button
onClick={handleCreate}
disabled={creating || !newName.trim()}
className="bg-primary hover:bg-primary/90 text-primary-foreground shrink-0"
>
{creating ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Plus className="size-4" />
)}
Add
</Button>
</div>
{/* List */}
<div className="max-h-72 overflow-y-auto rounded-md border border-slate-800">
{loading ? (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-slate-500">
<Loader2 className="size-4 animate-spin" />
Loading…
</div>
) : fields.length === 0 ? (
<p className="py-8 text-center text-sm text-slate-500">
No custom fields yet.
</p>
) : (
<ul className="divide-y divide-slate-800">
{fields.map((field) => (
<FieldRow
key={field.id}
field={field}
busy={busyId === field.id}
onRename={handleRename}
onDelete={handleDelete}
/>
))}
</ul>
)}
</div>
</DialogContent>
</Dialog>
);
}
/** A single editable row. Controlled local state lets us commit on blur /
* Enter and cleanly revert to the last saved name when a rename fails. */
function FieldRow({
field,
busy,
onRename,
onDelete,
}: {
field: CustomField;
busy: boolean;
onRename: (field: CustomField, name: string) => Promise<boolean>;
onDelete: (field: CustomField) => void;
}) {
const [name, setName] = useState(field.field_name);
async function commit() {
if (name.trim() === field.field_name) {
setName(field.field_name); // normalise any whitespace-only edit
return;
}
const ok = await onRename(field, name);
if (!ok) setName(field.field_name);
}
return (
<li className="flex items-center gap-2 px-3 py-2">
<Input
value={name}
disabled={busy}
onChange={(e) => setName(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur();
}}
aria-label={`Rename ${field.field_name}`}
className="h-8 border-transparent bg-transparent text-white hover:border-slate-700 focus:border-primary"
/>
<Button
variant="ghost"
size="icon-sm"
disabled={busy}
onClick={() => onDelete(field)}
title="Delete field"
className="shrink-0 text-slate-400 hover:text-red-400"
>
{busy ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Trash2 className="size-4" />
)}
</Button>
</li>
);
}

View File

@ -5,10 +5,12 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
const h = vi.hoisted(() => ({ const h = vi.hoisted(() => ({
state: { state: {
owned: null as { id: string } | null, owned: null as { id: string } | null,
ownedCustomField: null as { id: string } | null,
automations: [] as Record<string, unknown>[], automations: [] as Record<string, unknown>[],
steps: [] as Record<string, unknown>[], steps: [] as Record<string, unknown>[],
fromCalls: [] as string[], fromCalls: [] as string[],
updateCalls: [] as { table: string; filters: [string, string, unknown][] }[], updateCalls: [] as { table: string; filters: [string, string, unknown][] }[],
upsertCalls: [] as { table: string; payload: unknown }[],
}, },
})); }));
@ -18,6 +20,7 @@ vi.mock("./admin-client", () => {
function resolve(ops: { function resolve(ops: {
table: string; table: string;
type: string; type: string;
payload?: unknown;
filters: [string, string, unknown][]; filters: [string, string, unknown][];
}) { }) {
const { table, type } = ops; const { table, type } = ops;
@ -29,6 +32,17 @@ vi.mock("./admin-client", () => {
// ownership guard / condition read // ownership guard / condition read
return { data: state.owned, error: null }; return { data: state.owned, error: null };
} }
if (table === "custom_fields") {
// account-scoped ownership lookup for a custom field definition
return { data: state.ownedCustomField, error: null };
}
if (table === "contact_custom_values") {
if (type === "upsert") {
state.upsertCalls.push({ table, payload: ops.payload });
return { data: null, error: null };
}
return { data: null, error: null };
}
if (table === "automations") return { data: state.automations, error: null }; if (table === "automations") return { data: state.automations, error: null };
if (table === "automation_logs") { if (table === "automation_logs") {
if (type === "insert") return { data: { id: "log1" }, error: null }; if (type === "insert") return { data: { id: "log1" }, error: null };
@ -87,10 +101,12 @@ const ACCOUNT = "acct-1";
beforeEach(() => { beforeEach(() => {
h.state.owned = null; h.state.owned = null;
h.state.ownedCustomField = null;
h.state.automations = []; h.state.automations = [];
h.state.steps = []; h.state.steps = [];
h.state.fromCalls = []; h.state.fromCalls = [];
h.state.updateCalls = []; h.state.updateCalls = [];
h.state.upsertCalls = [];
}); });
describe("runAutomationsForTrigger — tenant isolation", () => { describe("runAutomationsForTrigger — tenant isolation", () => {
@ -147,6 +163,67 @@ describe("runAutomationsForTrigger — tenant isolation", () => {
}); });
}); });
describe("update_contact_field — custom fields", () => {
it("upserts contact_custom_values when the field is account-owned", async () => {
h.state.owned = { id: "c1" };
h.state.ownedCustomField = { id: "cf1" };
h.state.automations = [automationWithUpdateStep()];
h.state.steps = [customStep("custom:cf1", "Premium")];
await runAutomationsForTrigger({
accountId: ACCOUNT,
triggerType: "new_message_received",
contactId: "c1",
context: {},
});
// No direct contacts column write for a custom field.
expect(h.state.updateCalls).toHaveLength(0);
expect(h.state.upsertCalls).toHaveLength(1);
expect(h.state.upsertCalls[0].payload).toEqual({
contact_id: "c1",
custom_field_id: "cf1",
value: "Premium",
});
});
it("interpolates {{ vars.* }} into the custom value", async () => {
h.state.owned = { id: "c1" };
h.state.ownedCustomField = { id: "cf1" };
h.state.automations = [automationWithUpdateStep()];
h.state.steps = [customStep("custom:cf1", "{{ vars.source }}")];
await runAutomationsForTrigger({
accountId: ACCOUNT,
triggerType: "new_message_received",
contactId: "c1",
context: { vars: { source: "WhatsApp Ad" } },
});
expect(h.state.upsertCalls).toHaveLength(1);
expect(
(h.state.upsertCalls[0].payload as { value: string }).value,
).toBe("WhatsApp Ad");
});
it("refuses to write a custom field from another account", async () => {
h.state.owned = { id: "c1" };
h.state.ownedCustomField = null; // account-scoped lookup finds nothing
h.state.automations = [automationWithUpdateStep()];
h.state.steps = [customStep("custom:foreign-cf", "x")];
await runAutomationsForTrigger({
accountId: ACCOUNT,
triggerType: "new_message_received",
contactId: "c1",
context: {},
});
expect(h.state.upsertCalls).toHaveLength(0);
expect(h.state.updateCalls).toHaveLength(0);
});
});
function automationWithUpdateStep() { function automationWithUpdateStep() {
return { return {
id: "a1", id: "a1",
@ -168,3 +245,14 @@ function updateStep() {
step_config: { field: "company", value: "pwned-by-automation" }, step_config: { field: "company", value: "pwned-by-automation" },
}; };
} }
function customStep(field: string, value: string) {
return {
id: "s1",
automation_id: "a1",
step_type: "update_contact_field",
position: 0,
parent_step_id: null,
step_config: { field, value },
};
}

View File

@ -447,6 +447,40 @@ async function runStep(step: AutomationStep, args: ExecuteArgs): Promise<string>
case 'update_contact_field': { case 'update_contact_field': {
const cfg = step.step_config as UpdateContactFieldStepConfig const cfg = step.step_config as UpdateContactFieldStepConfig
if (!args.contactId) throw new Error('update_contact_field needs a contact') if (!args.contactId) throw new Error('update_contact_field needs a contact')
// Resolve workflow variables ({{ vars.* }}, {{ message.text }}) so custom
// values can be populated dynamically from the triggering context.
const value = interpolate(cfg.value, args)
// Custom fields are encoded as `custom:<custom_field_id>`; anything else
// is a built-in contact column.
if (cfg.field.startsWith('custom:')) {
const customFieldId = cfg.field.slice('custom:'.length)
if (!customFieldId) {
return `field ${cfg.field} not writable from automations`
}
// Defense in depth: the service-role client bypasses RLS, so confirm
// the field definition belongs to this account before writing.
const { data: field } = await db
.from('custom_fields')
.select('id')
.eq('id', customFieldId)
.eq('account_id', args.automation.account_id)
.maybeSingle()
if (!field) {
return `field ${cfg.field} not writable from automations`
}
// Upsert on the table's UNIQUE(contact_id, custom_field_id) so repeated
// runs overwrite rather than duplicate. Tenancy is enforced above and,
// for the contact side, by the entry-point ownership guard.
await db
.from('contact_custom_values')
.upsert(
{ contact_id: args.contactId, custom_field_id: customFieldId, value },
{ onConflict: 'contact_id,custom_field_id' },
)
return `custom field updated`
}
const allowed = new Set(['name', 'email', 'company']) const allowed = new Set(['name', 'email', 'company'])
if (!allowed.has(cfg.field)) { if (!allowed.has(cfg.field)) {
return `field ${cfg.field} not writable from automations` return `field ${cfg.field} not writable from automations`
@ -456,7 +490,7 @@ async function runStep(step: AutomationStep, args: ExecuteArgs): Promise<string>
// cannot write across tenants. // cannot write across tenants.
await db await db
.from('contacts') .from('contacts')
.update({ [cfg.field]: cfg.value, updated_at: new Date().toISOString() }) .update({ [cfg.field]: value, updated_at: new Date().toISOString() })
.eq('id', args.contactId) .eq('id', args.contactId)
.eq('account_id', args.automation.account_id) .eq('account_id', args.automation.account_id)
return `${cfg.field} updated` return `${cfg.field} updated`

View File

@ -119,6 +119,8 @@ export interface ContactTag {
export interface CustomField { export interface CustomField {
id: string; id: string;
user_id: string; user_id: string;
/** Tenancy key — NOT NULL since migration 017. */
account_id: string;
field_name: string; field_name: string;
field_type: string; field_type: string;
field_options?: Record<string, unknown>; field_options?: Record<string, unknown>;
@ -433,7 +435,15 @@ export interface AssignConversationStepConfig {
} }
export interface UpdateContactFieldStepConfig { export interface UpdateContactFieldStepConfig {
/**
* Either a built-in contact column (`name` | `email` | `company`) or a
* custom field encoded as `custom:<custom_field_id>`. The `custom:` prefix
* is how the engine distinguishes a `contact_custom_values` write from a
* direct `contacts` column update. Older configs store the bare column name,
* so this stays backward compatible.
*/
field: string; field: string;
/** Supports `{{ vars.* }}` / `{{ message.text }}` interpolation at runtime. */
value: string; value: string;
} }