Adds more campaign delivery stats

This commit is contained in:
Chris Anderson 2023-04-07 14:38:17 -04:00
parent b3b3e7d1e2
commit 2b437197b1
18 changed files with 174 additions and 51 deletions

View file

@ -0,0 +1,13 @@
exports.up = async function(knex) {
await knex.schema.table('campaign_sends', function(table) {
table.integer('clicks').defaultTo(0).after('send_at')
table.timestamp('opened_at').nullable().after('send_at')
})
}
exports.down = async function(knex) {
await knex.schema.table('campaign_sends', function(table) {
table.dropColumn('clicks')
table.dropColumn('opened_at')
})
}

View file

@ -9,6 +9,8 @@ export type CampaignState = 'draft' | 'pending' | 'scheduled' | 'running' | 'fin
export interface CampaignDelivery {
sent: number
total: number
opens: number
clicks: number
}
export type CampaignProgress = CampaignDelivery & { pending: number }
@ -44,12 +46,14 @@ export type CampaignParams = Omit<Campaign, ModelParams | 'delivery' | 'screensh
export type CampaignCreateParams = Omit<CampaignParams, 'state'>
export type CampaignUpdateParams = Omit<CampaignParams, 'channel'>
export type CampaignSendState = 'pending' | 'sent' | 'failed' | 'aborted'
export type CampaignSendState = 'pending' | 'sent' | 'failed' | 'bounced' | 'aborted'
export class CampaignSend extends Model {
campaign_id!: number
user_id!: number
state!: CampaignSendState
send_at!: string | Date
opened_at!: string | Date
clicks!: number
}
export type CampaignSendParams = Pick<CampaignSend, 'campaign_id' | 'user_id' | 'state' | 'send_at'>

View file

@ -0,0 +1,40 @@
import { Job } from '../queue'
import { unsubscribe } from '../subscriptions/SubscriptionService'
import { CampaignSend } from './Campaign'
import { getCampaignSend, updateCampaignSend } from './CampaignService'
interface CampaignIteraction {
user_id: number
campaign_id: number
subscription_id?: number
interaction: 'clicked' | 'opened' | 'bounced' | 'complained'
}
export default class CampaignInteractJob extends Job {
static $name = 'campaign_interact_job'
static from(data: CampaignIteraction): CampaignInteractJob {
return new this(data)
}
static async handler({ campaign_id, user_id, subscription_id, interaction }: CampaignIteraction) {
const send = await getCampaignSend(campaign_id, user_id)
if (!send) return
if (interaction === 'opened' && !send.opened_at) {
await updateCampaignSend(send.id, { opened_at: new Date() })
}
if (interaction === 'clicked') {
const updates: Partial<CampaignSend> = { clicks: ++send.clicks }
if (!send.opened_at) {
updates.opened_at = new Date()
}
await updateCampaignSend(send.id, updates)
}
if (subscription_id && (interaction === 'bounced' || interaction === 'complained')) {
await unsubscribe(user_id, subscription_id)
}
}
}

View file

@ -1,6 +1,4 @@
import App from '../app'
import { Job } from '../queue'
import CampaignStateJob from './CampaignStateJob'
import { campaignSendReadyQuery, getCampaign, sendCampaign } from './CampaignService'
import { CampaignJobParams } from './Campaign'
@ -21,8 +19,5 @@ export default class CampaignSendJob extends Job {
await sendCampaign(campaign, user_id)
}
})
.then(() => {
App.main.queue.enqueue(CampaignStateJob.from())
})
}
}

View file

@ -66,7 +66,7 @@ export const createCampaign = async (projectId: number, { tags, ...params }: Cam
throw new RequestError('Unable to find associated subscription', 404)
}
const delivery = { sent: 0, total: 0 }
const delivery = { sent: 0, total: 0, opens: 0, clicks: 0 }
if (params.list_ids) {
delivery.total = await totalUsersCount(
params.list_ids,
@ -164,7 +164,7 @@ export const getCampaignUsers = async (id: number, params: SearchParams, project
b => b.rightJoin('campaign_sends', 'campaign_sends.user_id', 'users.id')
.where('project_id', projectId)
.where('campaign_id', id)
.select('users.*', 'state', 'send_at'),
.select('users.*', 'state', 'send_at', 'opened_at', 'clicks'),
)
}
@ -228,6 +228,8 @@ export const generateSendList = async (campaign: SentCampaign) => {
campaign.list_ids,
campaign.exclusion_list_ids ?? [],
),
opens: 0,
clicks: 0,
},
})
@ -343,12 +345,14 @@ const totalUsersCount = async (listIds: number[], exclusionListIds: number[]): P
export const campaignProgress = async (campaign: Campaign): Promise<CampaignProgress> => {
const progress = await CampaignSend.query()
.where('campaign_id', campaign.id)
.select(CampaignSend.raw("SUM(IF(state = 'sent', 1, 0)) AS sent, SUM(IF(state = 'pending', 1, 0)) AS pending, COUNT(*) AS total"))
.select(CampaignSend.raw("SUM(IF(state = 'sent', 1, 0)) AS sent, SUM(IF(state = 'pending', 1, 0)) AS pending, COUNT(*) AS total, SUM(IF(opened_at IS NOT NULL, 1, 0)) AS opens, SUM(IF(clicks > 0, 1, 0)) AS clicks"))
.first()
return {
sent: parseInt(progress.sent),
pending: parseInt(progress.pending),
total: parseInt(progress.total),
opens: parseInt(progress.opens),
clicks: parseInt(progress.clicks),
}
}
@ -360,3 +364,14 @@ export const updateCampaignProgress = async (
): Promise<void> => {
await Campaign.update(qb => qb.where('id', id).where('project_id', projectId), { state, delivery })
}
export const getCampaignSend = async (campaignId: number, userId: number) => {
return CampaignSend.first(qb => qb.where('campaign_id', campaignId).where('user_id', userId))
}
export const updateCampaignSend = async (id: number, update: Partial<CampaignSend>) => {
await CampaignSend.update(
qb => qb.where('id', id),
update,
)
}

View file

@ -7,10 +7,10 @@ export default class CampaignStateJob extends Job {
static async handler() {
const campaigns = await Campaign.query()
.whereIn('state', ['running'])
.whereIn('state', ['running', 'finished'])
for (const campaign of campaigns) {
const { sent, pending, total } = await campaignProgress(campaign)
await updateCampaignProgress(campaign.id, campaign.project_id, pending <= 0 ? 'finished' : campaign.state, { sent, total })
const { sent, pending, total, opens, clicks } = await campaignProgress(campaign)
await updateCampaignProgress(campaign.id, campaign.project_id, pending <= 0 ? 'finished' : campaign.state, { sent, total, opens, clicks })
}
}
}

View file

@ -15,11 +15,13 @@ import ProcessListsJob from '../lists/ProcessListsJob'
import CampaignSendJob from '../campaigns/CampaignSendJob'
import CampaignStateJob from '../campaigns/CampaignStateJob'
import CampaignGenerateListJob from '../campaigns/CampaignGenerateListJob'
import CampaignInteractJob from '../campaigns/CampaignInteractJob'
export type Queues = Record<number, Queue>
export const loadJobs = (queue: Queue) => {
queue.register(CampaignGenerateListJob)
queue.register(CampaignInteractJob)
queue.register(CampaignTriggerJob)
queue.register(CampaignSendJob)
queue.register(CampaignStateJob)

View file

@ -7,6 +7,7 @@ import JourneyDelayJob from '../journey/JourneyDelayJob'
import ProcessListsJob from '../lists/ProcessListsJob'
import Model from '../core/Model'
import { sleep, randomInt } from '../utilities'
import CampaignStateJob from '../campaigns/CampaignStateJob'
export default async (app: App) => {
const scheduler = new Scheduler(app)
@ -22,6 +23,7 @@ export default async (app: App) => {
rule: '*/5 * * * *',
callback: () => {
app.queue.enqueue(ProcessListsJob.from())
app.queue.enqueue(CampaignStateJob.from())
},
lockLength: 360,
})

View file

@ -1,5 +1,6 @@
import { Variables, Wrap } from '../../render'
import { EmailTemplate } from '../../render/Template'
import { encodeHashid } from '../../utilities'
import { Email } from './Email'
import EmailProvider from './EmailProvider'
@ -29,8 +30,8 @@ export default class EmailChannel {
variables,
}), // Add link and open tracking
headers: {
'X-Campaign-Id': variables.context.campaign_id,
'X-Subscription-Id': variables.context.subscription_id,
'X-Campaign-Id': encodeHashid(variables.context.campaign_id),
'X-Subscription-Id': encodeHashid(variables.context.subscription_id),
},
}
await this.provider.send(email)

View file

@ -5,11 +5,11 @@ import EmailProvider from './EmailProvider'
import Router = require('@koa/router')
import Provider, { ExternalProviderParams, ProviderControllers, ProviderSchema } from '../Provider'
import { createController } from '../ProviderService'
import { createEvent } from '../../users/UserEventRepository'
import { secondsAgo } from '../../utilities'
import { decodeHashid, secondsAgo } from '../../utilities'
import { getUserFromEmail } from '../../users/UserRepository'
import { unsubscribe } from '../../subscriptions/SubscriptionService'
import { RequestError } from '../../core/errors'
import { getCampaign } from '../../campaigns/CampaignService'
import { trackLinkEvent } from '../../render/LinkService'
interface SESDataParams {
config: AWSConfig
@ -97,25 +97,17 @@ export default class SESEmailProvider extends EmailProvider {
const json = JSON.parse(message) as Record<string, any>
const { notificationType, mail: { destination, headers } } = json
const email: string | undefined = destination[0]
const subscriptionId = getHeader(headers, 'X-Subscription-Id')
const campaignId = getHeader(headers, 'X-Campaign-Id')
const subscriptionId = decodeHashid(getHeader(headers, 'X-Subscription-Id'))
const campaignId = decodeHashid(getHeader(headers, 'X-Campaign-Id'))
if (!email || !subscriptionId) return
if (!email || !subscriptionId || !campaignId) return
const user = await getUserFromEmail(projectId, email)
if (!user) return
const name = notificationType === 'Bounce' ? 'bounce' : 'complaint'
await createEvent(user, {
name,
data: {
subscription_id: subscriptionId,
campaign_id: campaignId,
},
})
const campaign = await getCampaign(campaignId, projectId)
if (name === 'bounce' && subscriptionId) {
await unsubscribe(user.id, parseInt(subscriptionId))
}
const interaction = notificationType === 'Bounce' ? 'bounced' : 'complained'
await trackLinkEvent({ user, campaign }, interaction)
}
}

View file

@ -1,6 +1,7 @@
import { URL } from 'node:url'
import App from '../app'
import Campaign from '../campaigns/Campaign'
import CampaignInteractJob from '../campaigns/CampaignInteractJob'
import { getCampaign } from '../campaigns/CampaignService'
import EventPostJob from '../client/EventPostJob'
import { User } from '../users/User'
@ -106,22 +107,36 @@ export const injectInBody = (html: string, injection: string, placement: 'start'
return html
}
export const trackLinkEvent = async (parts: TrackedLinkExport, eventName: string) => {
export const trackLinkEvent = async (
parts: Partial<TrackedLinkExport>,
interaction: 'opened' | 'clicked' | 'bounced' | 'complained',
) => {
const { user, campaign } = parts
if (!user || !campaign) return
const job = EventPostJob.from({
const eventJob = EventPostJob.from({
project_id: user.project_id,
event: {
external_id: user.external_id,
name: eventName,
name: interaction,
data: {
campaign_id: campaign.id,
channel: campaign.channel,
url: parts.redirect,
subscription_id: campaign.subscription_id,
},
},
})
await App.main.queue.enqueue(job)
const campaignJob = CampaignInteractJob.from({
campaign_id: campaign.id,
user_id: user.id,
subscription_id: campaign.subscription_id,
interaction,
})
await App.main.queue.enqueueBatch([
eventJob,
campaignJob,
])
}

View file

@ -7,7 +7,7 @@ server {
try_files $uri $uri/ /index.html;
}
location ~ ^\/(api|uploads|.well-known) {
location ~ ^\/(api|uploads|.well-known|c|o) {
proxy_pass http://api:3001;
}

View file

@ -265,16 +265,20 @@ export interface JourneyStepType<T = any, E = any> {
export type CampaignState = 'draft' | 'pending' | 'scheduled' | 'running' | 'finished' | 'aborted'
export interface CampaignDelivery {
sent: number
total: number
opens: number
clicks: number
}
export interface Campaign {
id: number
project_id: number
name: string
channel: ChannelType
state: CampaignState
delivery: {
sent: number
total: number
}
delivery: CampaignDelivery
provider_id: number
provider: Provider
subscription_id?: number

View file

@ -4,6 +4,7 @@
left: 0;
bottom: 0;
width: 225px;
background: var(--color-background);
border-right: 1px solid var(--color-grey);
display: flex;
flex-direction: column;

View file

@ -9,13 +9,13 @@
border-radius: var(--border-radius);
padding: 20px;
transition: box-shadow ease-in .1s;
cursor: pointer;
display: flex;
gap: 10px;
align-items: center;
}
.ui-tile:hover {
.ui-tile.interactive { cursor: pointer; }
.ui-tile.interactive:hover {
box-shadow: 0 3px 10px rgba(0,0,0,0.1);
}
@ -44,8 +44,17 @@
color: var(--color-primary-soft);
}
.ui-tile.large {
padding: 30px 20px;
}
.ui-tile.large h5 {
font-size: 30px;
margin: 0 0 10px;
}
@media only screen and (max-width: 600px) {
.ui-tile-grid {
grid-template-columns: 1fr;
grid-template-columns: 1fr !important;
}
}

View file

@ -7,6 +7,7 @@ type TileProps = PropsWithChildren<{
selected?: boolean
iconUrl?: string
title: ReactNode
size?: 'large' | 'regular'
}> & JSX.IntrinsicElements['div']
export default function Tile({
@ -16,12 +17,13 @@ export default function Tile({
className,
iconUrl,
title,
size = 'regular',
...rest
}: TileProps) {
return (
<div
{...rest}
className={clsx(className, 'ui-tile', { selected })}
className={clsx(className, 'ui-tile', { selected, interactive: onClick !== undefined }, size)}
onClick={onClick}
tabIndex={0}
>

View file

@ -29,3 +29,14 @@
.auth-step .form {
padding: 10px 0;
}
@media only screen and (max-width: 600px) {
.auth {
padding: 40px 20px;
}
.auth-step {
min-width: auto;
width: 100%;
}
}

View file

@ -1,13 +1,13 @@
import { useCallback, useContext } from 'react'
import api from '../../api'
import { CampaignContext, ProjectContext } from '../../contexts'
import { CampaignSendState } from '../../types'
import { CampaignDelivery as Delivery, CampaignSendState } from '../../types'
import Alert from '../../ui/Alert'
import Heading from '../../ui/Heading'
import { InfoTable } from '../../ui/InfoTable'
import { PreferencesContext } from '../../ui/PreferencesContext'
import { SearchTable, useSearchTableState } from '../../ui/SearchTable'
import Tag, { TagVariant } from '../../ui/Tag'
import Tile, { TileGrid } from '../../ui/Tile'
import { formatDate, snakeToTitle } from '../../utils'
import { useRoute } from '../router'
@ -23,6 +23,25 @@ export const CampaignSendTag = ({ state }: { state: CampaignSendState }) => {
</Tag>
}
export const CampaignStats = ({ delivery }: { delivery: Delivery }) => {
const percent = new Intl.NumberFormat(undefined, { style: 'percent', minimumFractionDigits: 2 })
const sent = delivery.sent.toLocaleString()
const deliveryRate = percent.format(delivery.sent / delivery.total)
const openRate = percent.format(delivery.opens / delivery.total)
const clickRate = percent.format(delivery.clicks / delivery.total)
return (
<TileGrid numColumns={4}>
<Tile title={sent} size="large">Total sent</Tile>
<Tile title={deliveryRate} size="large">Delivery Rate</Tile>
<Tile title={openRate} size="large">Open Rate</Tile>
<Tile title={clickRate} size="large">Click Rate</Tile>
</TileGrid>
)
}
export default function CampaignDelivery() {
const [project] = useContext(ProjectContext)
const [preferences] = useContext(PreferencesContext)
@ -38,11 +57,7 @@ export default function CampaignDelivery() {
{state === 'scheduled'
&& <Alert title="Scheduled">This campaign is pending delivery. It will begin to roll out at <strong>{formatDate(preferences, send_at)}</strong></Alert>
}
<InfoTable rows={{
Sent: delivery?.sent,
Total: delivery?.total,
}} direction="horizontal" />
{delivery && <CampaignStats delivery={delivery} />}
<Heading title="Users" size="h4" />
<SearchTable
{...searchState}
@ -55,6 +70,8 @@ export default function CampaignDelivery() {
cell: ({ item: { state } }) => CampaignSendTag({ state }),
},
{ key: 'send_at' },
{ key: 'opened_at' },
{ key: 'clicks' },
]}
onSelectRow={({ id }) => route(`users/${id}`)}
/>