Campaign Performance & UI Improvements (#673)

This commit is contained in:
Chris Anderson 2025-06-21 14:50:55 -05:00 committed by GitHub
parent fdeef7bbca
commit a2e3677508
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 206 additions and 81 deletions

View file

@ -76,6 +76,11 @@ export const getCampaign = async (id: number, projectId: number): Promise<Campai
if (campaign.state === 'loading') {
campaign.progress = await campaignPopulationProgress(campaign)
}
logger.warn({
query: await recipientClickhouseQuery(campaign),
campaignId: campaign.id,
}, 'campaign:generate:query')
}
return campaign
@ -291,12 +296,16 @@ export const generateSendList = async (campaign: SentCampaign) => {
const now = Date.now()
const cacheKey = CacheKeys.populationProgress(campaign)
logger.info({ campaignId: campaign.id, elapsed: Date.now() - now }, 'campaign:generate:progress:started')
const query = await recipientClickhouseQuery(campaign)
logger.info({
campaignId: campaign.id,
elapsed: Date.now() - now,
query,
}, 'campaign:generate:progress:started')
// Generate the initial send list
const result = await User.clickhouse().query(
await recipientClickhouseQuery(campaign),
)
const result = await User.clickhouse().query(query)
const chunker = new Chunker<CampaignSendParams>(async items => {
await App.main.db.transaction(async (trx) => {
@ -370,7 +379,7 @@ const recipientClickhouseQuery = async (campaign: Campaign) => {
.select('rule')
.whereIn('id', ids)
for (const list of lists) {
queries.push(getRuleQuery(campaign.project_id, list.rule))
queries.push('(' + getRuleQuery(campaign.project_id, list.rule) + ')')
}
return queries.join(' union distinct ')
}

View file

@ -8,6 +8,7 @@ import { ProjectState } from '../auth/AuthMiddleware'
import parse from '../storage/FileStream'
import { projectRoleMiddleware } from '../projects/ProjectService'
import ListStatsJob from './ListStatsJob'
import { migrateStaticList } from '../utilities/migrate'
const router = new Router<
ProjectState & { list?: List }
@ -219,4 +220,9 @@ router.post('/:listId/recount', async ctx => {
ctx.status = 204
})
router.post('/:listId/migrate', async ctx => {
await migrateStaticList(ctx.state.list!)
ctx.status = 204
})
export default router

View file

@ -1,6 +1,5 @@
export interface Push {
tokens: string | string[]
topic: string
title: string
body: string
custom: Record<string, string | number>

View file

@ -19,14 +19,21 @@ export default class PushChannel {
// Find tokens from active devices with push enabled
const tokens = variables.user.pushEnabledDevices.map(device => device.token)
// If no tokens, don't send
if (tokens?.length <= 0) return
const push = {
tokens,
...template.compile(variables),
}
// If no tokens, don't send
if (tokens?.length <= 0) {
return {
push,
success: false,
invalidTokens: [],
response: 'No active devices with push enabled found.',
}
}
return await this.provider.send(push)
}
}

View file

@ -162,7 +162,6 @@ export class TextTemplate extends Template {
export interface CompiledPush {
title: string
topic: string
body: string
custom: Record<string, any>
}
@ -170,7 +169,6 @@ export interface CompiledPush {
export class PushTemplate extends Template {
declare type: 'push'
title!: string
topic!: string
body!: string
url!: string
custom!: Record<string, any>
@ -179,7 +177,6 @@ export class PushTemplate extends Template {
super.parseJson(json)
this.title = json?.data.title
this.topic = json?.data.topic
this.body = json?.data.body
this.url = json?.data.url
this.custom = json?.data.custom ?? {}
@ -194,7 +191,6 @@ export class PushTemplate extends Template {
const url = this.compileUrl(variables)
return {
topic: this.topic,
title: Render(this.title, variables),
body: Render(this.body, variables),
custom: { ...custom, url },
@ -223,16 +219,15 @@ export class PushTemplate extends Template {
validate() {
return isValid({
type: 'object',
required: ['title', 'topic', 'body'],
required: ['title', 'body'],
properties: {
title: { type: 'string' },
topic: { type: 'string' },
body: { type: 'string' },
url: { type: 'string', nullable: true },
},
additionalProperties: true,
errorMessage: {
required: this.requiredErrors('title', 'topic', 'body'),
required: this.requiredErrors('title', 'body'),
},
}, this.data)
}

View file

@ -14,6 +14,8 @@ import { getUserFromEmail, getUserFromPhone } from '../users/UserRepository'
import { loadWebhookChannel } from '../providers/webhook'
import Project from '../projects/Project'
import { getProject } from '../projects/ProjectService'
import { logger } from '../config/logger'
import EventPostJob from '../client/EventPostJob'
export const pagedTemplates = async (params: PageParams, projectId: number) => {
return await Template.search(
@ -72,7 +74,7 @@ export const screenshotHtml = (template: TemplateType) => {
} else if (template.type === 'text') {
return template.text
} else if (template.type === 'push') {
return `${template.title}<br/>${template.body}`
return `<html style="font-size:36px;padding:10px">${template.title}<br/>${template.body}</html>`
}
return ''
}
@ -106,25 +108,39 @@ export const sendProof = async (template: TemplateType, variables: Variables, re
user.data = { ...user?.data, ...variables.user }
variables = { user, event, context, project }
let response: any
if (template.type === 'email') {
const channel = await loadEmailChannel(campaign.provider_id, project.id)
await channel?.send(template, variables)
response = await channel?.send(template, variables)
logger.info(response, 'template:proof:email:result')
} else if (template.type === 'text') {
const channel = await loadTextChannel(campaign.provider_id, project.id)
await channel?.send(template, variables)
response = await channel?.send(template, variables)
logger.info(response, 'template:proof:text:result')
} else if (template.type === 'push') {
const channel = await loadPushChannel(campaign.provider_id, project.id)
if (!user.id) throw new RequestError('Unable to find a user matching the criteria.')
await channel?.send(template, variables)
response = await channel?.send(template, variables)
logger.info(response, 'template:proof:push:result')
} else if (template.type === 'webhook') {
const channel = await loadWebhookChannel(campaign.provider_id, project.id)
await channel?.send(template, variables)
} else {
throw new RequestError('Sending template proofs is only supported for email and text message types as this time.')
}
await EventPostJob.from({
project_id: project.id,
user_id: user.id,
event: {
name: 'proof_sent',
external_id: user.external_id,
data: {
context,
response,
},
},
}).queue()
}
// Determine what template to send to the user based on the following:

View file

@ -67,7 +67,6 @@ describe('Template', () => {
type: 'push',
data: {
title: 'title',
topic: 'topic',
body: 'body',
url,
custom: {
@ -99,7 +98,6 @@ describe('Template', () => {
type: 'push',
data: {
title: 'title',
topic: 'topic',
body: 'body',
url,
custom: {

View file

@ -26,7 +26,6 @@ Object {
"url": "https://google.com",
},
"title": "title",
"topic": "topic",
}
`;
@ -38,6 +37,5 @@ Object {
"url": "https://parcelvoy.com/c?u=M8LRMWZ645&c=M8LRMWZ645&s=1&r=https%253A%252F%252Fgoogle.com",
},
"title": "title",
"topic": "topic",
}
`;

View file

@ -40,6 +40,7 @@ export const whereQuery = <T extends AnyJson | undefined>(path: string, operator
}
if (operator === 'contains') return `${path} LIKE '%${value}%'`
if (operator === 'not contain') return `${path} NOT LIKE '%${value}%'`
if (operator === 'starts with') return `${path} LIKE '${value}%'`
if (operator === 'not start with') return `${path} NOT LIKE '${value}%'`

View file

@ -63,7 +63,7 @@ export default {
const ruleValue = compile(rule, item => String(item))
if (['=', '!=', 'contains', 'any', 'none', 'starts with', 'not start with'].includes(rule.operator)) {
if (['=', '!=', 'contains', 'not contain', 'any', 'none', 'starts with', 'not start with'].includes(rule.operator)) {
return whereQuery(path, rule.operator, ruleValue, 'String')
}

View file

@ -76,7 +76,7 @@ export const migrateEvents = async (since?: Date) => {
const size = 1000
const chunker = new Chunker<UserEvent>(async events => {
await UserEvent.insert(events.map(event => ({ ...event, uuid: randomUUID() })))
await UserEvent.clickhouse().insert(events.map(event => ({ ...event, uuid: randomUUID() })))
}, size)
for await (const event of events) {
@ -93,7 +93,7 @@ export const migrateStaticList = async ({ id, project_id }: List) => {
const size = 1000
const chunker = new Chunker<{ user_id: number, list_id: number, created_at: number, version: number }>(async users => {
await UserEvent.insert(
await UserEvent.clickhouse().insert(
users.map(user => ({
name: 'user_imported_to_list',
user_id: user.user_id,

View file

@ -207,6 +207,7 @@
"message": "Message",
"message_settings": "Message Settings",
"method": "Method",
"migrate": "Migrate",
"minute": "Minute",
"minute_one": "{{count}} Minute",
"minute_other": "{{count}} Minutes",

View file

@ -243,6 +243,9 @@ const api = {
recount: async (projectId: number | string, listId: number | string) => await client
.post<List>(`${projectUrl(projectId)}/lists/${listId}/recount`)
.then(r => r.data),
migrate: async (projectId: number | string, listId: number | string) => await client
.post<List>(`${projectUrl(projectId)}/lists/${listId}/migrate`)
.then(r => r.data),
},
projectAdmins: {

View file

@ -435,7 +435,6 @@ export interface TextTemplateData {
export interface PushTemplateData {
title: string
topic: string
body: string
url: string
custom: Record<string, unknown>

View file

@ -27,6 +27,15 @@
margin-bottom: -5px;
}
.ui-info-table .info-value .tag-list {
margin-bottom: -15px;
}
.ui-info-table .info-value .tag-list .ui-tag {
margin-top: -5px;
margin-bottom: 10px;
}
.ui-info-table .info-row:last-child {
border-bottom-width: 0px;
}

View file

@ -42,7 +42,6 @@ export const localeState = (templates: Template[]) => {
}
export const createLocale = async ({ locale, data }: LocaleParams, campaign: Campaign): Promise<Template> => {
// TODO: Get base locale from user preferences
const baseLocale = 'en'
const template = campaign.templates.find(template => template.locale === baseLocale) ?? campaign.templates[0]
return await api.templates.create(campaign.project_id, {

View file

@ -14,6 +14,7 @@ import ChannelTag from './ChannelTag'
import CodeExample from '../../ui/CodeExample'
import { env } from '../../config/env'
import { useTranslation } from 'react-i18next'
import { Tag } from '../../ui'
export default function CampaignOverview() {
const [project] = useContext(ProjectContext)
@ -22,12 +23,15 @@ export default function CampaignOverview() {
const [campaign, setCampaign] = useContext(CampaignContext)
const [isEditOpen, setIsEditOpen] = useState(false)
const DelimitedLists = ({ lists }: { lists?: List[] }) => {
return lists?.map<ReactNode>(
list => (
<Link to={`/projects/${campaign.project_id}/lists/${list.id}`} key={list.id}>{list.name}</Link>
),
)?.reduce((prev, curr) => prev ? [prev, ', ', curr] : curr, '') ?? '&#8211;'
const DelimitedLists = ({ lists, delimiter = ' ' }: { lists?: List[], delimiter?: ReactNode }) => {
if (!lists || lists?.length === 0) return <>&#8211;</>
return <div className="tag-list">
{lists?.map<ReactNode>(
list => (
<Tag variant="plain" key={list.id}><Link to={`/projects/${campaign.project_id}/lists/${list.id}`}>{list.name}</Link></Tag>
),
)?.reduce((prev, curr) => prev ? [prev, delimiter, curr] : curr, '')}
</div>
}
const canEdit = campaign.type === 'trigger' || campaign.state === 'draft' || campaign.state === 'aborted'

View file

@ -95,7 +95,6 @@ const PushTable = ({ data }: { data: PushTemplateData }) => {
return <InfoTable rows={{
[t('title')]: data.title ?? <Tag variant="warn">{t('missing')}</Tag>,
[t('body')]: data.body ?? <Tag variant="warn">{t('missing')}</Tag>,
[t('topic')]: data.topic ?? <Tag variant="warn">{t('missing')}</Tag>,
[t('deeplink')]: data.url,
[t('raw_json')]: JSON.stringify(data.custom),
}} />
@ -115,12 +114,6 @@ const PushForm = ({ form }: { form: UseFormReturn<TemplateUpdateParams, any> })
label={t('body')}
textarea
required />
<TextInput.Field
form={form}
name="data.topic"
label={t('topic')}
textarea
required />
<TextInput.Field
form={form}
name="data.url"

View file

@ -307,6 +307,9 @@
.journey-step-body-name {
margin-bottom: 10px;
font-weight: bold;
display: flex;
align-items: center;
gap: 10px;
}
.journey-step-edge {
@ -347,4 +350,30 @@
.react-flow__attribution {
display: none;
}
.journey-step-action-preview {
background: var(--color-background-soft);
height: 200px;
width: 250px;
border-radius: var(--border-radius-inner);
display: flex;
align-items: center;
justify-content: center;
}
.journey-step-action-preview-placeholder {
color: var(--color-grey);
font-weight: 500;
}
.journey-step-action-type {
background: var(--color-background-soft);
border-radius: var(--border-radius-inner);
padding: 5px;
width: 30px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
}

View file

@ -1,17 +1,44 @@
import { useCallback } from 'react'
import { useCallback, useState } from 'react'
import api, { apiUrl } from '../../../api'
import { JourneyStepType } from '../../../types'
import { Campaign, JourneyStepType } from '../../../types'
import { EntityIdPicker } from '../../../ui/form/EntityIdPicker'
import { ActionStepIcon } from '../../../ui/icons'
import { CampaignForm } from '../../campaign/CampaignForm'
import { useResolver } from '../../../hooks'
import PreviewImage from '../../../ui/PreviewImage'
import { useTranslation } from 'react-i18next'
import { ChannelIcon } from '../../campaign/ChannelTag'
import Preview from '../../../ui/Preview'
import { SingleSelect } from '../../../ui/form/SingleSelect'
import { Heading } from '../../../ui'
import { locales } from '../../campaign/CampaignDetail'
interface ActionConfig {
campaign_id: number
}
const JourneyTemplatePreview = ({ campaign }: { campaign: Campaign }) => {
const { t } = useTranslation()
const allLocales = locales(campaign.templates)
const [locale, setLocale] = useState(allLocales[0])
const template = campaign.templates.find(value => value.locale === locale.key)
return <>
<Heading
title={t('preview')}
size="h4"
actions={
<SingleSelect
options={allLocales}
size="small"
value={locale}
onChange={(locale) => setLocale(locale)}
/>
}
/>
{template && <Preview template={template} />}
</>
}
export const actionStep: JourneyStepType<ActionConfig> = {
name: 'send',
icon: <ActionStepIcon />,
@ -31,24 +58,37 @@ export const actionStep: JourneyStepType<ActionConfig> = {
return null
}, [projectId, campaign_id]))
if (campaign) {
return (
<>
<div className="journey-step-body-name">{campaign.name}</div>
{
campaign.channel !== 'webhook' && (
<PreviewImage
url={apiUrl(projectId, `campaigns/${campaign.id}/preview`)}
width={200}
height={200}
/>
return (
<>
<div className="journey-step-body-name">
<div className="journey-step-action-type">
{campaign && <ChannelIcon channel={campaign.channel} />}
</div>
{campaign?.name ?? <>&#8211;</>}
</div>
<div className="journey-step-action-preview">
{ campaign
? (
campaign.channel !== 'webhook'
? (
<PreviewImage
url={apiUrl(projectId, `campaigns/${campaign.id}/preview`)}
width={250}
height={200}
/>
)
: (
<div className="placeholder">
<ChannelIcon channel={campaign.channel} />
</div>
)
)
}
</>
)
}
return null
: (
<div className="journey-step-action-preview-placeholder">Create campaign to preview</div>
)}
</div>
</>
)
},
newData: async () => ({
campaign_id: 0,
@ -58,25 +98,36 @@ export const actionStep: JourneyStepType<ActionConfig> = {
onChange,
value,
}) {
const [campaign] = useResolver(useCallback(async () => {
if (value) {
return await api.campaigns.get(projectId, value.campaign_id)
}
return null
}, [projectId, value]))
const { t } = useTranslation()
return (
<EntityIdPicker
label={t('campaign')}
subtitle={t('send_campaign_desc')}
get={useCallback(async id => await api.campaigns.get(projectId, id), [projectId])}
search={useCallback(async q => await api.campaigns.search(projectId, { q, limit: 50, filter: { type: 'trigger' } }), [projectId])}
value={value.campaign_id}
onChange={campaign_id => onChange({ ...value, campaign_id })}
required
createModalSize="large"
renderCreateForm={onCreated => (
<CampaignForm
type="trigger"
onSave={onCreated}
/>
)}
onEditLink={campaign => window.open(`/projects/${projectId}/campaigns/${campaign.id}`)}
/>
<>
<EntityIdPicker
label={t('campaign')}
subtitle={t('send_campaign_desc')}
get={useCallback(async id => await api.campaigns.get(projectId, id), [projectId])}
search={useCallback(async q => await api.campaigns.search(projectId, { q, limit: 50, filter: { type: 'trigger' } }), [projectId])}
value={value.campaign_id}
onChange={campaign_id => onChange({ ...value, campaign_id })}
required
createModalSize="large"
renderCreateForm={onCreated => (
<CampaignForm
type="trigger"
onSave={onCreated}
/>
)}
onEditLink={campaign => window.open(`/projects/${projectId}/campaigns/${campaign.id}`)}
/>
{campaign && <JourneyTemplatePreview campaign={campaign} />}
</>
)
},
hasDataKey: true,

View file

@ -120,6 +120,11 @@ export default function ListDetail() {
window.location.reload()
}
const handleMigrateList = async () => {
await api.lists.migrate(project.id, list.id)
window.location.reload()
}
const handleArchiveList = async () => {
await api.lists.delete(project.id, list.id)
window.location.href = `/projects/${project.id}/lists`
@ -155,6 +160,9 @@ export default function ListDetail() {
<MenuItem onClick={async () => await handleArchiveList()}>
<ArchiveIcon />{t('archive')}
</MenuItem>
<MenuItem onClick={async () => await handleMigrateList()}>
<SendIcon />{t('migrate')}
</MenuItem>
</Menu>
</>
}>