Skip to content

Commit 08fa4f0

Browse files
committed
uniform dot notation
1 parent 6bfb136 commit 08fa4f0

43 files changed

Lines changed: 3857 additions & 2382 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ All notable changes to this project will be documented in this file.
44

55
## [36.0] - 2026-07-08
66

7+
- **Feature**: Segments and automation conditions can now target link clicks by broadcast and by URL. A "clicked email" timeline condition can be scoped to a specific broadcast and/or to a clicked link, where the link is matched as a case-insensitive substring of the destination URL (e.g. `/pricing` matches `https://acme.com/pricing?utm_campaign=summer`). This makes segments and automation filters like "clicked the pricing link in the Summer Sale broadcast" possible; it builds on the per-link click data already recorded in `message_history.clicked_links`, so no migration is needed (#339, #311).
78
- **Fix**: Viewing the contacts of a segment no longer returns a server error. The `segments.contacts` endpoint ordered by a `created_at` column that `contact_segments` does not have, so every request failed; it now orders by `matched_at` (when the contact joined the segment).
89
- **Fix**: Removed the "Photo URL" option from the automation *contact updated* trigger's field list — contacts have no photo-URL field, so an automation filtered on it could never fire.
910
- **Fix**: Segment/automation conditions that filter contact-timeline events by a field value now work. The query builder referenced a non-existent `metadata` column on `contact_timeline` (event fields live in the `changes` JSONB as `{field: {old, new}}`), so any such condition failed at SQL execution; it now reads the field's new value from `changes`.
10-
- **Fix**: Automations triggered by an email engagement event (opened, clicked, bounced, complained, unsubscribed) now actually fire. The trigger listened for the dotted event kind (e.g. `email.clicked`), but the contact timeline records these events under a different name (e.g. `click_email`), so the trigger condition never matched and the automation silently never ran. The trigger generator now translates these event kinds to the names the timeline uses, and workspace migration v36 regenerates the triggers of already-live automations so the fix applies without needing to re-save them. (The `email.sent` and `email.delivered` triggers remain inert for now — enabling them for an "every time" automation with a send node could loop.) (#339)
11+
- **Fix / Change**: The contact timeline now records email engagement events in the same dot notation as the rest of the timeline and the webhooks — `email.opened`, `email.clicked`, `email.bounced`, `email.complained`, `email.unsubscribed`, plus `email.sent` and `email.updated` — replacing the old `open_email` / `click_email` / `insert_message_history` / `update_message_history` names. This fixes automations triggered by an email engagement event, which previously never fired because the trigger listened for `email.clicked` while the timeline stored `click_email`. Workspace migration v36 renames existing timeline rows, rewrites stored segment definitions (both the tree and the compiled query) and regenerates the triggers of already-live automations, so segments and automations keep working without needing to be re-saved. The `email.sent` and `email.delivered` options were removed from the automation trigger list — they could never fire, and an `email.sent` trigger would loop an "every time" automation that sends email. (#339)
1112

1213
## [35.1] - 2026-07-08
1314

console/src/components/automations/config/TriggerConfigForm.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,6 @@ export const TriggerConfigForm: React.FC<TriggerConfigFormProps> = ({ config, on
8686
value: 'email',
8787
label: t`Email`,
8888
children: [
89-
{ value: 'email.sent', label: t`Sent` },
90-
{ value: 'email.delivered', label: t`Delivered` },
9189
{ value: 'email.opened', label: t`Opened` },
9290
{ value: 'email.clicked', label: t`Clicked` },
9391
{ value: 'email.bounced', label: t`Bounced` },
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import React, { useMemo, useRef, useState } from 'react'
2+
import { Select } from 'antd'
3+
import { useQuery } from '@tanstack/react-query'
4+
import { useLingui } from '@lingui/react/macro'
5+
import { broadcastApi } from '../../services/api/broadcast'
6+
7+
interface BroadcastSelectorInputProps {
8+
value?: string | null
9+
onChange?: (value: string | null) => void
10+
workspaceId: string
11+
placeholder?: string
12+
size?: 'small' | 'middle' | 'large'
13+
style?: React.CSSProperties
14+
}
15+
16+
// BroadcastSelectorInput is a searchable dropdown of the workspace's broadcasts, used to
17+
// scope a timeline segment/automation condition to a specific broadcast. Search is
18+
// server-side (so workspaces with many broadcasts are fully reachable), and the currently
19+
// selected broadcast is resolved by id so its name shows even when it is not on the current
20+
// results page. Controlled via value/onChange so it plugs into an Ant Design Form.Item.
21+
const BroadcastSelectorInput: React.FC<BroadcastSelectorInputProps> = ({
22+
value,
23+
onChange,
24+
workspaceId,
25+
placeholder,
26+
size,
27+
style
28+
}) => {
29+
const { t } = useLingui()
30+
const [search, setSearch] = useState('')
31+
const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
32+
33+
const handleSearch = (text: string) => {
34+
if (debounceRef.current) clearTimeout(debounceRef.current)
35+
debounceRef.current = setTimeout(() => setSearch(text), 300)
36+
}
37+
38+
const { data, isLoading, isError } = useQuery({
39+
queryKey: ['broadcasts-selector', workspaceId, search],
40+
queryFn: () =>
41+
broadcastApi.list({ workspace_id: workspaceId, search: search || undefined, limit: 50 }),
42+
enabled: !!workspaceId
43+
})
44+
45+
// Resolve the selected broadcast so its name renders even when it is not in the current
46+
// results page (or was created before this component mounted).
47+
const { data: selected } = useQuery({
48+
queryKey: ['broadcast-selected', workspaceId, value],
49+
queryFn: () => broadcastApi.get({ workspace_id: workspaceId, id: value as string }),
50+
enabled: !!workspaceId && !!value
51+
})
52+
53+
const options = useMemo(() => {
54+
const list = (data?.broadcasts || []).map((b) => ({ value: b.id, label: b.name }))
55+
if (selected?.broadcast && !list.some((o) => o.value === selected.broadcast.id)) {
56+
list.unshift({ value: selected.broadcast.id, label: selected.broadcast.name })
57+
}
58+
return list
59+
}, [data, selected])
60+
61+
return (
62+
<Select
63+
value={value || undefined}
64+
onChange={(v) => onChange?.(v ?? null)}
65+
placeholder={placeholder || t`Select a broadcast`}
66+
loading={isLoading}
67+
allowClear
68+
showSearch
69+
filterOption={false}
70+
onSearch={handleSearch}
71+
notFoundContent={isError ? t`Failed to load broadcasts` : undefined}
72+
size={size}
73+
style={style}
74+
options={options}
75+
/>
76+
)
77+
}
78+
79+
export default BroadcastSelectorInput

console/src/components/segment/form_leaf.tsx

Lines changed: 72 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
import dayjs, { Dayjs } from 'dayjs'
1414
import { InputDimensionFilters } from './input_dimension_filters'
1515
import TemplateSelectorInput from '../templates/TemplateSelectorInput'
16+
import BroadcastSelectorInput from './BroadcastSelectorInput'
1617
import Messages from './messages'
1718
import { useLingui } from '@lingui/react/macro'
1819

@@ -290,12 +291,12 @@ export const LeafActionForm = (props: LeafFormProps) => {
290291
size="small"
291292
placeholder={t`Select event`}
292293
options={[
293-
{ value: 'insert_message_history', label: t`New message (email...)` },
294-
{ value: 'open_email', label: t`Open email` },
295-
{ value: 'click_email', label: t`Click email` },
296-
{ value: 'bounce_email', label: t`Bounce email` },
297-
{ value: 'complain_email', label: t`Complain email` },
298-
{ value: 'unsubscribe_email', label: t`Unsubscribe from list` }
294+
{ value: 'email.sent', label: t`New message (email...)` },
295+
{ value: 'email.opened', label: t`Open email` },
296+
{ value: 'email.clicked', label: t`Click email` },
297+
{ value: 'email.bounced', label: t`Bounce email` },
298+
{ value: 'email.complained', label: t`Complain email` },
299+
{ value: 'email.unsubscribed', label: t`Unsubscribe from list` }
299300
]}
300301
/>
301302
</Form.Item>
@@ -306,30 +307,78 @@ export const LeafActionForm = (props: LeafFormProps) => {
306307
<Form.Item noStyle shouldUpdate>
307308
{(funcs) => {
308309
const kind = funcs.getFieldValue(['contact_timeline', 'kind'])
309-
const emailKinds = ['open_email', 'click_email', 'bounce_email', 'complain_email', 'unsubscribe_email']
310+
const emailKinds = [
311+
'email.opened',
312+
'email.clicked',
313+
'email.bounced',
314+
'email.complained',
315+
'email.unsubscribed'
316+
]
310317

311318
if (!emailKinds.includes(kind) || !props.workspaceId) {
312319
return null
313320
}
314321

315322
return (
316323
<div className="mb-2">
317-
<Space>
318-
<span className="opacity-60" style={{ lineHeight: '32px' }}>
319-
{t`template`}
320-
</span>
321-
<Form.Item
322-
noStyle
323-
name={['contact_timeline', 'template_id']}
324-
colon={false}
325-
>
326-
<TemplateSelectorInput
327-
workspaceId={props.workspaceId}
328-
placeholder={t`Any template`}
329-
clearable={true}
330-
size="small"
331-
/>
332-
</Form.Item>
324+
<Space direction="vertical" size={4}>
325+
<Space>
326+
<span className="opacity-60" style={{ lineHeight: '32px' }}>
327+
{t`template`}
328+
</span>
329+
<Form.Item
330+
noStyle
331+
name={['contact_timeline', 'template_id']}
332+
colon={false}
333+
>
334+
<TemplateSelectorInput
335+
workspaceId={props.workspaceId}
336+
placeholder={t`Any template`}
337+
clearable={true}
338+
size="small"
339+
/>
340+
</Form.Item>
341+
<span className="opacity-60" style={{ lineHeight: '32px' }}>
342+
{t`broadcast`}
343+
</span>
344+
<Form.Item
345+
noStyle
346+
name={['contact_timeline', 'broadcast_id']}
347+
colon={false}
348+
// Drop the value if the kind changes to one whose block hides it, so a
349+
// hidden broadcast filter is never silently submitted.
350+
preserve={false}
351+
>
352+
<BroadcastSelectorInput
353+
workspaceId={props.workspaceId}
354+
placeholder={t`Any broadcast`}
355+
size="small"
356+
style={{ minWidth: 180 }}
357+
/>
358+
</Form.Item>
359+
</Space>
360+
{kind === 'email.clicked' && (
361+
<Space>
362+
<span className="opacity-60" style={{ lineHeight: '32px' }}>
363+
{t`clicked link contains`}
364+
</span>
365+
<Form.Item
366+
noStyle
367+
name={['contact_timeline', 'link_url']}
368+
colon={false}
369+
// Drop the value when switching away from email.clicked, otherwise a
370+
// stale link_url would be submitted with a kind the backend rejects.
371+
preserve={false}
372+
>
373+
<Input
374+
placeholder={t`e.g. /pricing`}
375+
allowClear
376+
size="small"
377+
style={{ minWidth: 200 }}
378+
/>
379+
</Form.Item>
380+
</Space>
381+
)}
333382
</Space>
334383
</div>
335384
)

console/src/components/segment/input.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -722,13 +722,13 @@ export const TreeNodeInput = (props: TreeNodeInputProps) => {
722722
<div className="mb-2">
723723
<span className="opacity-60 pr-3">{t`type`}</span>
724724
<Tag bordered={false} color="blue">
725-
{node.leaf?.contact_timeline.kind === 'open_email' && t`Open email`}
726-
{node.leaf?.contact_timeline.kind === 'click_email' && t`Click email`}
727-
{node.leaf?.contact_timeline.kind === 'bounce_email' && t`Bounce email`}
728-
{node.leaf?.contact_timeline.kind === 'complain_email' && t`Complain email`}
729-
{node.leaf?.contact_timeline.kind === 'unsubscribe_email' &&
725+
{node.leaf?.contact_timeline.kind === 'email.opened' && t`Open email`}
726+
{node.leaf?.contact_timeline.kind === 'email.clicked' && t`Click email`}
727+
{node.leaf?.contact_timeline.kind === 'email.bounced' && t`Bounce email`}
728+
{node.leaf?.contact_timeline.kind === 'email.complained' && t`Complain email`}
729+
{node.leaf?.contact_timeline.kind === 'email.unsubscribed' &&
730730
t`Unsubscribe from list`}
731-
{node.leaf?.contact_timeline.kind === 'insert_message_history' &&
731+
{node.leaf?.contact_timeline.kind === 'email.sent' &&
732732
t`New message (email...)`}
733733
</Tag>
734734
</div>

console/src/components/timeline/ContactTimeline.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,9 +123,9 @@ export function ContactTimeline({
123123
return faFolderOpen
124124
case 'contact_segment':
125125
// Use kind to determine join vs leave
126-
if (entry.kind === 'join_segment') {
126+
if (entry.kind === 'segment.joined') {
127127
return faArrowRightToBracket
128-
} else if (entry.kind === 'leave_segment') {
128+
} else if (entry.kind === 'segment.left') {
129129
return faArrowRightFromBracket
130130
}
131131
return faClock
@@ -481,7 +481,7 @@ export function ContactTimeline({
481481
)
482482

483483
// Map kind to action labels
484-
const segmentActionLabel = entry.kind === 'join_segment' ? t`joined` : t`left`
484+
const segmentActionLabel = entry.kind === 'segment.joined' ? t`joined` : t`left`
485485

486486
return (
487487
<div>

console/src/i18n/locales/ca.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)