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,
|
||||
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">
|
||||
|
||||
@ -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>
|
||||
|
||||
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(() => ({
|
||||
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 },
|
||||
};
|
||||
}
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user