Improves timezone and locale support (#106)

* Improves timezone and locale support

* Fixes user controller
This commit is contained in:
Chris Anderson 2023-04-01 17:05:42 -05:00 committed by GitHub
parent 16e58a248c
commit c525d4826a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 90 additions and 6 deletions

View file

@ -1,7 +1,7 @@
exports.up = async function(knex) {
}
exports.down = async function(knex) {
}

View file

@ -0,0 +1,13 @@
exports.up = async function(knex) {
await knex.schema
.table('users', function(table) {
table.string('locale').after('timezone')
})
}
exports.down = async function(knex) {
await knex.schema
.table('users', function(table) {
table.dropColumn('locale')
})
}

View file

@ -39,6 +39,7 @@
"koa-body": "5.0.0",
"koa-jwt": "^4.0.3",
"koa-static": "^5.0.0",
"libphonenumber-js": "^1.10.24",
"mysql2": "^2.3.3",
"node-pushnotifications": "^2.0.3",
"node-schedule": "^2.1.0",
@ -8167,6 +8168,11 @@
"node": ">= 0.8.0"
}
},
"node_modules/libphonenumber-js": {
"version": "1.10.24",
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.10.24.tgz",
"integrity": "sha512-3Dk8f5AmrcWqg+oHhmm9hwSTqpWHBdSqsHmjCJGroULFubi0+x7JEIGmRZCuL3TI8Tx39xaKqfnhsDQ4ALa/Nw=="
},
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",

View file

@ -33,6 +33,7 @@
"koa-body": "5.0.0",
"koa-jwt": "^4.0.3",
"koa-static": "^5.0.0",
"libphonenumber-js": "^1.10.24",
"mysql2": "^2.3.3",
"node-pushnotifications": "^2.0.3",
"node-schedule": "^2.1.0",

View file

@ -13,7 +13,7 @@ export type ClientAliasParams = {
export type ClientIdentity = RequireAtLeastOne<ClientAliasParams, 'anonymous_id' | 'external_id'>
export type ClientIdentifyParams = Partial<Pick<User, 'email' | 'phone' | 'data'>> & ClientIdentity
export type ClientIdentifyParams = Partial<Pick<User, 'email' | 'phone' | 'timezone' | 'data'>> & ClientIdentity
export type ClientIdentifyUser = Pick<User, 'external_id'> & Partial<Pick<User, 'email' | 'phone' | 'data'>>
@ -24,6 +24,7 @@ export type ClientDeleteUsersRequest = string[]
export type ClientPostEvent = {
name: string
data?: Record<string, unknown>
created_at?: Date
} & ClientIdentity
export type ClientPostEventsRequest = ClientPostEvent[]
@ -52,6 +53,7 @@ export type SegmentPostEvent = {
traits?: Record<string, any>
type: 'track' | 'alias' | 'identify'
timestamp: string
locale: string
} & (
{
type: 'track',

View file

@ -64,6 +64,14 @@ const identifyParams: JSONSchemaType<ClientIdentifyParams> = {
type: 'string',
nullable: true,
},
timezone: {
type: 'string',
nullable: true,
},
locale: {
type: 'string',
nullable: true,
},
data: {
type: 'object',
nullable: true,

View file

@ -81,6 +81,8 @@ router.post('/segment', async ctx => {
...identity,
email: event.traits.email,
phone: event.traits.phone,
timezone: event.context.timezone,
locale: event.locale,
data: event.traits,
},
}))
@ -92,6 +94,7 @@ router.post('/segment', async ctx => {
...identity,
name: event.event,
data: { ...event.properties, ...event.context },
created_at: new Date(event.timestamp),
},
}))
}

View file

@ -5,6 +5,7 @@ import { RenderContext } from '../render'
import Template, { TemplateType } from '../render/Template'
import { User } from '../users/User'
import { UserEvent } from '../users/UserEvent'
import { partialMatchLocale } from '../utilities'
import { MessageTrigger } from './MessageTrigger'
interface MessageTriggerHydrated<T> {
@ -34,8 +35,16 @@ export async function loadSendJob<T extends TemplateType>({ campaign_id, user_id
qb => qb.where('campaign_id', campaign_id),
)
// 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`)
// - If a project locale is set and there is amtch, use that template
// - If there is a project locale and its a partial match, use
// - Otherwise return any template available
const template = templates.find(item => item.locale === user.locale)
|| templates.find(item => partialMatchLocale(item.locale, user.locale))
|| templates.find(item => item.locale === project.locale)
|| templates.find(item => partialMatchLocale(item.locale, project.locale))
|| templates[0]
// If campaign or template dont exist, log and abort

View file

@ -1,5 +1,6 @@
import { ClientIdentity } from '../client/Client'
import Model, { ModelParams } from '../core/Model'
import parsePhoneNumber from 'libphonenumber-js'
export interface TemplateUser extends Record<string, any> {
id: string
@ -42,7 +43,6 @@ export class User extends Model {
phone?: string
devices?: Device[]
data!: Record<string, any> // first_name, last_name live in data
attributes!: UserAttribute[] // ???
timezone!: string
locale!: string
@ -80,6 +80,19 @@ export class User extends Model {
get lastName() {
return this.data.last_name ?? this.data.lastName
}
toJSON() {
const json = super.toJSON()
if (this.phone) {
const parsedNumber = parsePhoneNumber(this.phone)
if (parsedNumber) {
json.phone = parsedNumber.formatInternational()
}
}
return json
}
}
export type UserParams = Partial<Pick<User, 'email' | 'phone' | 'data'>> & ClientIdentity
export type UserParams = Partial<Pick<User, 'email' | 'phone' | 'timezone' |'locale' | 'data'>> & ClientIdentity

View file

@ -48,6 +48,14 @@ const patchUsersRequest: JSONSchemaType<UserParams[]> = {
type: 'string',
nullable: true,
},
timezone: {
type: 'string',
nullable: true,
},
locale: {
type: 'string',
nullable: true,
},
data: {
type: 'object',
nullable: true,
@ -74,6 +82,14 @@ const patchUsersRequest: JSONSchemaType<UserParams[]> = {
type: 'string',
nullable: true,
},
timezone: {
type: 'string',
nullable: true,
},
locale: {
type: 'string',
nullable: true,
},
data: {
type: 'object',
nullable: true,

View file

@ -84,6 +84,17 @@ export const batch = <T>(arr: T[], size: number) => {
return result
}
export const parseLocale = (locale: string): string | undefined => {
const parts = locale.split('-')
return parts.length === 1 ? locale[0] : locale
}
export const partialMatchLocale = (locale1?: string, locale2?: string) => {
const locale1Root = locale1?.split('-')[0]
const locale2Root = locale2?.split('-')[0]
return locale1 === locale2 || locale1Root === locale2Root
}
export function extractQueryParams<T extends Record<string, any>>(search: URLSearchParams | Record<string, undefined | string | string[]>, schema: JSONSchemaType<T>) {
return validate(schema, Object.entries<JSONSchemaType<any>>(schema.properties).reduce((a, [name, def]) => {
let values: string[]

View file

@ -36,7 +36,7 @@ export default function UserDetailEvents() {
}}
/>
<Modal title={event?.name}
size="regular"
size="large"
open={event != null}
onClose={() => setEvent(undefined)}
>

View file

@ -17,6 +17,8 @@ export default function UserTabs() {
{ key: 'full_name', title: 'Name' },
{ key: 'email' },
{ key: 'phone' },
{ key: 'timezone' },
{ key: 'locale' },
{ key: 'created_at' },
{ key: 'updated_at' },
]}