feat(automations): support custom contact fields in Update Contact Field action

The "Update Contact Field" automation action could only write the built-in
name/email/company columns. This wires up the existing custom_fields /
contact_custom_values tables so automations can read and update arbitrary
custom fields, and adds the missing UI to define them.

- Add a "Custom fields" manager dialog on the Contacts page (admin+ gated)
  to create / rename / delete field definitions. Inserts carry account_id.
- Load account custom fields into the automation builder's ResourcesContext
  and offer them in the Field dropdown, encoded as `custom:<id>`. A saved
  field that's since been deleted is preserved as an "(unknown field)"
  option so editing doesn't silently drop it.
- Engine: interpolate the value ({{ vars.* }} / {{ message.text }}) for all
  fields, and for `custom:<id>` verify the field belongs to the automation's
  account (defense in depth) before upserting into contact_custom_values.
- Tests: cover the custom-value upsert, variable interpolation, and
  cross-account rejection.

Closes #235

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-06-11 15:23:49 +03:00
parent c569426c8a
commit d477a7a429
No known key found for this signature in database
6 changed files with 486 additions and 15 deletions

View File

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

View File

@ -46,6 +46,7 @@ import type {
AccountMember,
AutomationStepType,
AutomationTriggerType,
CustomField,
KeywordMatchTriggerConfig,
MessageTemplate,
Tag as TagRecord,
@ -179,12 +180,14 @@ interface AutomationResources {
tags: TagRecord[]
members: AccountMember[]
templates: MessageTemplate[]
customFields: CustomField[]
}
const ResourcesContext = createContext<AutomationResources>({
tags: [],
members: [],
templates: [],
customFields: [],
})
function useResources(): AutomationResources {
@ -195,27 +198,30 @@ function ResourcesProvider({ children }: { children: ReactNode }) {
const [tags, setTags] = useState<TagRecord[]>([])
const [members, setMembers] = useState<AccountMember[]>([])
const [templates, setTemplates] = useState<MessageTemplate[]>([])
const [customFields, setCustomFields] = useState<CustomField[]>([])
useEffect(() => {
let cancelled = false
const supabase = createClient()
// Tags and templates come straight from the DB — RLS scopes both
// to the caller's account. Only APPROVED templates can actually be
// sent (anything else 400s at send time), matching the broadcast
// picker.
// Tags, templates and custom fields come straight from the DB — RLS
// scopes them to the caller's account. Only APPROVED templates can
// actually be sent (anything else 400s at send time), matching the
// broadcast picker.
void (async () => {
const [tagsRes, templatesRes] = await Promise.all([
const [tagsRes, templatesRes, customFieldsRes] = await Promise.all([
supabase.from("tags").select("*").order("name"),
supabase
.from("message_templates")
.select("*")
.eq("status", "APPROVED")
.order("name"),
supabase.from("custom_fields").select("*").order("field_name"),
])
if (cancelled) return
setTags((tagsRes.data as TagRecord[] | null) ?? [])
setTemplates((templatesRes.data as MessageTemplate[] | null) ?? [])
setCustomFields((customFieldsRes.data as CustomField[] | null) ?? [])
})()
// Members go through the API so we inherit its email-visibility
@ -238,7 +244,7 @@ function ResourcesProvider({ children }: { children: ReactNode }) {
}, [])
return (
<ResourcesContext.Provider value={{ tags, members, templates }}>
<ResourcesContext.Provider value={{ tags, members, templates, customFields }}>
{children}
</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
* a raw id input when the member list is unavailable. */
function AgentSelect({
@ -1044,20 +1090,16 @@ function StepEditor({
return (
<>
<FieldBlock label="Field">
<select
<ContactFieldSelect
value={(cfg.field as string) ?? "name"}
onChange={(e) => set({ field: e.target.value })}
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>
onChange={(v) => set({ field: v })}
/>
</FieldBlock>
<FieldBlock label="Value">
<Input
value={(cfg.value as string) ?? ""}
onChange={(e) => set({ value: e.target.value })}
placeholder="Text or {{ vars.x }} / {{ message.text }}"
className="bg-slate-800 text-white"
/>
</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(() => ({
state: {
owned: null as { id: string } | null,
ownedCustomField: null as { id: string } | null,
automations: [] as Record<string, unknown>[],
steps: [] as Record<string, unknown>[],
fromCalls: [] as string[],
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: {
table: string;
type: string;
payload?: unknown;
filters: [string, string, unknown][];
}) {
const { table, type } = ops;
@ -29,6 +32,17 @@ vi.mock("./admin-client", () => {
// ownership guard / condition read
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 === "automation_logs") {
if (type === "insert") return { data: { id: "log1" }, error: null };
@ -87,10 +101,12 @@ const ACCOUNT = "acct-1";
beforeEach(() => {
h.state.owned = null;
h.state.ownedCustomField = null;
h.state.automations = [];
h.state.steps = [];
h.state.fromCalls = [];
h.state.updateCalls = [];
h.state.upsertCalls = [];
});
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() {
return {
id: "a1",
@ -168,3 +245,14 @@ function updateStep() {
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': {
const cfg = step.step_config as UpdateContactFieldStepConfig
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'])
if (!allowed.has(cfg.field)) {
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.
await db
.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('account_id', args.automation.account_id)
return `${cfg.field} updated`

View File

@ -119,6 +119,8 @@ export interface ContactTag {
export interface CustomField {
id: string;
user_id: string;
/** Tenancy key — NOT NULL since migration 017. */
account_id: string;
field_name: string;
field_type: string;
field_options?: Record<string, unknown>;
@ -433,7 +435,15 @@ export interface AssignConversationStepConfig {
}
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;
/** Supports `{{ vars.* }}` / `{{ message.text }}` interpolation at runtime. */
value: string;
}