From d477a7a429a4cf9fd27da8b88a39b860c54b4d72 Mon Sep 17 00:00:00 2001 From: Arnas Donauskas Date: Thu, 11 Jun 2026 15:23:49 +0300 Subject: [PATCH] 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:`. 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:` 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) --- src/app/(dashboard)/contacts/page.tsx | 22 ++ .../automations/automation-builder.tsx | 70 ++++- .../contacts/custom-fields-manager.tsx | 275 ++++++++++++++++++ src/lib/automations/engine.test.ts | 88 ++++++ src/lib/automations/engine.ts | 36 ++- src/types/index.ts | 10 + 6 files changed, 486 insertions(+), 15 deletions(-) create mode 100644 src/components/contacts/custom-fields-manager.tsx diff --git a/src/app/(dashboard)/contacts/page.tsx b/src/app/(dashboard)/contacts/page.tsx index 20c685f..50182ba 100644 --- a/src/app/(dashboard)/contacts/page.tsx +++ b/src/app/(dashboard)/contacts/page.tsx @@ -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([]); const [loading, setLoading] = useState(true); @@ -70,6 +73,7 @@ export default function ContactsPage() { const [detailOpen, setDetailOpen] = useState(false); const [detailContactId, setDetailContactId] = useState(null); const [importOpen, setImportOpen] = useState(false); + const [customFieldsOpen, setCustomFieldsOpen] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); const [deleting, setDeleting] = useState(false); @@ -219,6 +223,16 @@ export default function ContactsPage() {

+ {canEditSettings && ( + + )} + {/* Custom Fields Manager (admin+) */} + {canEditSettings && ( + + )} + {/* Delete Confirmation */} diff --git a/src/components/automations/automation-builder.tsx b/src/components/automations/automation-builder.tsx index 2a4ea01..6e317ed 100644 --- a/src/components/automations/automation-builder.tsx +++ b/src/components/automations/automation-builder.tsx @@ -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({ tags: [], members: [], templates: [], + customFields: [], }) function useResources(): AutomationResources { @@ -195,27 +198,30 @@ function ResourcesProvider({ children }: { children: ReactNode }) { const [tags, setTags] = useState([]) const [members, setMembers] = useState([]) const [templates, setTemplates] = useState([]) + const [customFields, setCustomFields] = useState([]) 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 ( - + {children} ) @@ -296,6 +302,46 @@ function TagSelect({ ) } +/** Contact-field dropdown for "Update Contact Field": built-in columns plus + * any account custom fields (stored as `custom:`). 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 ( + + ) +} + /** 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 ( <> - + onChange={(v) => set({ field: v })} + /> set({ value: e.target.value })} + placeholder="Text or {{ vars.x }} / {{ message.text }}" className="bg-slate-800 text-white" /> diff --git a/src/components/contacts/custom-fields-manager.tsx b/src/components/contacts/custom-fields-manager.tsx new file mode 100644 index 0000000..6b759cf --- /dev/null +++ b/src/components/contacts/custom-fields-manager.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [newName, setNewName] = useState(''); + const [creating, setCreating] = useState(false); + const [busyId, setBusyId] = useState(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 { + 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 ( + + + + Custom fields + + Define extra contact fields (e.g. ZIP code, lead source). They appear + on every contact and in the “Update Contact Field” automation action. + + + + {/* Create */} +
+ setNewName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + void handleCreate(); + } + }} + placeholder="New field name…" + className="bg-slate-800 text-white" + /> + +
+ + {/* List */} +
+ {loading ? ( +
+ + Loading… +
+ ) : fields.length === 0 ? ( +

+ No custom fields yet. +

+ ) : ( +
    + {fields.map((field) => ( + + ))} +
+ )} +
+
+
+ ); +} + +/** 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; + 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 ( +
  • + 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" + /> + +
  • + ); +} diff --git a/src/lib/automations/engine.test.ts b/src/lib/automations/engine.test.ts index 03684d9..e9617fc 100644 --- a/src/lib/automations/engine.test.ts +++ b/src/lib/automations/engine.test.ts @@ -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[], steps: [] as Record[], 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 }, + }; +} diff --git a/src/lib/automations/engine.ts b/src/lib/automations/engine.ts index 6124fac..c21a25c 100644 --- a/src/lib/automations/engine.ts +++ b/src/lib/automations/engine.ts @@ -447,6 +447,40 @@ async function runStep(step: AutomationStep, args: ExecuteArgs): Promise 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:`; 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 // 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` diff --git a/src/types/index.ts b/src/types/index.ts index 3d388e1..66e0b8a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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; @@ -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:`. 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; }