From 80773c1fae4ea1c0364835fc26a76edb30efc88f Mon Sep 17 00:00:00 2001 From: Chris Anderson Date: Sat, 22 Apr 2023 09:01:01 -0500 Subject: [PATCH] Fixes offsets for campaign sends not matching user timezone (#139) --- apps/platform/src/campaigns/CampaignService.ts | 11 +++++++---- .../campaigns/__tests__/CampaignService.spec.ts | 6 +++++- apps/platform/src/projects/Project.ts | 2 +- apps/platform/src/projects/ProjectController.ts | 7 ++----- apps/platform/src/providers/Provider.ts | 2 +- apps/platform/src/utilities/index.ts | 16 ++++++++++++++++ 6 files changed, 32 insertions(+), 12 deletions(-) diff --git a/apps/platform/src/campaigns/CampaignService.ts b/apps/platform/src/campaigns/CampaignService.ts index 0819eef9..7fec8595 100644 --- a/apps/platform/src/campaigns/CampaignService.ts +++ b/apps/platform/src/campaigns/CampaignService.ts @@ -13,9 +13,8 @@ import App from '../app' import { SearchParams } from '../core/searchParams' import { allLists, listUserCount } from '../lists/ListService' import { allTemplates, duplicateTemplate, validateTemplates } from '../render/TemplateService' -import { utcToZonedTime } from 'date-fns-tz' import { getSubscription } from '../subscriptions/SubscriptionService' -import { pick } from '../utilities' +import { crossTimezoneCopy, pick } from '../utilities' import { getProvider } from '../providers/ProviderRepository' import { createTagSubquery, getTags, setTags } from '../tags/TagService' import { getProject } from '../projects/ProjectService' @@ -111,7 +110,7 @@ export const updateCampaign = async (id: number, projectId: number, { tags, ...p } // If we are rescheduling, abort sends to they are reset - if (data.send_at !== campaign.send_at) { + if (data.send_at && data.send_at !== campaign.send_at) { data.state = 'pending' await abortCampaign(campaign) } @@ -267,7 +266,11 @@ export const generateSendList = async (campaign: SentCampaign) => { campaign_id: campaign.id, state: 'pending', send_at: campaign.send_in_user_timezone - ? utcToZonedTime(campaign.send_at, timezone ?? project.timezone) + ? crossTimezoneCopy( + campaign.send_at, + project.timezone, + timezone ?? project.timezone, + ) : campaign.send_at, }) i++ diff --git a/apps/platform/src/campaigns/__tests__/CampaignService.spec.ts b/apps/platform/src/campaigns/__tests__/CampaignService.spec.ts index 2c165a25..5c4985db 100644 --- a/apps/platform/src/campaigns/__tests__/CampaignService.spec.ts +++ b/apps/platform/src/campaigns/__tests__/CampaignService.spec.ts @@ -29,7 +29,10 @@ describe('CampaignService', () => { last_name: uuid(), email: `${uuid()}@test.com`, }) - const project = await createProject(adminId, { name: uuid() }) + const project = await createProject(adminId, { + name: uuid(), + timezone: 'utc', + }) const subscription = await createSubscription(project.id, { name: uuid(), channel: 'email', @@ -40,6 +43,7 @@ describe('CampaignService', () => { data: {}, name: uuid(), is_default: false, + rate_limit: 10, }) return { project_id: project.id, diff --git a/apps/platform/src/projects/Project.ts b/apps/platform/src/projects/Project.ts index f64d8688..cfd9b53f 100644 --- a/apps/platform/src/projects/Project.ts +++ b/apps/platform/src/projects/Project.ts @@ -6,7 +6,7 @@ export default class Project extends Model { description?: string deleted_at?: Date locale?: string - timezone?: string + timezone!: string } export type ProjectParams = Omit diff --git a/apps/platform/src/projects/ProjectController.ts b/apps/platform/src/projects/ProjectController.ts index 8ed4137c..89686afb 100644 --- a/apps/platform/src/projects/ProjectController.ts +++ b/apps/platform/src/projects/ProjectController.ts @@ -50,7 +50,7 @@ router.get('/all', async ctx => { const projectCreateParams: JSONSchemaType = { $id: 'projectCreate', type: 'object', - required: ['name'], + required: ['name', 'timezone'], properties: { name: { type: 'string', @@ -63,10 +63,7 @@ const projectCreateParams: JSONSchemaType = { type: 'string', nullable: true, }, - timezone: { - type: 'string', - nullable: true, - }, + timezone: { type: 'string' }, }, additionalProperties: false, } diff --git a/apps/platform/src/providers/Provider.ts b/apps/platform/src/providers/Provider.ts index f4b70261..d43a01eb 100644 --- a/apps/platform/src/providers/Provider.ts +++ b/apps/platform/src/providers/Provider.ts @@ -90,7 +90,7 @@ export default class Provider extends Model { export type ProviderMap = (record: any) => T -export type ProviderParams = Omit +export type ProviderParams = Omit export type ExternalProviderParams = Omit diff --git a/apps/platform/src/utilities/index.ts b/apps/platform/src/utilities/index.ts index a5e05e97..73958914 100644 --- a/apps/platform/src/utilities/index.ts +++ b/apps/platform/src/utilities/index.ts @@ -3,6 +3,7 @@ import { validate } from '../core/validate' import crypto from 'crypto' import Hashids from 'hashids' import { differenceInSeconds } from 'date-fns' +import { utcToZonedTime } from 'date-fns-tz' export const pluralize = (noun: string, count = 2, suffix = 's') => `${noun}${count !== 1 ? suffix : ''}` @@ -96,6 +97,21 @@ export const partialMatchLocale = (locale1?: string, locale2?: string) => { return locale1 === locale2 || locale1Root === locale2Root } +export const crossTimezoneCopy = ( + date: Date, + fromTimezone: string, + toTimezone: string, +) => { + const baseDate = utcToZonedTime(date, fromTimezone) + + const utcDate = new Date(baseDate.toLocaleString('en-US', { timeZone: 'UTC' })) + const tzDate = new Date(baseDate.toLocaleString('en-US', { timeZone: toTimezone })) + const offset = utcDate.getTime() - tzDate.getTime() + + baseDate.setTime(baseDate.getTime() + offset) + return baseDate +} + export function extractQueryParams>(search: URLSearchParams | Record, schema: JSONSchemaType) { return validate(schema, Object.entries>(schema.properties).reduce((a, [name, def]) => { let values!: string[]