mirror of
https://fast.feibisi.com/https://github.com/parcelvoy/platform.git
synced 2025-08-28 11:46:02 +08:00
Break campaign generation into additional chunks (#643)
This commit is contained in:
parent
456a97851e
commit
d728ed2523
12 changed files with 206 additions and 80 deletions
33
.github/workflows/publish.yml
vendored
33
.github/workflows/publish.yml
vendored
|
@ -1,33 +0,0 @@
|
||||||
name: Publish Packages
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "v*.*.*"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-package:
|
|
||||||
name: "Publish to GitHub Packages"
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
if: github.repository_owner == 'parcelvoy'
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- name: Use Node.js 20.x
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20.x
|
|
||||||
registry-url: https://registry.npmjs.org
|
|
||||||
- name: Cache NPM
|
|
||||||
uses: actions/cache@v4
|
|
||||||
with:
|
|
||||||
path: ~/.npm
|
|
||||||
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-node-
|
|
||||||
- run: npm ci
|
|
||||||
- run: npm run package:publish --tag=$(echo ${GITHUB_REF_NAME:1})
|
|
||||||
env:
|
|
||||||
NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
|
|
|
@ -65,6 +65,7 @@
|
||||||
"docker:build": "docker buildx build -f ./Dockerfile -t ghcr.io/parcelvoy/api:latest -t ghcr.io/parcelvoy/api:$npm_config_tag ../../",
|
"docker:build": "docker buildx build -f ./Dockerfile -t ghcr.io/parcelvoy/api:latest -t ghcr.io/parcelvoy/api:$npm_config_tag ../../",
|
||||||
"docker:build:push": "npm run docker:build -- --push",
|
"docker:build:push": "npm run docker:build -- --push",
|
||||||
"migration:create": "node ./scripts/create-migration.mjs",
|
"migration:create": "node ./scripts/create-migration.mjs",
|
||||||
|
"migration:output": "node ./scripts/output-migration.mjs",
|
||||||
"package:publish": "npm run build && npm version $npm_config_tag --no-git-tag-version && npm pack && npm publish --access public"
|
"package:publish": "npm run build && npm version $npm_config_tag --no-git-tag-version && npm pack && npm publish --access public"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
49
apps/platform/scripts/output-migration.mjs
Normal file
49
apps/platform/scripts/output-migration.mjs
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
import knex from 'knex'
|
||||||
|
|
||||||
|
const connection = knex({
|
||||||
|
client: process.env.DB_CLIENT ?? 'mysql2',
|
||||||
|
connection: {
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
user: process.env.DB_USERNAME,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
port: process.env.DB_PORT,
|
||||||
|
database: process.env.DB_DATABASE,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const name = process.argv[2]
|
||||||
|
if (!name) {
|
||||||
|
console.log('migration: please include a migration to output')
|
||||||
|
process.exit(9)
|
||||||
|
}
|
||||||
|
|
||||||
|
const logRaw = (sql) => {
|
||||||
|
const end = sql.charAt(sql.length - 1)
|
||||||
|
console.log(end !== ';' ? sql + ';' : sql)
|
||||||
|
}
|
||||||
|
|
||||||
|
const migration = await import(`../db/migrations/${name}`)
|
||||||
|
const method = (type) => {
|
||||||
|
return (name, query) => {
|
||||||
|
const schema = connection.schema
|
||||||
|
const result = schema[type](name, query)
|
||||||
|
logRaw(result.toString())
|
||||||
|
return options.schema
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
schema: {
|
||||||
|
table: method('table'),
|
||||||
|
createTable: method('createTable'),
|
||||||
|
alterTable: method('alterTable'),
|
||||||
|
dropTable: method('dropTable'),
|
||||||
|
},
|
||||||
|
raw: (query) => logRaw(query),
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('up')
|
||||||
|
migration.up(options).then(() => {
|
||||||
|
console.log('down')
|
||||||
|
migration.down(options)
|
||||||
|
})
|
|
@ -8,7 +8,7 @@ import { crossTimezoneCopy } from '../utilities'
|
||||||
import Project from '../projects/Project'
|
import Project from '../projects/Project'
|
||||||
import { User } from '../users/User'
|
import { User } from '../users/User'
|
||||||
|
|
||||||
export type CampaignState = 'draft' | 'scheduled' | 'pending' | 'running' | 'finished' | 'aborted'
|
export type CampaignState = 'draft' | 'scheduled' | 'loading' | 'running' | 'finished' | 'aborted'
|
||||||
export interface CampaignDelivery {
|
export interface CampaignDelivery {
|
||||||
sent: number
|
sent: number
|
||||||
total: number
|
total: number
|
||||||
|
@ -37,6 +37,7 @@ export default class Campaign extends Model {
|
||||||
state!: CampaignState
|
state!: CampaignState
|
||||||
delivery!: CampaignDelivery
|
delivery!: CampaignDelivery
|
||||||
tags?: string[]
|
tags?: string[]
|
||||||
|
progress?: CampaignPopulationProgress
|
||||||
|
|
||||||
send_in_user_timezone?: boolean
|
send_in_user_timezone?: boolean
|
||||||
send_at?: string | Date | null
|
send_at?: string | Date | null
|
||||||
|
@ -50,9 +51,14 @@ export default class Campaign extends Model {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type CampaignPopulationProgress = {
|
||||||
|
complete: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
export type SentCampaign = Campaign & { send_at: Date }
|
export type SentCampaign = Campaign & { send_at: Date }
|
||||||
|
|
||||||
export type CampaignParams = Omit<Campaign, ModelParams | 'delivery' | 'eventName' | 'templates' | 'lists' | 'exclusion_lists' | 'subscription' | 'provider' | 'deleted_at'>
|
export type CampaignParams = Omit<Campaign, ModelParams | 'delivery' | 'eventName' | 'templates' | 'lists' | 'exclusion_lists' | 'subscription' | 'provider' | 'deleted_at' | 'progress'>
|
||||||
export type CampaignCreateParams = Omit<CampaignParams, 'state'>
|
export type CampaignCreateParams = Omit<CampaignParams, 'state'>
|
||||||
export type CampaignUpdateParams = Omit<CampaignParams, 'channel' | 'type'>
|
export type CampaignUpdateParams = Omit<CampaignParams, 'channel' | 'type'>
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
|
import App from '../app'
|
||||||
import { logger } from '../config/logger'
|
import { logger } from '../config/logger'
|
||||||
|
import { cacheSet } from '../config/redis'
|
||||||
import { acquireLock, releaseLock } from '../core/Lock'
|
import { acquireLock, releaseLock } from '../core/Lock'
|
||||||
import { Job } from '../queue'
|
import { Job } from '../queue'
|
||||||
import { CampaignJobParams, SentCampaign } from './Campaign'
|
import { CampaignJobParams, SentCampaign } from './Campaign'
|
||||||
import CampaignEnqueueSendsJob from './CampaignEnqueueSendsJob'
|
import CampaignEnqueueSendsJob from './CampaignEnqueueSendsJob'
|
||||||
import { estimatedSendSize, generateSendList, getCampaign } from './CampaignService'
|
import { CacheKeys, estimatedSendSize, generateSendList, getCampaign } from './CampaignService'
|
||||||
|
|
||||||
export default class CampaignGenerateListJob extends Job {
|
export default class CampaignGenerateListJob extends Job {
|
||||||
static $name = 'campaign_generate_list_job'
|
static $name = 'campaign_generate_list_job'
|
||||||
|
@ -20,8 +22,14 @@ export default class CampaignGenerateListJob extends Job {
|
||||||
if (!campaign) return
|
if (!campaign) return
|
||||||
if (campaign.state === 'aborted' || campaign.state === 'draft') return
|
if (campaign.state === 'aborted' || campaign.state === 'draft') return
|
||||||
|
|
||||||
// Increase lock duration based on estimated send size
|
// Approximate the size of the send list
|
||||||
const estimatedSize = await estimatedSendSize(campaign)
|
const estimatedSize = await estimatedSendSize(campaign)
|
||||||
|
|
||||||
|
// Use approximate size for progress
|
||||||
|
await cacheSet<number>(App.main.redis, CacheKeys.populationTotal(campaign), estimatedSize, 86400)
|
||||||
|
await cacheSet<number>(App.main.redis, CacheKeys.populationProgress(campaign), 0, 86400)
|
||||||
|
|
||||||
|
// Increase lock duration based on estimated send size
|
||||||
const lockTime = Math.ceil(Math.max(estimatedSize / 1000, 900))
|
const lockTime = Math.ceil(Math.max(estimatedSize / 1000, 900))
|
||||||
logger.info({ id, estimatedSize, lockTime }, 'campaign:generate:estimated_size')
|
logger.info({ id, estimatedSize, lockTime }, 'campaign:generate:estimated_size')
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,7 @@ import TextJob from '../providers/text/TextJob'
|
||||||
import EmailJob from '../providers/email/EmailJob'
|
import EmailJob from '../providers/email/EmailJob'
|
||||||
import { User } from '../users/User'
|
import { User } from '../users/User'
|
||||||
import { UserEvent } from '../users/UserEvent'
|
import { UserEvent } from '../users/UserEvent'
|
||||||
import Campaign, { CampaignCreateParams, CampaignDelivery, CampaignParams, CampaignProgress, CampaignSend, CampaignSendParams, CampaignSendReferenceType, CampaignSendState, SentCampaign } from './Campaign'
|
import Campaign, { CampaignCreateParams, CampaignDelivery, CampaignParams, CampaignPopulationProgress, CampaignProgress, CampaignSend, CampaignSendParams, CampaignSendReferenceType, CampaignSendState, SentCampaign } from './Campaign'
|
||||||
import List, { UserList } from '../lists/List'
|
import List, { UserList } from '../lists/List'
|
||||||
import Subscription, { SubscriptionState, UserSubscription } from '../subscriptions/Subscription'
|
import Subscription, { SubscriptionState, UserSubscription } from '../subscriptions/Subscription'
|
||||||
import { RequestError } from '../core/errors'
|
import { RequestError } from '../core/errors'
|
||||||
|
@ -21,10 +21,14 @@ import CampaignGenerateListJob from './CampaignGenerateListJob'
|
||||||
import Project from '../projects/Project'
|
import Project from '../projects/Project'
|
||||||
import Template from '../render/Template'
|
import Template from '../render/Template'
|
||||||
import { differenceInDays, subDays } from 'date-fns'
|
import { differenceInDays, subDays } from 'date-fns'
|
||||||
import { raw } from '../core/Model'
|
import { raw, ref } from '../core/Model'
|
||||||
|
import { cacheGet, cacheIncr } from '../config/redis'
|
||||||
|
import App from '../app'
|
||||||
|
|
||||||
export const CacheKeys = {
|
export const CacheKeys = {
|
||||||
pendingStats: 'campaigns:pending_stats',
|
pendingStats: 'campaigns:pending_stats',
|
||||||
|
populationProgress: (campaign: Campaign) => `campaigns:${campaign.id}:progress`,
|
||||||
|
populationTotal: (campaign: Campaign) => `campaigns:${campaign.id}:total`,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const pagedCampaigns = async (params: PageParams, projectId: number) => {
|
export const pagedCampaigns = async (params: PageParams, projectId: number) => {
|
||||||
|
@ -67,6 +71,9 @@ export const getCampaign = async (id: number, projectId: number): Promise<Campai
|
||||||
campaign.subscription = await getSubscription(campaign.subscription_id, projectId)
|
campaign.subscription = await getSubscription(campaign.subscription_id, projectId)
|
||||||
campaign.provider = await getProvider(campaign.provider_id, projectId)
|
campaign.provider = await getProvider(campaign.provider_id, projectId)
|
||||||
campaign.tags = await getTags(Campaign.tableName, [campaign.id]).then(m => m.get(campaign.id))
|
campaign.tags = await getTags(Campaign.tableName, [campaign.id]).then(m => m.get(campaign.id))
|
||||||
|
if (campaign.state === 'loading') {
|
||||||
|
campaign.progress = await campaignPopulationProgress(campaign)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return campaign
|
return campaign
|
||||||
|
@ -87,14 +94,6 @@ export const createCampaign = async (projectId: number, { tags, ...params }: Cam
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Calculate initial users count
|
|
||||||
await Campaign.update(qb => qb.where('id', campaign.id), {
|
|
||||||
delivery: {
|
|
||||||
...campaign.delivery,
|
|
||||||
total: await initialUsersCount(campaign),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (tags?.length) {
|
if (tags?.length) {
|
||||||
await setTags({
|
await setTags({
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
|
@ -128,7 +127,7 @@ export const updateCampaign = async (id: number, projectId: number, { tags, ...p
|
||||||
if (send_at
|
if (send_at
|
||||||
&& campaign.send_at
|
&& campaign.send_at
|
||||||
&& send_at !== campaign.send_at) {
|
&& send_at !== campaign.send_at) {
|
||||||
data.state = 'pending'
|
data.state = 'loading'
|
||||||
await abortCampaign(campaign)
|
await abortCampaign(campaign)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -136,8 +135,8 @@ export const updateCampaign = async (id: number, projectId: number, { tags, ...p
|
||||||
if (data.state === 'scheduled') {
|
if (data.state === 'scheduled') {
|
||||||
await validateTemplates(projectId, id)
|
await validateTemplates(projectId, id)
|
||||||
|
|
||||||
// Set to pending if success so scheduling starts
|
// Set to loading if success so scheduling starts
|
||||||
data.state = 'pending'
|
data.state = 'loading'
|
||||||
}
|
}
|
||||||
|
|
||||||
// If this is a trigger campaign, should always be running
|
// If this is a trigger campaign, should always be running
|
||||||
|
@ -159,7 +158,7 @@ export const updateCampaign = async (id: number, projectId: number, { tags, ...p
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.state === 'pending' && campaign.type === 'blast') {
|
if (data.state === 'loading' && campaign.type === 'blast') {
|
||||||
await CampaignGenerateListJob.from(campaign).queue()
|
await CampaignGenerateListJob.from(campaign).queue()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -289,16 +288,35 @@ export const generateSendList = async (campaign: SentCampaign) => {
|
||||||
// Clear any aborted sends
|
// Clear any aborted sends
|
||||||
await clearCampaign(campaign)
|
await clearCampaign(campaign)
|
||||||
|
|
||||||
// Generate the initial send list
|
const stream = UserList.query()
|
||||||
const query = recipientQuery(campaign)
|
.select('users.id AS id')
|
||||||
await chunk<CampaignSendParams>(query, 250, async (items) => {
|
.leftJoin('users', 'user_list.user_id', 'users.id')
|
||||||
await CampaignSend.query()
|
.whereIn('user_list.list_id', campaign.list_ids ?? [])
|
||||||
.insert(items)
|
.stream()
|
||||||
.onConflict(['campaign_id', 'user_id', 'reference_id'])
|
|
||||||
.ignore()
|
const ingest = async (lastId: number, limit: number) => {
|
||||||
}, ({ user_id, timezone }: { user_id: number, timezone: string }) =>
|
const query = recipientPartialQuery(campaign, lastId, limit)
|
||||||
CampaignSend.create(campaign, project, User.fromJson({ id: user_id, timezone })),
|
const cacheKey = CacheKeys.populationProgress(campaign)
|
||||||
)
|
await chunk<CampaignSendParams>(query, 300, async (items) => {
|
||||||
|
await CampaignSend.query()
|
||||||
|
.insert(items)
|
||||||
|
.onConflict(['campaign_id', 'user_id', 'reference_id'])
|
||||||
|
.ignore()
|
||||||
|
await cacheIncr(App.main.redis, cacheKey, items.length, 300)
|
||||||
|
}, ({ user_id, timezone }: { user_id: number, timezone: string }) =>
|
||||||
|
CampaignSend.create(campaign, project, User.fromJson({ id: user_id, timezone })),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = 0
|
||||||
|
let lastId = 0
|
||||||
|
const limit = 10_000
|
||||||
|
|
||||||
|
for await (const user of stream) {
|
||||||
|
if (count % limit === 0) await ingest(lastId, limit)
|
||||||
|
lastId = user.id
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
await Campaign.update(qb => qb.where('id', campaign.id), { state: 'scheduled' })
|
await Campaign.update(qb => qb.where('id', campaign.id), { state: 'scheduled' })
|
||||||
}
|
}
|
||||||
|
@ -413,15 +431,14 @@ export const duplicateCampaign = async (campaign: Campaign) => {
|
||||||
return await getCampaign(cloneId, campaign.project_id)
|
return await getCampaign(cloneId, campaign.project_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialUsersCount = async (campaign: Campaign): Promise<number> => {
|
export const campaignPopulationProgress = async (campaign: Campaign): Promise<CampaignPopulationProgress> => {
|
||||||
const response = await recipientQuery(campaign)
|
return {
|
||||||
.clear('select')
|
complete: await cacheGet<number>(App.main.redis, CacheKeys.populationProgress(campaign)) ?? 0,
|
||||||
.select(UserList.raw('COUNT(DISTINCT(users.id)) as count'))
|
total: await cacheGet<number>(App.main.redis, CacheKeys.populationTotal(campaign)) ?? 0,
|
||||||
const { count } = response[0]
|
}
|
||||||
return Math.max(0, count)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const campaignProgress = async (campaignId: number): Promise<CampaignProgress> => {
|
export const campaignDeliveryProgress = async (campaignId: number): Promise<CampaignProgress> => {
|
||||||
const progress = await CampaignSend.query()
|
const progress = await CampaignSend.query()
|
||||||
.where('campaign_id', campaignId)
|
.where('campaign_id', campaignId)
|
||||||
.select(CampaignSend.raw("SUM(IF(state = 'sent', 1, 0)) AS sent, SUM(IF(state IN('pending', 'throttled'), 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"))
|
.select(CampaignSend.raw("SUM(IF(state = 'sent', 1, 0)) AS sent, SUM(IF(state IN('pending', 'throttled'), 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"))
|
||||||
|
@ -443,7 +460,7 @@ export const updateCampaignProgress = async (campaign: Campaign): Promise<void>
|
||||||
return 'running'
|
return 'running'
|
||||||
}
|
}
|
||||||
|
|
||||||
const { pending, ...delivery } = await campaignProgress(campaign.id)
|
const { pending, ...delivery } = await campaignDeliveryProgress(campaign.id)
|
||||||
const state = currentState(pending, delivery)
|
const state = currentState(pending, delivery)
|
||||||
|
|
||||||
// If nothing has changed, continue otherwise update
|
// If nothing has changed, continue otherwise update
|
||||||
|
@ -533,3 +550,42 @@ export const updateCampaignSendEnrollment = async (user: User) => {
|
||||||
.merge(['state', 'send_at'])
|
.merge(['state', 'send_at'])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const recipientPartialQuery = (campaign: Campaign, sinceId: number, limit = 10000) => {
|
||||||
|
return User.query()
|
||||||
|
.select('users.id AS user_id', 'users.timezone')
|
||||||
|
.innerJoin('user_list', sbq =>
|
||||||
|
sbq.on('users.id', 'user_list.user_id')
|
||||||
|
.onIn('user_list.list_id', campaign.list_ids ?? []),
|
||||||
|
)
|
||||||
|
.where('users.project_id', campaign.project_id)
|
||||||
|
.where(qb => {
|
||||||
|
if (campaign.channel === 'email') {
|
||||||
|
qb.whereNotNull('users.email')
|
||||||
|
} else if (campaign.channel === 'text') {
|
||||||
|
qb.whereNotNull('users.phone')
|
||||||
|
} else if (campaign.channel === 'push') {
|
||||||
|
qb.whereNotNull('users.devices')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.whereNotExists(
|
||||||
|
UserList.query()
|
||||||
|
.whereIn('list_id', campaign.exclusion_list_ids ?? [])
|
||||||
|
.where('user_id', ref('users.id')),
|
||||||
|
)
|
||||||
|
.whereNotExists(
|
||||||
|
CampaignSend.query()
|
||||||
|
.where('campaign_id', campaign.id)
|
||||||
|
.where('user_id', ref('users.id'))
|
||||||
|
.where('state', 'sent'),
|
||||||
|
)
|
||||||
|
.whereNotExists(
|
||||||
|
UserSubscription.query()
|
||||||
|
.where('subscription_id', campaign.subscription_id)
|
||||||
|
.where('user_id', ref('users.id'))
|
||||||
|
.where('state', SubscriptionState.unsubscribed),
|
||||||
|
)
|
||||||
|
.where('users.id', '>', sinceId)
|
||||||
|
.orderBy('user_list.id')
|
||||||
|
.limit(limit)
|
||||||
|
}
|
||||||
|
|
|
@ -9,14 +9,14 @@ export default class ProcessCampaignsJob extends Job {
|
||||||
static async handler() {
|
static async handler() {
|
||||||
|
|
||||||
const campaigns = await Campaign.query()
|
const campaigns = await Campaign.query()
|
||||||
.whereIn('state', ['pending', 'scheduled', 'running'])
|
.whereIn('state', ['loading', 'scheduled', 'running'])
|
||||||
.whereNotNull('send_at')
|
.whereNotNull('send_at')
|
||||||
.whereNull('deleted_at')
|
.whereNull('deleted_at')
|
||||||
.where('type', 'blast')
|
.where('type', 'blast') as Campaign[]
|
||||||
for (const campaign of campaigns) {
|
for (const campaign of campaigns) {
|
||||||
|
|
||||||
// When pending we need to regenerate send list
|
// When pending we need to regenerate send list
|
||||||
if (campaign.state === 'pending') {
|
if (campaign.state === 'loading') {
|
||||||
await CampaignGenerateListJob.from(campaign).queue()
|
await CampaignGenerateListJob.from(campaign).queue()
|
||||||
|
|
||||||
// Otherwise lets look through messages that are ready to send
|
// Otherwise lets look through messages that are ready to send
|
||||||
|
|
|
@ -8,6 +8,10 @@ export const raw = (raw: Database.Value, db: Database = App.main.db) => {
|
||||||
return db.raw(raw)
|
return db.raw(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const ref = (ref: string, db: Database = App.main.db) => {
|
||||||
|
return db.ref(ref)
|
||||||
|
}
|
||||||
|
|
||||||
export interface SearchResult<T> {
|
export interface SearchResult<T> {
|
||||||
results: T[]
|
results: T[]
|
||||||
nextCursor?: string
|
nextCursor?: string
|
||||||
|
|
|
@ -349,7 +349,7 @@ export interface JourneyEntranceDetail {
|
||||||
userSteps: JourneyUserStep[]
|
userSteps: JourneyUserStep[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CampaignState = 'draft' | 'pending' | 'scheduled' | 'running' | 'finished' | 'aborted'
|
export type CampaignState = 'draft' | 'loading' | 'scheduled' | 'running' | 'finished' | 'aborted'
|
||||||
|
|
||||||
export interface CampaignDelivery {
|
export interface CampaignDelivery {
|
||||||
sent: number
|
sent: number
|
||||||
|
@ -381,6 +381,10 @@ export interface Campaign {
|
||||||
send_in_user_timezone: boolean
|
send_in_user_timezone: boolean
|
||||||
send_at: string
|
send_at: string
|
||||||
screenshot_url: string
|
screenshot_url: string
|
||||||
|
progress?: {
|
||||||
|
complete: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback, useContext } from 'react'
|
import { useCallback, useContext, useEffect } from 'react'
|
||||||
import api from '../../api'
|
import api from '../../api'
|
||||||
import { CampaignContext, ProjectContext } from '../../contexts'
|
import { CampaignContext, ProjectContext } from '../../contexts'
|
||||||
import { CampaignDelivery as Delivery, CampaignSendState } from '../../types'
|
import { CampaignDelivery as Delivery, CampaignSendState } from '../../types'
|
||||||
|
@ -51,10 +51,30 @@ export default function CampaignDelivery() {
|
||||||
const [project] = useContext(ProjectContext)
|
const [project] = useContext(ProjectContext)
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [preferences] = useContext(PreferencesContext)
|
const [preferences] = useContext(PreferencesContext)
|
||||||
const [{ id, state, send_at, delivery }] = useContext(CampaignContext)
|
const [campaign, setCampaign] = useContext(CampaignContext)
|
||||||
|
const { id, state, send_at, delivery, progress } = campaign
|
||||||
const searchState = useSearchTableState(useCallback(async params => await api.campaigns.users(project.id, id, params), [id, project]))
|
const searchState = useSearchTableState(useCallback(async params => await api.campaigns.users(project.id, id, params), [id, project]))
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const refresh = () => {
|
||||||
|
api.campaigns.get(project.id, campaign.id)
|
||||||
|
.then(setCampaign)
|
||||||
|
.then(() => searchState.reload)
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state !== 'loading') return
|
||||||
|
const complete = progress?.complete ?? 0
|
||||||
|
const total = progress?.total ?? 0
|
||||||
|
const percent = total > 0 ? complete / total * 100 : 0
|
||||||
|
const refreshRate = percent < 5 ? 1000 : 5000
|
||||||
|
const interval = setInterval(refresh, refreshRate)
|
||||||
|
refresh()
|
||||||
|
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [state])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Heading title={t('delivery')} size="h3" />
|
<Heading title={t('delivery')} size="h3" />
|
||||||
|
|
|
@ -57,16 +57,19 @@ export default function CampaignDetail() {
|
||||||
const [project] = useContext(ProjectContext)
|
const [project] = useContext(ProjectContext)
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [campaign, setCampaign] = useContext(CampaignContext)
|
const [campaign, setCampaign] = useContext(CampaignContext)
|
||||||
const { name, templates, state } = campaign
|
const { name, templates, state, progress } = campaign
|
||||||
const [locale, setLocale] = useState<LocaleSelection>(localeState(templates ?? []))
|
const [locale, setLocale] = useState<LocaleSelection>(localeState(templates ?? []))
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocale(localeState(templates ?? []))
|
setLocale(localeState(templates ?? []))
|
||||||
}, [campaign.id])
|
}, [campaign.id])
|
||||||
const [isLaunchOpen, setIsLaunchOpen] = useState(false)
|
const [isLaunchOpen, setIsLaunchOpen] = useState(false)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
const handleAbort = async () => {
|
const handleAbort = async () => {
|
||||||
|
setIsLoading(true)
|
||||||
const value = await api.campaigns.update(project.id, campaign.id, { state: 'aborted' })
|
const value = await api.campaigns.update(project.id, campaign.id, { state: 'aborted' })
|
||||||
setCampaign(value)
|
setCampaign(value)
|
||||||
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
|
@ -105,7 +108,7 @@ export default function CampaignDetail() {
|
||||||
onClick={() => setIsLaunchOpen(true)}
|
onClick={() => setIsLaunchOpen(true)}
|
||||||
>{t('restart_campaign')}</Button>
|
>{t('restart_campaign')}</Button>
|
||||||
),
|
),
|
||||||
pending: <></>,
|
loading: <></>,
|
||||||
scheduled: (
|
scheduled: (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
|
@ -114,6 +117,7 @@ export default function CampaignDetail() {
|
||||||
>{t('change_schedule')}</Button>
|
>{t('change_schedule')}</Button>
|
||||||
<Button
|
<Button
|
||||||
icon={<ForbiddenIcon />}
|
icon={<ForbiddenIcon />}
|
||||||
|
isLoading={isLoading}
|
||||||
onClick={async () => await handleAbort()}
|
onClick={async () => await handleAbort()}
|
||||||
>{t('abort_campaign')}</Button>
|
>{t('abort_campaign')}</Button>
|
||||||
</>
|
</>
|
||||||
|
@ -121,6 +125,7 @@ export default function CampaignDetail() {
|
||||||
running: (
|
running: (
|
||||||
<Button
|
<Button
|
||||||
icon={<ForbiddenIcon />}
|
icon={<ForbiddenIcon />}
|
||||||
|
isLoading={isLoading}
|
||||||
onClick={async () => await handleAbort()}
|
onClick={async () => await handleAbort()}
|
||||||
>{t('abort_campaign')}</Button>
|
>{t('abort_campaign')}</Button>
|
||||||
),
|
),
|
||||||
|
@ -130,7 +135,7 @@ export default function CampaignDetail() {
|
||||||
return (
|
return (
|
||||||
<PageContent
|
<PageContent
|
||||||
title={name}
|
title={name}
|
||||||
desc={state !== 'draft' && <CampaignTag state={campaign.state} />}
|
desc={state !== 'draft' && <CampaignTag state={state} progress={progress} />}
|
||||||
actions={campaign.type !== 'trigger' && action[state]}
|
actions={campaign.type !== 'trigger' && action[state]}
|
||||||
fullscreen={true}>
|
fullscreen={true}>
|
||||||
<NavigationTabs tabs={tabs} />
|
<NavigationTabs tabs={tabs} />
|
||||||
|
|
|
@ -18,17 +18,23 @@ import { ProjectContext } from '../../contexts'
|
||||||
import { PreferencesContext } from '../../ui/PreferencesContext'
|
import { PreferencesContext } from '../../ui/PreferencesContext'
|
||||||
import { Translation, useTranslation } from 'react-i18next'
|
import { Translation, useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export const CampaignTag = ({ state }: { state: CampaignState }) => {
|
export const CampaignTag = ({ state, progress }: Pick<Campaign, 'state' | 'progress'>) => {
|
||||||
const variant: Record<CampaignState, TagVariant> = {
|
const variant: Record<CampaignState, TagVariant> = {
|
||||||
draft: 'plain',
|
draft: 'plain',
|
||||||
aborted: 'error',
|
aborted: 'error',
|
||||||
pending: 'info',
|
loading: 'info',
|
||||||
scheduled: 'info',
|
scheduled: 'info',
|
||||||
running: 'info',
|
running: 'info',
|
||||||
finished: 'success',
|
finished: 'success',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const complete = progress?.complete ?? 0
|
||||||
|
const total = progress?.total ?? 0
|
||||||
|
const percent = total > 0 ? complete / total : 0
|
||||||
|
const percentStr = percent.toLocaleString(undefined, { style: 'percent', minimumFractionDigits: 0 })
|
||||||
return <Tag variant={variant[state]}>
|
return <Tag variant={variant[state]}>
|
||||||
<Translation>{ (t) => t(state) }</Translation>
|
<Translation>{ (t) => t(state) }</Translation>
|
||||||
|
{progress && ` (${percentStr})`}
|
||||||
</Tag>
|
</Tag>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue