diff --git a/apps/platform/db/migrations/20250716223958_add_devices_table.js b/apps/platform/db/migrations/20250716223958_add_devices_table.js new file mode 100644 index 00000000..86cf3d0f --- /dev/null +++ b/apps/platform/db/migrations/20250716223958_add_devices_table.js @@ -0,0 +1,41 @@ +exports.up = async function(knex) { + await knex.schema + .createTable('devices', function(table) { + table.increments() + table.integer('project_id') + .unsigned() + .notNullable() + .references('id') + .inTable('projects') + .onDelete('CASCADE') + table.integer('user_id') + .unsigned() + .notNullable() + .references('id') + .inTable('users') + .onDelete('CASCADE') + table.string('device_id', 255).notNullable() + table.string('token', 255) + table.string('os') + table.string('os_version') + table.string('model') + table.string('app_version') + table.string('app_build') + table.timestamp('created_at').defaultTo(knex.fn.now()) + table.timestamp('updated_at').defaultTo(knex.fn.now()) + + table.unique(['project_id', 'token']) + table.unique(['project_id', 'device_id']) + }) + + await knex.schema.table('users', function(table) { + table.boolean('has_push_device').defaultTo(0) + }) +} + +exports.down = async function(knex) { + await knex.schema.dropTable('devices') + await knex.schema.table('users', function(table) { + table.dropColumn('has_push_device') + }) +} diff --git a/apps/platform/src/campaigns/CampaignService.ts b/apps/platform/src/campaigns/CampaignService.ts index 11ca1749..63fd89e5 100644 --- a/apps/platform/src/campaigns/CampaignService.ts +++ b/apps/platform/src/campaigns/CampaignService.ts @@ -450,7 +450,7 @@ const recipientClickhouseQuery = async (campaign: Campaign) => { } else if (campaign.channel === 'text') { return "(users.phone != '' AND users.phone IS NOT NULL)" } else if (campaign.channel === 'push') { - return '(users.devices IS NOT NULL AND NOT empty(users.devices))' + return '((users.devices IS NOT NULL AND NOT empty(users.devices)) OR users.has_push_device = 1)' } return '' } @@ -572,9 +572,9 @@ export const estimatedSendSize = async (campaign: Campaign) => { return lists.reduce((acc, list) => (list.users_count ?? 0) + acc, 0) } -export const canSendCampaignToUser = (campaign: Campaign, user: Pick) => { +export const canSendCampaignToUser = (campaign: Campaign, user: Pick) => { if (campaign.channel === 'email' && !user.email) return false if (campaign.channel === 'text' && !user.phone) return false - if (campaign.channel === 'push' && !user.devices) return false + if (campaign.channel === 'push' && !(user.has_push_device || !!user.devices)) return false return true } diff --git a/apps/platform/src/client/ClientController.ts b/apps/platform/src/client/ClientController.ts index e67cee03..ae0b794a 100644 --- a/apps/platform/src/client/ClientController.ts +++ b/apps/platform/src/client/ClientController.ts @@ -4,7 +4,7 @@ import { JSONSchemaType, validate } from '../core/validate' import { ClientIdentifyParams, ClientIdentityKeys, ClientPostEventsRequest } from './Client' import { ProjectState } from '../auth/AuthMiddleware' import { projectMiddleware } from '../projects/ProjectController' -import { DeviceParams } from '../users/User' +import { DeviceParams } from '../users/Device' import UserPatchJob from '../users/UserPatchJob' import UserDeviceJob from '../users/UserDeviceJob' import UserAliasJob from '../users/UserAliasJob' diff --git a/apps/platform/src/providers/push/PushChannel.ts b/apps/platform/src/providers/push/PushChannel.ts index 6a006df3..e1c85b46 100644 --- a/apps/platform/src/providers/push/PushChannel.ts +++ b/apps/platform/src/providers/push/PushChannel.ts @@ -2,6 +2,7 @@ import { PushTemplate } from '../../render/Template' import { Variables } from '../../render' import { PushProvider } from './PushProvider' import { PushResponse } from './Push' +import { PushDevice } from '../../users/Device' export default class PushChannel { readonly provider: PushProvider @@ -14,10 +15,15 @@ export default class PushChannel { } } - async send(template: PushTemplate, variables: Variables): Promise { + async send(template: PushTemplate, devices: PushDevice[], variables: Variables): Promise { // Find tokens from active devices with push enabled - const tokens = variables.user.pushEnabledDevices.map(device => device.token) + // Temporarily include the old table + const oldDevices = variables.user?.devices?.filter(device => device.token != null) as PushDevice[] ?? [] + const tokens: string[] = [...new Set([ + ...devices.map(device => device.token), + ...oldDevices.map(device => device.token), + ])] const push = { tokens, diff --git a/apps/platform/src/providers/push/PushJob.ts b/apps/platform/src/providers/push/PushJob.ts index 093c092a..5d170dfd 100644 --- a/apps/platform/src/providers/push/PushJob.ts +++ b/apps/platform/src/providers/push/PushJob.ts @@ -9,6 +9,7 @@ import { loadPushChannel } from '.' import App from '../../app' import { releaseLock } from '../../core/Lock' import { EventPostJob } from '../../jobs' +import { getPushDevicesForUser } from '../../users/DeviceRepository' export default class PushJob extends Job { static $name = 'push' @@ -22,6 +23,7 @@ export default class PushJob extends Job { if (!data) return const { campaign, template, user, project, context } = data + const devices = await getPushDevicesForUser(project.id, user.id) try { // Load email channel so its ready to send @@ -41,7 +43,7 @@ export default class PushJob extends Job { if (!isReady) return // Send the push and update the send record - const result = await channel.send(template, data) + const result = await channel.send(template, devices, data) if (result) { await finalizeSend(data, result) @@ -49,7 +51,7 @@ export default class PushJob extends Job { // may have failed even though the push was // successful. We need to check for those and // disable them - if (result.invalidTokens.length) await disableNotifications(user.id, result.invalidTokens) + if (result.invalidTokens.length) await disableNotifications(user, result.invalidTokens) } } catch (error: any) { @@ -57,7 +59,7 @@ export default class PushJob extends Job { // If the push is unable to send, find invalidated tokens // and disable those devices - await disableNotifications(user.id, error.invalidTokens) + await disableNotifications(user, error.invalidTokens) // Update send record await updateSendState({ diff --git a/apps/platform/src/render/TemplateService.ts b/apps/platform/src/render/TemplateService.ts index 50939be4..c5c7e962 100644 --- a/apps/platform/src/render/TemplateService.ts +++ b/apps/platform/src/render/TemplateService.ts @@ -1,5 +1,5 @@ import { PageParams } from '../core/searchParams' -import Template, { TemplateParams, TemplateType, TemplateUpdateParams } from './Template' +import Template, { EmailTemplate, PushTemplate, TemplateParams, TemplateType, TemplateUpdateParams, TextTemplate, WebhookTemplate } from './Template' import { partialMatchLocale, pick, prune } from '../utilities' import { Variables } from '.' import { loadEmailChannel } from '../providers/email' @@ -16,6 +16,8 @@ import Project from '../projects/Project' import { getProject } from '../projects/ProjectService' import { logger } from '../config/logger' import EventPostJob from '../client/EventPostJob' +import { getPushDevicesForUser } from '../users/DeviceRepository' +import Campaign from '../campaigns/Campaign' export const pagedTemplates = async (params: PageParams, projectId: number) => { return await Template.search( @@ -111,26 +113,13 @@ export const sendProof = async (template: TemplateType, variables: Variables, re let response: any if (template.type === 'email') { - const channel = await loadEmailChannel(campaign.provider_id, project.id) - response = await channel?.send(template, variables) - logger.info(response, 'template:proof:email:result') + response = await sendEmailProof(campaign, template, variables) } else if (template.type === 'text') { - const channel = await loadTextChannel(campaign.provider_id, project.id) - response = await channel?.send(template, variables) - logger.info(response, 'template:proof:text:result') + response = await sendTextProof(campaign, template, variables) } else if (template.type === 'push') { - const channel = await loadPushChannel(campaign.provider_id, project.id) - if (!user.id) throw new RequestError('Unable to find a user matching the criteria.') - response = await channel?.send(template, variables) - - // Disable any tokens that we've discovered are invalid - if (response.invalidTokens.length) { - await disableNotifications(user.id, response.invalidTokens) - } - logger.info(response, 'template:proof:push:result') + response = await sendPushProof(campaign, template, variables) } else if (template.type === 'webhook') { - const channel = await loadWebhookChannel(campaign.provider_id, project.id) - response = await channel?.send(template, variables) + response = await sendWebhookProof(campaign, template, variables) } else { throw new RequestError('Sending template proofs is only supported for email and text message types as this time.') } @@ -151,6 +140,43 @@ export const sendProof = async (template: TemplateType, variables: Variables, re return response } +const sendEmailProof = async (campaign: Campaign, template: EmailTemplate, variables: Variables) => { + if (variables.user.unsubscribe_ids?.includes(campaign.subscription_id)) { + throw new RequestError('This template cannot be sent to this user as they have unsubscribed from emails.') + } + const channel = await loadEmailChannel(campaign.provider_id, variables.project.id) + const response = await channel?.send(template, variables) + logger.info(response, 'template:proof:email:result') + return response +} + +const sendTextProof = async (campaign: Campaign, template: TextTemplate, variables: Variables) => { + const channel = await loadTextChannel(campaign.provider_id, variables.project.id) + const response = await channel?.send(template, variables) + logger.info(response, 'template:proof:text:result') + return response +} + +const sendPushProof = async (campaign: Campaign, template: PushTemplate, variables: Variables) => { + const { user, project } = variables + const devices = await getPushDevicesForUser(project.id, user.id) + const channel = await loadPushChannel(campaign.provider_id, project.id) + if (!user.id) throw new RequestError('Unable to find a user matching the criteria.') + const response = await channel?.send(template, devices, variables) + + // Disable any tokens that we've discovered are invalid + if (response?.invalidTokens.length) { + await disableNotifications(user, response.invalidTokens) + } + logger.info(response, 'template:proof:push:result') + return response +} + +const sendWebhookProof = async (campaign: Campaign, template: WebhookTemplate, variables: Variables) => { + const channel = await loadWebhookChannel(campaign.provider_id, variables.project.id) + return await channel?.send(template, variables) +} + // Determine what template to send to the user based on the following: // - Find an exact match of users locale with a template // - Find a partial match (same root locale i.e. `en` vs `en-US`) diff --git a/apps/platform/src/users/Device.ts b/apps/platform/src/users/Device.ts new file mode 100644 index 00000000..5d8d67af --- /dev/null +++ b/apps/platform/src/users/Device.ts @@ -0,0 +1,18 @@ +import { ClientIdentity } from '../client/Client' +import Model, { ModelParams } from '../core/Model' + +export class Device extends Model { + project_id!: number + user_id!: number + device_id!: string + token?: string | null + os?: string + os_version?: string + model?: string + app_build?: string + app_version?: string +} + +export type DeviceParams = Omit & ClientIdentity + +export type PushDevice = Device & { token: string } diff --git a/apps/platform/src/users/DeviceRepository.ts b/apps/platform/src/users/DeviceRepository.ts new file mode 100644 index 00000000..da401965 --- /dev/null +++ b/apps/platform/src/users/DeviceRepository.ts @@ -0,0 +1,33 @@ +import { Transaction } from '../core/Model' +import { Device, PushDevice } from './Device' + +export const getDeviceFromIdOrToken = async (projectId: number, deviceId: string, token: string | null | undefined, trx?: Transaction): Promise => { + if (!deviceId && !token) return undefined + if (deviceId) { + const device = await Device.first(qb => qb.where('project_id', projectId).where('device_id', deviceId), trx) + if (device) return device + } + if (token) { + const device = await Device.first(qb => qb.where('project_id', projectId).where('token', token), trx) + if (device) return device + } +} + +export const markDevicesAsPushDisabled = async (projectId: number, tokens: string[], trx?: Transaction): Promise => { + await Device.update(qb => qb.whereIn('token', tokens).where('project_id', projectId), { token: null }, trx) +} + +export const userHasPushDevice = async (projectId: number, userId: number, trx?: Transaction): Promise => { + return await Device.exists(qb => + qb.where('project_id', projectId) + .where('user_id', userId) + .whereNotNull('token'), + trx, + ) +} + +export const getPushDevicesForUser = async (projectId: number, userId: number, trx?: Transaction): Promise => { + return await Device.all(qb => qb.where('project_id', projectId) + .where('user_id', userId) + .whereNotNull('token'), trx) as PushDevice[] +} diff --git a/apps/platform/src/users/User.ts b/apps/platform/src/users/User.ts index b521c6e1..951efa08 100644 --- a/apps/platform/src/users/User.ts +++ b/apps/platform/src/users/User.ts @@ -1,7 +1,8 @@ import { ClientIdentity } from '../client/Client' -import { ModelParams, UniversalModel } from '../core/Model' +import { UniversalModel } from '../core/Model' import parsePhoneNumber from 'libphonenumber-js' import { SubscriptionState } from '../subscriptions/Subscription' +import { Device } from './Device' export interface TemplateUser extends Record { id: string @@ -16,22 +17,6 @@ export interface UserAttribute { value: any } -export interface Device { - device_id: string - token?: string - os?: string - os_version?: string - model?: string - app_build?: string - app_version?: string -} - -export type DeviceParams = Omit & ClientIdentity - -interface PushEnabledDevice extends Device { - token: string -} - export class User extends UniversalModel { project_id!: number anonymous_id!: string @@ -39,6 +24,7 @@ export class User extends UniversalModel { email?: string phone?: string devices?: Device[] + has_push_device!: boolean data!: Record // first_name, last_name live in data unsubscribe_ids?: number[] timezone?: string @@ -64,10 +50,6 @@ export class User extends UniversalModel { } } - get pushEnabledDevices(): PushEnabledDevice[] { - return this.devices?.filter(device => device.token != null) as PushEnabledDevice[] ?? [] - } - get fullName() { // Handle case were user has a full name attribute in data const fullName = this.data.full_name ?? this.data.fullName diff --git a/apps/platform/src/users/UserDeviceJob.ts b/apps/platform/src/users/UserDeviceJob.ts index 5f14b03a..1fac0762 100644 --- a/apps/platform/src/users/UserDeviceJob.ts +++ b/apps/platform/src/users/UserDeviceJob.ts @@ -1,7 +1,6 @@ import { Job } from '../queue' import { saveDevice } from './UserRepository' -import { DeviceParams } from './User' -import App from '../app' +import { DeviceParams } from './Device' type UserDeviceTrigger = DeviceParams & { project_id: number diff --git a/apps/platform/src/users/UserRepository.ts b/apps/platform/src/users/UserRepository.ts index d26155ff..0217156e 100644 --- a/apps/platform/src/users/UserRepository.ts +++ b/apps/platform/src/users/UserRepository.ts @@ -2,7 +2,7 @@ import { RuleTree } from '../rules/Rule' import { ClientAliasParams, ClientIdentity } from '../client/Client' import { PageParams } from '../core/searchParams' import { RetryError } from '../queue/Job' -import { Device, DeviceParams, User, UserInternalParams } from '../users/User' +import { User, UserInternalParams } from '../users/User' import { deepEqual, pick, uuid } from '../utilities' import { getRuleEventParams } from '../rules/RuleHelpers' import { UserEvent } from './UserEvent' @@ -10,15 +10,17 @@ import { Context } from 'koa' import { EventPostJob } from '../jobs' import { Transaction } from '../core/Model' import App from '../app' +import { Device, DeviceParams } from './Device' +import { getDeviceFromIdOrToken, markDevicesAsPushDisabled, userHasPushDevice } from './DeviceRepository' import Project from '../projects/Project' -export const getUser = async (id: number, projectId?: number): Promise => { +export const getUser = async (id: number, projectId?: number, trx?: Transaction): Promise => { return await User.find(id, qb => { if (projectId) { qb.where('project_id', projectId) } return qb - }) + }, trx) } export const getUserFromContext = async (ctx: Context): Promise => { @@ -190,64 +192,87 @@ export const deleteUser = async (projectId: number, externalId: string): Promise }) } -export const saveDevice = async (projectId: number, { external_id, anonymous_id, ...params }: DeviceParams, trx?: Transaction): Promise => { +export const saveDevice = async (projectId: number, { external_id, anonymous_id, ...params }: DeviceParams, trx?: Transaction): Promise => { const user = await getUserFromClientId(projectId, { external_id, anonymous_id } as ClientIdentity, trx) if (!user) throw new RetryError() - const devices = user.devices ? [...user.devices] : [] - let device = devices?.find(device => { - return device.device_id === params.device_id - || (device.token === params.token && device.token != null) - }) - if (device) { - const newDevice = { ...device, ...params } - const isDirty = !deepEqual(device, newDevice) - if (!isDirty) return device - Object.assign(device, params) - } else { - device = { - ...params, - device_id: params.device_id, - } - devices.push(device) - } + const { device_id, token } = params + const device = await getDeviceFromIdOrToken(projectId, device_id, token, trx) + // If we have a device, move it to the new user and update both users + // in the DB to reflect their current push state + if (device) { + + const oldParams = pick(device, ['os', 'os_version', 'model', 'app_build', 'app_version', 'token']) + const newParams = pick(params, ['os', 'os_version', 'model', 'app_build', 'app_version', 'token']) + + // If nothing has changed on the device, just return the ID + const isDirty = !deepEqual(oldParams, newParams) || device.user_id !== user.id + if (!isDirty) return device.id + + // Update the device, combining the old and new params + await Device.update(qb => qb.where('id', device.id), { + ...oldParams, + ...newParams, + user_id: user.id, + }, trx) + + // If this user had no previous push device, update the db + if (!user.has_push_device && !!token) { + await updateUserDeviceState(user, true, trx) + } + + // Update the user we stole the device from + const previousUser = await getUser(device.user_id, projectId, trx) + const hasPushDevice = await userHasPushDevice(user.project_id, device.user_id, trx) + if (previousUser) { + await updateUserDeviceState(previousUser, hasPushDevice, trx) + } + + return device.id + } else { + // If no device found, create a new one + const newDevice = { + ...params, + project_id: projectId, + device_id: device_id ?? uuid(), + token, + user_id: user.id, + } + const deviceId = await Device.insert(newDevice, trx) + + // If user previously had another device, no need to update + if (user.has_push_device || !token) return deviceId + await updateUserDeviceState(user, true, trx) + + return deviceId + } +} + +export const disableNotifications = async (user: User, tokens: string[]): Promise => { + + await App.main.db.transaction(async (trx) => { + + // Wipe the token from all devices provided + await markDevicesAsPushDisabled(user.project_id, tokens, trx) + + // Check if the user has any push devices left + const hasPushDevice = await userHasPushDevice(user.project_id, user.id, trx) + + // If the push state has changed for a user, update the record + if (hasPushDevice === user.has_push_device) return + await updateUserDeviceState(user, hasPushDevice, trx) + }) + return true +} + +const updateUserDeviceState = async (user: User, hasPushDevice: boolean, trx?: Transaction): Promise => { const after = await User.updateAndFetch(user.id, { - devices, + has_push_device: hasPushDevice, version: Date.now(), }, trx) await User.clickhouse().upsert(after, user) - - return device -} - -export const disableNotifications = async (userId: number, tokens: string[]): Promise => { - - await App.main.db.transaction(async (trx) => { - const user = await User.find(userId, undefined, trx) - if (!user) return false - if (!user.devices?.length) return false - - const devices = [] - for (const { token, ...device } of user.devices) { - if (token && tokens.includes(token)) { - devices.push(device) - } else { - devices.push({ - ...device, - token, - }) - } - } - - const after = await User.updateAndFetch(user.id, { - devices, - version: Date.now(), - }, trx) - await User.clickhouse().upsert(after, user) - }) - return true } export const getUserEventsForRules = async ( diff --git a/apps/platform/src/users/__tests__/UserRepository.spec.ts b/apps/platform/src/users/__tests__/UserRepository.spec.ts index da41ad03..ae4d08d9 100644 --- a/apps/platform/src/users/__tests__/UserRepository.spec.ts +++ b/apps/platform/src/users/__tests__/UserRepository.spec.ts @@ -6,6 +6,7 @@ import { uuid } from '../../utilities' import { User } from '../User' import { UserEvent } from '../UserEvent' import { createEvent } from '../UserEventRepository' +import { Device } from '../Device' describe('UserRepository', () => { describe('getUserFromClientId', () => { @@ -54,9 +55,10 @@ describe('UserRepository', () => { external_id: uuid(), }) - const device = await saveDevice(project.id, { + const deviceUuid = uuid() + const deviceId = await saveDevice(project.id, { external_id: user.external_id, - device_id: uuid(), + device_id: deviceUuid, token: uuid(), os: 'ios', model: 'iPhone', @@ -64,9 +66,11 @@ describe('UserRepository', () => { app_version: '1.0', }) - const freshUser = await User.find(user.id) - expect(freshUser?.devices?.length).toEqual(1) - expect(freshUser?.devices?.[0].device_id).toEqual(device?.device_id) + const userDb = await User.find(user.id) + const deviceDb = await Device.find(deviceId) + expect(userDb?.has_push_device).toEqual(true) + expect(deviceDb?.user_id).toEqual(userDb?.id) + expect(deviceDb?.device_id).toEqual(deviceUuid) }) test('update a device for a user', async () => { @@ -95,11 +99,50 @@ describe('UserRepository', () => { app_version: '1.1', }) - const freshUser = await User.find(user.id) - expect(freshUser?.devices?.length).toEqual(1) - expect(freshUser?.devices?.[0].device_id).toEqual(deviceId) - expect(freshUser?.devices?.[0].token).toEqual(token) - expect(freshUser?.devices?.[0].app_build).toEqual('2') + const devices = await Device.all(qb => qb.where('user_id', user.id)) + expect(devices.length).toEqual(1) + expect(devices[0].device_id).toEqual(deviceId) + expect(devices[0].token).toEqual(token) + expect(devices[0].app_build).toEqual('2') + }) + + test('changing a devices user moves it', async () => { + const project = await createTestProject() + const deviceId = uuid() + const token = uuid() + const user = await createUser(project.id, { + external_id: uuid(), + }) + const user2 = await createUser(project.id, { + external_id: uuid(), + }) + await saveDevice(project.id, { + external_id: user.external_id, + device_id: deviceId, + token: uuid(), + os: 'ios', + model: 'iPhone', + app_build: '1', + app_version: '1.0', + }) + await saveDevice(project.id, { + external_id: user2.external_id, + device_id: deviceId, + token, + os: 'ios', + model: 'iPhone', + app_build: '2', + app_version: '1.1', + }) + + const devices = await Device.all(qb => qb.where('user_id', user.id)) + const devices2 = await Device.all(qb => qb.where('user_id', user2.id)) + const userDb1 = await User.find(user.id) + const userDb2 = await User.find(user2.id) + expect(devices.length).toEqual(0) + expect(devices2.length).toEqual(1) + expect(userDb1?.has_push_device).toEqual(false) + expect(userDb2?.has_push_device).toEqual(true) }) })