mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
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:
parent
c569426c8a
commit
d477a7a429
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
275
src/components/contacts/custom-fields-manager.tsx
Normal file
275
src/components/contacts/custom-fields-manager.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@ -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`
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user