Improved Campaign State Handling (#651)

This commit is contained in:
Chris Anderson 2025-03-09 14:47:40 -05:00 committed by GitHub
parent 610c820943
commit c528c06d9d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 131 additions and 37 deletions

View file

@ -8,7 +8,7 @@ import { crossTimezoneCopy } from '../utilities'
import Project from '../projects/Project'
import { User } from '../users/User'
export type CampaignState = 'draft' | 'scheduled' | 'loading' | 'running' | 'finished' | 'aborted'
export type CampaignState = 'draft' | 'scheduled' | 'loading' | 'running' | 'finished' | 'aborting' | 'aborted'
export interface CampaignDelivery {
sent: number
total: number

View file

@ -0,0 +1,32 @@
import { Job } from '../queue'
import Campaign, { CampaignJobParams } from './Campaign'
import CampaignGenerateListJob from './CampaignGenerateListJob'
import { abortCampaign, getCampaign } from './CampaignService'
export interface CampaignAbortParams extends CampaignJobParams {
reschedule?: boolean
}
export default class CampaignAbortJob extends Job {
static $name = 'campaign_abort_job'
static from({ id, project_id, reschedule }: CampaignAbortParams): CampaignAbortJob {
return new this({ id, project_id, reschedule }).jobId(`cid_${id}_abort`)
}
static async handler({ id, project_id, reschedule }: CampaignAbortParams) {
const campaign = await getCampaign(id, project_id)
if (!campaign) return
await abortCampaign(campaign)
const state = reschedule ? 'loading' : 'aborted'
await Campaign.update(qb => qb.where('id', id), {
state,
})
if (state === 'loading' && campaign.type === 'blast') {
await CampaignGenerateListJob.from(campaign).queue()
}
}
}

View file

@ -10,8 +10,8 @@ import { CacheKeys, estimatedSendSize, generateSendList, getCampaign } from './C
export default class CampaignGenerateListJob extends Job {
static $name = 'campaign_generate_list_job'
static from(data: CampaignJobParams): CampaignGenerateListJob {
return new this(data)
static from({ id, project_id }: CampaignJobParams): CampaignGenerateListJob {
return new this({ id, project_id }).jobId(`cid_${id}_generate`)
}
static async handler({ id, project_id }: CampaignJobParams) {

View file

@ -26,6 +26,7 @@ import Model, { raw, ref } from '../core/Model'
import { cacheGet, cacheIncr } from '../config/redis'
import App from '../app'
import { releaseLock } from '../core/Lock'
import CampaignAbortJob from './CampaignAbortJob'
export const CacheKeys = {
pendingStats: 'campaigns:pending_stats',
@ -119,18 +120,19 @@ export const updateCampaign = async (id: number, projectId: number, { tags, ...p
const data: Partial<Campaign> = { ...params }
let send_at: Date | undefined | null = data.send_at ? new Date(data.send_at) : undefined
const isRescheduling = send_at != null
&& campaign.send_at != null
&& send_at !== campaign.send_at
// If we are aborting, reset `send_at`
if (data.state === 'aborted') {
send_at = null
await abortCampaign(campaign)
data.state = 'aborting'
}
// If we are rescheduling, abort sends so they are reset
if (send_at
&& campaign.send_at
&& send_at !== campaign.send_at) {
data.state = 'loading'
await abortCampaign(campaign)
if (isRescheduling) {
data.state = 'aborting'
}
// Check templates to make sure we can schedule a send
@ -164,6 +166,10 @@ export const updateCampaign = async (id: number, projectId: number, { tags, ...p
await CampaignGenerateListJob.from(campaign).queue()
}
if (data.state === 'aborting') {
await CampaignAbortJob.from({ ...campaign, reschedule: isRescheduling }).queue()
}
return await getCampaign(id, projectId)
}

View file

@ -28,8 +28,10 @@ import ScheduledEntranceOrchestratorJob from '../journey/ScheduledEntranceOrches
import ListRefreshJob from '../lists/ListRefreshJob'
import ListEvaluateUserJob from '../lists/ListEvaluateUserJob'
import UserListMatchJob from '../lists/UserListMatchJob'
import CampaignAbortJob from '../campaigns/CampaignAbortJob'
export const jobs = [
CampaignAbortJob,
CampaignGenerateListJob,
CampaignEnqueueSendJob,
CampaignInteractJob,

View file

@ -1,6 +1,7 @@
{
"abort_campaign": "Abort Campaign",
"aborted": "Aborted",
"aborting": "Aborting",
"action": "Action",
"add_admin": "Add Admin",
"add_admin_description": "Add a new admin to this organization. Admins have full access to all projects and settings, members can only access projects they are a part of.",
@ -170,7 +171,10 @@
"label": "Label",
"language": "Language",
"last_name": "Last Name",
"launch": "Launch",
"launch_campaign": "Launch Campaign",
"launch_period": "Launch Period",
"launch_subtitle": "Please check to ensure all settings are correct before launching a campaign. A scheduled campaign can be aborted, but one sent immediately cannot.",
"launched_at": "Launched At",
"light_mode": "Use Light Theme",
"link": "Link",
@ -211,6 +215,7 @@
"new_team_member": "New Team Member",
"no_providers": "No Providers",
"no_template_alert_body": "There are no templates yet for this campaign. Add a locale above or use the button below to get started.",
"now": "Now",
"onboarding_installation_success": "Looks like everything is working with the installation! Now let's get you setup and ready to run some campaigns!",
"onboarding_project_setup_description": "At Parcelvoy, projects represent a single workspace for sending messages. You can use them for creating staging environments, isolating different clients, etc. Let's create your first one to get you started!",
"onboarding_project_setup_title": "Project Setup",
@ -245,6 +250,8 @@
"remove": "Remove",
"remove_locale_warning": "Are you sure you want to delete this locale? The template cannot be recovered.",
"reply_to": "Reply To",
"reschedule": "Reschedule",
"rescheduling": "Rescheduling",
"restart": "Restart",
"restart_campaign": "Restart Campaign",
"role": "Role",
@ -269,8 +276,13 @@
"second": "Second",
"send": "Send",
"send_at": "Send At",
"send_at_date": "Send At Date",
"send_at_time": "Send At Time",
"send_at_timezone_notice": "The selected date and time will be in the project's timezone not your own.",
"send_campaign_desc": "Send this campaign when users reach this step.",
"send_desc": "Trigger a send (email, sms, push notification, webhook) to a user.",
"send_in_user_timezone": "Send In Users Timezone",
"send_in_user_timezone_desc": "Should the campaign go out at the selected time in the users timezone or in the projects timezone?",
"send_lists": "Send Lists",
"send_proof": "Send Proof",
"sent": "Sent",

View file

@ -1,6 +1,7 @@
{
"abort_campaign": "Cancelar campaña",
"aborted": "Cancellado",
"aborting": "Abortando",
"action": "Acción",
"add_admin": "Añadir Admin",
"add_admin_description": "Añade un nuevo administrador a esta organización. Los administradores tienen acceso completo a todos los proyectos y ajustes, los miembros sólo pueden acceder a los proyectos de los que forman parte.",
@ -170,7 +171,10 @@
"label": "Etiqueta",
"language": "Idioma",
"last_name": "Apellido",
"launch": "Enviar",
"launch_campaign": "Enviar Campaña",
"launch_period": "Cuando Enviar",
"launch_subtitle": "Verifique que todas las configuraciones sean correctas antes de iniciar una campaña. Una campaña programada se puede cancelar, pero una que se envía de inmediato no.",
"launched_at": "Lanzado en",
"light_mode": "Usar tema de luz",
"link": "Enlace",
@ -210,7 +214,8 @@
"name": "Nombre",
"new_team_member": "Nuevo miembro del equipo",
"no_providers": "Sin proveedores",
"no_template_alert_body": "",
"no_template_alert_body": "Todavía no hay plantillas para esta campaña. Agrega una configuración regional arriba o usa el botón a continuación para comenzar.",
"now": "Ahora",
"onboarding_installation_success": "¡Parece que todo está funcionando con la instalación! ¡Ahora vamos a configurarlo y prepararlo para ejecutar algunas campañas!",
"onboarding_project_setup_description": "En Parcelvoy, los proyectos representan un espacio de trabajo único para el envío de mensajes. Puede utilizarlos para crear entornos de ensayo, aislar diferentes clientes, etc. ¡Vamos a crear tu primero para empezar!",
"onboarding_project_setup_title": "Configuración del proyecto",
@ -245,6 +250,8 @@
"remove": "Eliminar",
"remove_locale_warning": "¿Está seguro de que desea eliminar esta configuración regional? La plantilla no se puede recuperar.",
"reply_to": "Responder a",
"reschedule": "Reprogramar",
"rescheduling": "Reprogramando",
"restart": "Reinicie",
"restart_campaign": "Reiniciar campaña",
"role": "Role",
@ -269,8 +276,13 @@
"second": "Segundo",
"send": "Enviar",
"send_at": "Enviado En",
"send_at_date": "Fecha de envio",
"send_at_time": "Hora de envio",
"send_at_timezone_notice": "La fecha y hora seleccionadas estarán en la zona horaria del proyecto, no en la suya.",
"send_campaign_desc": "Envíe esta campaña cuando los usuarios lleguen a este paso.",
"send_desc": "Activar un envío (email, sms, notificación push, webhook) a un usuario.",
"send_in_user_timezone": "Enviar la Zona Horaria ",
"send_in_user_timezone_desc": "¿La campaña debe salir a la hora seleccionada en la zona horaria de los usuarios o en la zona horaria de los proyectos?",
"send_lists": "Listas para enviar",
"send_proof": "Enviar Prueba",
"sent": "Enviado",

View file

@ -14,6 +14,7 @@ i18n
interpolation: {
escapeValue: false, // react already safes from xss
},
fallbackLng: 'en',
}).catch(() => {})
export default i18n

View file

@ -349,7 +349,7 @@ export interface JourneyEntranceDetail {
userSteps: JourneyUserStep[]
}
export type CampaignState = 'draft' | 'loading' | 'scheduled' | 'running' | 'finished' | 'aborted'
export type CampaignState = 'draft' | 'loading' | 'scheduled' | 'running' | 'finished' | 'aborting' | 'aborted'
export interface CampaignDelivery {
sent: number
@ -389,7 +389,7 @@ export interface Campaign {
updated_at: string
}
export type CampaignSendState = 'pending' | 'throttled' | 'bounced' | 'sent' | 'failed'
export type CampaignSendState = 'pending' | 'sent' | 'throttled' | 'failed' | 'bounced' | 'aborted'
export type CampaignUpdateParams = Partial<Pick<Campaign, 'name' | 'state' | 'list_ids' | 'exclusion_list_ids' | 'subscription_id' | 'tags'>>
export type CampaignCreateParams = Pick<Campaign, 'name' | 'type' | 'list_ids' | 'exclusion_list_ids' | 'channel' | 'subscription_id' | 'provider_id' | 'tags'>

View file

@ -1,7 +1,7 @@
import { useCallback, useContext, useEffect } from 'react'
import api from '../../api'
import { CampaignContext, ProjectContext } from '../../contexts'
import { CampaignDelivery as Delivery, CampaignSendState } from '../../types'
import { CampaignDelivery as Delivery, CampaignSendState, CampaignState } from '../../types'
import Alert from '../../ui/Alert'
import Heading from '../../ui/Heading'
import { PreferencesContext } from '../../ui/PreferencesContext'
@ -19,13 +19,14 @@ export const CampaignSendTag = ({ state }: { state: CampaignSendState }) => {
bounced: 'error',
sent: 'success',
failed: 'error',
aborted: 'warn',
}
return <Tag variant={variant[state]}>
<Translation>{ (t) => t(state) }</Translation>
</Tag>
}
export const CampaignStats = ({ delivery }: { delivery: Delivery }) => {
export const CampaignStats = ({ state, delivery }: { state: CampaignState, delivery: Delivery }) => {
const { t } = useTranslation()
const percent = new Intl.NumberFormat(undefined, { style: 'percent', minimumFractionDigits: 2 })
@ -35,7 +36,7 @@ export const CampaignStats = ({ delivery }: { delivery: Delivery }) => {
const openRate = percent.format(delivery.total ? delivery.opens / delivery.total : 0)
const clickRate = percent.format(delivery.total ? delivery.clicks / delivery.total : 0)
const SentSpan: React.ReactNode = <span>{sent}/<small>{total}</small></span>
const SentSpan: React.ReactNode = <span>{sent}/<small>{state === 'loading' ? `~${total}` : total}</small></span>
return (
<TileGrid numColumns={4}>
@ -60,11 +61,11 @@ export default function CampaignDelivery() {
const refresh = () => {
api.campaigns.get(project.id, campaign.id)
.then(setCampaign)
.then(() => searchState.reload)
.then(searchState.reload)
.catch(() => {})
}
if (state !== 'loading') return
if (!['loading', 'aborting'].includes(state)) return
const complete = progress?.complete ?? 0
const total = progress?.total ?? 0
const percent = total > 0 ? complete / total * 100 : 0
@ -83,7 +84,7 @@ export default function CampaignDelivery() {
{state === 'scheduled'
&& <Alert title={t('scheduled')}>{t('campaign_alert_scheduled')} <strong>{formatDate(preferences, send_at)}</strong></Alert>
}
{delivery && <CampaignStats delivery={delivery} />}
{delivery && <CampaignStats delivery={delivery} state={state} />}
<Heading title={t('users')} size="h4" />
<SearchTable
{...searchState}

View file

@ -57,7 +57,7 @@ export default function CampaignDetail() {
const [project] = useContext(ProjectContext)
const { t } = useTranslation()
const [campaign, setCampaign] = useContext(CampaignContext)
const { name, templates, state, progress } = campaign
const { name, templates, state, send_at, progress } = campaign
const [locale, setLocale] = useState<LocaleSelection>(localeState(templates ?? []))
useEffect(() => {
setLocale(localeState(templates ?? []))
@ -68,6 +68,7 @@ export default function CampaignDetail() {
const handleAbort = async () => {
setIsLoading(true)
const value = await api.campaigns.update(project.id, campaign.id, { state: 'aborted' })
console.log('finished', value)
setCampaign(value)
setIsLoading(false)
}
@ -108,6 +109,19 @@ export default function CampaignDetail() {
onClick={() => setIsLaunchOpen(true)}
>{t('restart_campaign')}</Button>
),
aborting: send_at
? (
<Button
icon={<SendIcon />}
isLoading={true}
>{t('rescheduling')}</Button>
)
: (
<Button
icon={<ForbiddenIcon />}
isLoading={true}
>{t('abort_campaign')}</Button>
),
loading: <></>,
scheduled: (
<>
@ -135,7 +149,11 @@ export default function CampaignDetail() {
return (
<PageContent
title={name}
desc={state !== 'draft' && <CampaignTag state={state} progress={progress} />}
desc={state !== 'draft' && <CampaignTag
state={state}
progress={progress}
send_at={send_at}
/>}
actions={campaign.type !== 'trigger' && action[state]}
fullscreen={true}>
<NavigationTabs tabs={tabs} />

View file

@ -78,7 +78,7 @@ export default function CampaignOverview() {
{campaign.type === 'blast' && <>
<Heading title={t('delivery')} size="h4" />
<InfoTable rows={{
[t('state')]: CampaignTag({ state: campaign.state }),
[t('state')]: CampaignTag({ state: campaign.state, send_at: campaign.send_at }),
[t('launched_at')]: campaign.send_at ? formatDate(preferences, campaign.send_at, undefined, project.timezone) : undefined,
[t('in_timezone')]: campaign.send_in_user_timezone ? 'Yes' : 'No',
[t('send_lists')]: DelimitedLists({ lists: campaign.lists }),

View file

@ -18,10 +18,11 @@ import { ProjectContext } from '../../contexts'
import { PreferencesContext } from '../../ui/PreferencesContext'
import { Translation, useTranslation } from 'react-i18next'
export const CampaignTag = ({ state, progress }: Pick<Campaign, 'state' | 'progress'>) => {
export const CampaignTag = ({ state, progress, send_at }: Pick<Campaign, 'state' | 'progress' | 'send_at'>) => {
const variant: Record<CampaignState, TagVariant> = {
draft: 'plain',
aborted: 'error',
aborting: 'error',
loading: 'info',
scheduled: 'info',
running: 'info',
@ -32,8 +33,11 @@ export const CampaignTag = ({ state, progress }: Pick<Campaign, 'state' | 'progr
const total = progress?.total ?? 0
const percent = total > 0 ? complete / total : 0
const percentStr = percent.toLocaleString(undefined, { style: 'percent', minimumFractionDigits: 0 })
const label = state === 'aborting' && send_at ? 'rescheduling' : state
return <Tag variant={variant[state]}>
<Translation>{ (t) => t(state) }</Translation>
<Translation>{ (t) => t(label) }</Translation>
{progress && ` (${percentStr})`}
</Tag>
}
@ -136,7 +140,7 @@ export default function Campaigns() {
key: 'state',
title: t('state'),
sortable: true,
cell: ({ item: { state } }) => CampaignTag({ state }),
cell: ({ item: { state, send_at } }) => CampaignTag({ state, send_at }),
},
{
key: 'delivery',

View file

@ -13,14 +13,17 @@ import { zonedTimeToUtc } from 'date-fns-tz'
import { Column, Columns } from '../../ui/Columns'
import { useController } from 'react-hook-form'
import { SelectionProps } from '../../ui/form/Field'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
interface DateTimeFieldProps extends SelectionProps<CampaignLaunchParams> {
label: string
required?: boolean
}
function DateTimeField({ label, name, control, required }: DateTimeFieldProps) {
function DateTimeField({ name, control, required }: DateTimeFieldProps) {
const [project] = useContext(ProjectContext)
const { t } = useTranslation()
const [date, setDate] = useState('')
const [time, setTime] = useState('')
@ -55,7 +58,7 @@ function DateTimeField({ label, name, control, required }: DateTimeFieldProps) {
<TextInput<string>
type="date"
name="date"
label={`${label} Date`}
label={t('send_at_date')}
onChange={handleSetDate}
onBlur={handleOnChange}
value={date}
@ -65,7 +68,7 @@ function DateTimeField({ label, name, control, required }: DateTimeFieldProps) {
<TextInput<string>
type="time"
name="time"
label={`${label} Time`}
label={t('send_at_time')}
onChange={handleSetTime}
onBlur={handleOnChange}
value={time}
@ -73,7 +76,7 @@ function DateTimeField({ label, name, control, required }: DateTimeFieldProps) {
</Column>
</Columns>
<span className="label-subtitle">
{"The selected date and time will be in the project's timezone not your own."}
{t('send_at_timezone_notice')}
</span>
</div>
}
@ -85,6 +88,8 @@ interface LaunchCampaignParams {
export default function LaunchCampaign({ open, onClose }: LaunchCampaignParams) {
const [project] = useContext(ProjectContext)
const { t } = useTranslation()
const navigate = useNavigate()
const [campaign, setCampaign] = useContext(CampaignContext)
const [launchType, setLaunchType] = useState('now')
const [error, setError] = useState<string | undefined>()
@ -103,6 +108,7 @@ export default function LaunchCampaign({ open, onClose }: LaunchCampaignParams)
const value = await api.campaigns.update(project.id, campaign.id, params)
setCampaign(value)
onClose(false)
navigate('delivery')
} catch (error: any) {
if (error?.response?.data) {
setError(error?.response?.data?.error)
@ -110,29 +116,29 @@ export default function LaunchCampaign({ open, onClose }: LaunchCampaignParams)
}
}
return <Modal title="Launch Campaign" open={open} onClose={onClose}>
return <Modal title={t('launch_campaign')} open={open} onClose={onClose}>
{error && <Alert variant="error" title="Error">{error}</Alert>}
<p>Please check to ensure all settings are correct before launching a campaign. A scheduled campaign can be aborted, but one sent immediately cannot.</p>
<p>{t('launch_subtitle')}</p>
<FormWrapper<CampaignLaunchParams>
submitLabel="Launch"
submitLabel={t(campaign.send_at ? 'reschedule' : 'launch')}
onSubmit={handleLaunchCampaign}>
{form => <>
<RadioInput
label="Launch Period"
options={[{ key: 'now', label: 'Now' }, { key: 'later', label: 'Schedule' }]}
label={t('launch_period')}
options={[{ key: 'now', label: t('now') }, { key: 'later', label: t('schedule') }]}
value={launchType}
onChange={setLaunchType} />
{launchType === 'later' && <>
<DateTimeField
control={form.control}
name="send_at"
label="Send At"
label={t('Send At')}
required />
<SwitchField
form={form}
name="send_in_user_timezone"
label="Send In Users Timezone"
subtitle="Should the campaign go out at the selected time in the users timezone or in the projects timezone?" />
label={t('send_in_user_timezone')}
subtitle={t('send_in_user_timezone_desc')} />
</>}
</>}
</FormWrapper>

View file

@ -35,7 +35,7 @@ export default function Settings() {
toast.success('Saved organization settings')
}}
submitLabel="Save Settings"
submitLabel={t('save_settings')}
>
{form => <>
<Heading size="h3" title={t('general')} />