From c339f798c58abcdf21362dfb978968313060d905 Mon Sep 17 00:00:00 2001 From: Chris Anderson Date: Fri, 7 Apr 2023 18:07:50 -0400 Subject: [PATCH] Adds more campaign delivery stats (#117) * Adds more campaign delivery stats * Minor bug fixes --- .../20230407162219_add_campaign_stats.js | 13 ++++++ apps/platform/src/campaigns/Campaign.ts | 8 +++- .../src/campaigns/CampaignInteractJob.ts | 40 +++++++++++++++++++ .../platform/src/campaigns/CampaignSendJob.ts | 5 --- .../platform/src/campaigns/CampaignService.ts | 27 ++++++++++--- .../src/campaigns/CampaignStateJob.ts | 6 +-- apps/platform/src/config/queue.ts | 2 + apps/platform/src/config/scheduler.ts | 2 + .../src/providers/email/EmailChannel.ts | 5 ++- .../src/providers/email/SESEmailProvider.ts | 26 +++++------- apps/platform/src/render/LinkService.ts | 23 +++++++++-- apps/ui/docker/nginx/conf.d/default.conf | 2 +- apps/ui/src/types.ts | 12 ++++-- apps/ui/src/ui/Sidebar.css | 1 + apps/ui/src/ui/Tile.css | 15 +++++-- apps/ui/src/ui/Tile.tsx | 4 +- apps/ui/src/views/auth/Auth.css | 11 +++++ .../src/views/campaign/CampaignDelivery.tsx | 31 ++++++++++---- 18 files changed, 178 insertions(+), 55 deletions(-) create mode 100644 apps/platform/db/migrations/20230407162219_add_campaign_stats.js create mode 100644 apps/platform/src/campaigns/CampaignInteractJob.ts diff --git a/apps/platform/db/migrations/20230407162219_add_campaign_stats.js b/apps/platform/db/migrations/20230407162219_add_campaign_stats.js new file mode 100644 index 00000000..c7da2d4f --- /dev/null +++ b/apps/platform/db/migrations/20230407162219_add_campaign_stats.js @@ -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') + }) +} diff --git a/apps/platform/src/campaigns/Campaign.ts b/apps/platform/src/campaigns/Campaign.ts index 2ce27ede..75efc3cf 100644 --- a/apps/platform/src/campaigns/Campaign.ts +++ b/apps/platform/src/campaigns/Campaign.ts @@ -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 export type CampaignUpdateParams = Omit -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 diff --git a/apps/platform/src/campaigns/CampaignInteractJob.ts b/apps/platform/src/campaigns/CampaignInteractJob.ts new file mode 100644 index 00000000..9fcfe841 --- /dev/null +++ b/apps/platform/src/campaigns/CampaignInteractJob.ts @@ -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 = { 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) + } + } +} diff --git a/apps/platform/src/campaigns/CampaignSendJob.ts b/apps/platform/src/campaigns/CampaignSendJob.ts index a386fe82..9afebcb9 100644 --- a/apps/platform/src/campaigns/CampaignSendJob.ts +++ b/apps/platform/src/campaigns/CampaignSendJob.ts @@ -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()) - }) } } diff --git a/apps/platform/src/campaigns/CampaignService.ts b/apps/platform/src/campaigns/CampaignService.ts index 8e14e265..096ef39e 100644 --- a/apps/platform/src/campaigns/CampaignService.ts +++ b/apps/platform/src/campaigns/CampaignService.ts @@ -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 => { 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 => { 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) => { + await CampaignSend.update( + qb => qb.where('id', id), + update, + ) +} diff --git a/apps/platform/src/campaigns/CampaignStateJob.ts b/apps/platform/src/campaigns/CampaignStateJob.ts index b70cfcf8..b95504a7 100644 --- a/apps/platform/src/campaigns/CampaignStateJob.ts +++ b/apps/platform/src/campaigns/CampaignStateJob.ts @@ -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 }) } } } diff --git a/apps/platform/src/config/queue.ts b/apps/platform/src/config/queue.ts index 4f57d567..0b09dc8b 100644 --- a/apps/platform/src/config/queue.ts +++ b/apps/platform/src/config/queue.ts @@ -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 export const loadJobs = (queue: Queue) => { queue.register(CampaignGenerateListJob) + queue.register(CampaignInteractJob) queue.register(CampaignTriggerJob) queue.register(CampaignSendJob) queue.register(CampaignStateJob) diff --git a/apps/platform/src/config/scheduler.ts b/apps/platform/src/config/scheduler.ts index 32663971..8b5e893e 100644 --- a/apps/platform/src/config/scheduler.ts +++ b/apps/platform/src/config/scheduler.ts @@ -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, }) diff --git a/apps/platform/src/providers/email/EmailChannel.ts b/apps/platform/src/providers/email/EmailChannel.ts index 6d08d568..d3a8c824 100644 --- a/apps/platform/src/providers/email/EmailChannel.ts +++ b/apps/platform/src/providers/email/EmailChannel.ts @@ -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) diff --git a/apps/platform/src/providers/email/SESEmailProvider.ts b/apps/platform/src/providers/email/SESEmailProvider.ts index fa740b74..f550b92b 100644 --- a/apps/platform/src/providers/email/SESEmailProvider.ts +++ b/apps/platform/src/providers/email/SESEmailProvider.ts @@ -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 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) } } diff --git a/apps/platform/src/render/LinkService.ts b/apps/platform/src/render/LinkService.ts index 164f0625..28364c39 100644 --- a/apps/platform/src/render/LinkService.ts +++ b/apps/platform/src/render/LinkService.ts @@ -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, + 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, + ]) } diff --git a/apps/ui/docker/nginx/conf.d/default.conf b/apps/ui/docker/nginx/conf.d/default.conf index faecb202..58a2d94e 100644 --- a/apps/ui/docker/nginx/conf.d/default.conf +++ b/apps/ui/docker/nginx/conf.d/default.conf @@ -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; } diff --git a/apps/ui/src/types.ts b/apps/ui/src/types.ts index 40218270..1f4566e8 100644 --- a/apps/ui/src/types.ts +++ b/apps/ui/src/types.ts @@ -265,16 +265,20 @@ export interface JourneyStepType { 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 diff --git a/apps/ui/src/ui/Sidebar.css b/apps/ui/src/ui/Sidebar.css index 5ed448b2..3ff20ef9 100644 --- a/apps/ui/src/ui/Sidebar.css +++ b/apps/ui/src/ui/Sidebar.css @@ -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; diff --git a/apps/ui/src/ui/Tile.css b/apps/ui/src/ui/Tile.css index 72a63c58..c74da458 100644 --- a/apps/ui/src/ui/Tile.css +++ b/apps/ui/src/ui/Tile.css @@ -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; } } \ No newline at end of file diff --git a/apps/ui/src/ui/Tile.tsx b/apps/ui/src/ui/Tile.tsx index 88e5a189..69a34839 100644 --- a/apps/ui/src/ui/Tile.tsx +++ b/apps/ui/src/ui/Tile.tsx @@ -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 (
diff --git a/apps/ui/src/views/auth/Auth.css b/apps/ui/src/views/auth/Auth.css index 1d682605..81633233 100644 --- a/apps/ui/src/views/auth/Auth.css +++ b/apps/ui/src/views/auth/Auth.css @@ -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%; + } +} \ No newline at end of file diff --git a/apps/ui/src/views/campaign/CampaignDelivery.tsx b/apps/ui/src/views/campaign/CampaignDelivery.tsx index e25e736e..53405418 100644 --- a/apps/ui/src/views/campaign/CampaignDelivery.tsx +++ b/apps/ui/src/views/campaign/CampaignDelivery.tsx @@ -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 }) => { } +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 ( + + Total sent + Delivery Rate + Open Rate + Click Rate + + ) +} + export default function CampaignDelivery() { const [project] = useContext(ProjectContext) const [preferences] = useContext(PreferencesContext) @@ -38,11 +57,7 @@ export default function CampaignDelivery() { {state === 'scheduled' && This campaign is pending delivery. It will begin to roll out at {formatDate(preferences, send_at)} } - - + {delivery && } CampaignSendTag({ state }), }, { key: 'send_at' }, + { key: 'opened_at' }, + { key: 'clicks' }, ]} onSelectRow={({ id }) => route(`users/${id}`)} />