mirror of
https://fast.feibisi.com/https://github.com/parcelvoy/platform.git
synced 2025-09-01 12:26:08 +08:00
Adds more campaign delivery stats (#117)
* Adds more campaign delivery stats * Minor bug fixes
This commit is contained in:
parent
b3b3e7d1e2
commit
c339f798c5
18 changed files with 178 additions and 55 deletions
|
@ -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')
|
||||
})
|
||||
}
|
|
@ -5,10 +5,12 @@ import List from '../lists/List'
|
|||
import Template from '../render/Template'
|
||||
import Subscription from '../subscriptions/Subscription'
|
||||
|
||||
export type CampaignState = 'draft' | 'pending' | 'scheduled' | 'running' | 'finished' | 'aborted'
|
||||
export type CampaignState = 'draft' | 'scheduled' | 'pending' | 'running' | 'finished' | 'aborted'
|
||||
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'>
|
||||
|
|
40
apps/platform/src/campaigns/CampaignInteractJob.ts
Normal file
40
apps/platform/src/campaigns/CampaignInteractJob.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
sent: parseInt(progress.sent ?? 0),
|
||||
pending: parseInt(progress.pending ?? 0),
|
||||
total: parseInt(progress.total ?? 0),
|
||||
opens: parseInt(progress.opens ?? 0),
|
||||
clicks: parseInt(progress.clicks ?? 0),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
])
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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}
|
||||
>
|
||||
|
|
|
@ -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%;
|
||||
}
|
||||
}
|
|
@ -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}`)}
|
||||
/>
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue