Merge pull request #8 from parcelvoy/feat/7/add-lists

Add lists
This commit is contained in:
chrishills 2022-08-22 21:14:53 -05:00 committed by GitHub
commit 0941659de2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 435 additions and 221 deletions

View file

@ -0,0 +1,64 @@
exports.up = function(knex) {
return knex.schema
.createTable('lists', function(table) {
table.increments()
table.string('name', 255).defaultTo('')
table.integer('project_id')
.unsigned()
.notNullable()
.references('id')
.inTable('projects')
.onDelete('CASCADE')
table.json('rules')
table.timestamp('created_at').defaultTo(knex.fn.now())
table.timestamp('updated_at').defaultTo(knex.fn.now())
})
.createTable('user_events', function(table) {
table.increments()
table.string('name', 255).defaultTo('')
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.json('data')
table.timestamp('created_at').defaultTo(knex.fn.now())
table.timestamp('updated_at').defaultTo(knex.fn.now())
})
.createTable('user_list', function(table) {
table.increments()
table.integer('user_id')
.unsigned()
.notNullable()
.references('id')
.inTable('users')
.onDelete('CASCADE')
table.integer('list_id')
.unsigned()
.notNullable()
.references('id')
.inTable('lists')
.onDelete('CASCADE')
table.integer('event_id')
.unsigned()
.references('id')
.inTable('user_events')
.onDelete('CASCADE')
table.timestamp('created_at').defaultTo(knex.fn.now())
table.timestamp('updated_at').defaultTo(knex.fn.now())
})
}
exports.down = function(knex) {
knex.schema
.dropTable('user_list')
.dropTable('user_events')
.dropTable('lists')
}

View file

@ -7,6 +7,7 @@ import Queue from './queue'
import EmailSender from './sender/email/EmailSender'
import TextSender from './sender/text/TextSender'
import WebhookSender from './sender/webhook/WebhookSender'
import configQueue from './config/queue'
export default class App {
private static $main: App
@ -27,6 +28,9 @@ export default class App {
// Setup app
App.$main = new App(env, database)
// Register jobs
configQueue(App.$main.queue)
return App.$main
}

17
src/config/queue.ts Normal file
View file

@ -0,0 +1,17 @@
import Queue from '../queue'
import EmailJob from '../jobs/EmailJob'
import EventPostJob from '../jobs/EventPostJob'
import TextJob from '../jobs/TextJob'
import UserDeleteJob from '../jobs/UserDeleteJob'
import UserPatchJob from '../jobs/UserPatchJob'
import WebhookJob from '../jobs/WebhookJob'
export default (queue: Queue) => {
queue.register(EmailJob)
queue.register(TextJob)
queue.register(WebhookJob)
queue.register(UserPatchJob)
queue.register(UserDeleteJob)
queue.register(EventPostJob)
}

View file

@ -1,80 +0,0 @@
/* eslint-disable */
import Router from '@koa/router'
import App from '../app'
import { User } from '../models/User'
import EmailJob from '../sender/email/EmailJob'
const router = new Router({
prefix: '/users'
})
const user = new User({
id: 1,
project_id: 1,
external_id: "1",
email: 'canderson@twochris.com',
data: {
firstName: 'Chris',
lastName: 'Anderson',
city: 'Chicago'
},
devices: [],
attributes: [],
created_at: new Date(),
updated_at: new Date()
})
router.get('/', async (ctx, next) => {
const email = {
from: 'hi@chrisanderson.io',
to: 'chrisanderson93@gmail.com',
subject: 'Hello there {{user.firstName}}!',
html: 'This is a test to see how sending a template would work.\n\nFirst Name: {{user.firstName}}\nLast Name: {{user.lastName}}\nEmail: {{user.email}} {{reverse user.email}} {{multiply (add 1 1) 10}}\n\n<br>{{numberFormat 100000}}',
text: 'This is the text version seen in {{user.city}}'
}
const job = EmailJob.from({
email,
user,
event: {}
})
await App.main.queue.enqueue(job)
ctx.body = 'sent email!'
})
router.get('/text', async (ctx, next) => {
await App.main.texter.send(
{
to: '952-769-6903',
from: '123-456-7890',
text: 'You have won a fabulous prize {{ user.firstName }}!'
},
{
user
}
)
ctx.body = 'sent text!'
})
router.get('/webhook', async (ctx, next) => {
await App.main.webhooker.send(
{
method: 'POST',
endpoint: 'twochris.com/status',
headers: { },
body: {
foo: 'bar {{ user.email }}'
}
},
{
user
}
)
ctx.body = 'sent webhook!'
})
export default router

View file

@ -1,9 +1,9 @@
import { Job } from '../queue'
import { User } from '../models/User'
import App from '../app'
import { UserEvent } from '../sender/journey/UserEvent'
import { UserEvent } from '../journey/UserEvent'
import { EmailTemplate } from '../models/Template'
import { createEvent } from '../sender/journey/UserEventRepository'
import { createEvent } from '../journey/UserEventRepository'
import { MessageTrigger } from '../models/MessageTrigger'
export default class EmailJob extends Job {
@ -27,8 +27,12 @@ export default class EmailJob extends Job {
await App.main.mailer.send(template, { user, event })
// Create an event on the user about the email
createEvent(user_id, 'email_sent', {
// TODO: Add whatever other attributes
await createEvent({
project_id: user.project_id,
user_id: user.id,
name: 'email_sent',
data: { // TODO: Add whatever other attributes
},
})
}
}

View file

@ -1,4 +1,7 @@
import { logger } from '../config/logger'
import { createEvent } from '../journey/UserEventRepository'
import { getUserFromExternalId } from '../journey/UserRepository'
import { updateLists } from '../lists/ListService'
import { ClientPostEvent } from '../models/client'
import { Job } from '../queue'
@ -16,8 +19,18 @@ export default class EventPostJob extends Job {
static async handler({ project_id, event }: EventPostTrigger) {
// TODO: Handle events
logger.debug('project ' + project_id + ' received event: ' + JSON.stringify(event))
const user = await getUserFromExternalId(project_id, event.user_id)
if (!user) return // TODO: Maybe log an error somewhere?
// Create event for given user
const dbEvent = await createEvent({
project_id,
user_id: user.id,
name: event.name,
data: event.data || {},
})
// Check to see if a user has any lists
await updateLists(user, dbEvent)
}
}

View file

@ -1,9 +1,9 @@
import { Job } from '../queue'
import { User } from '../models/User'
import App from '../app'
import { UserEvent } from '../sender/journey/UserEvent'
import { UserEvent } from '../journey/UserEvent'
import { TextTemplate } from '../models/Template'
import { createEvent } from '../sender/journey/UserEventRepository'
import { createEvent } from '../journey/UserEventRepository'
import { MessageTrigger } from '../models/MessageTrigger'
export default class TextJob extends Job {
@ -27,8 +27,13 @@ export default class TextJob extends Job {
await App.main.texter.send(template, { user, event })
// Create an event on the user about the text
createEvent(user_id, 'text_sent', {
// TODO: Add whatever other attributes
createEvent({
project_id: user.project_id,
user_id: user.id,
name: 'text_sent',
data: {
// TODO: Add whatever other attributes
},
})
}
}

View file

@ -1,7 +1,8 @@
import App from '../app'
import { User } from '../models/User'
import { ClientPatchUser } from '../models/client'
import { Job } from '../queue'
import { getUserFromExternalId } from '../journey/UserRepository'
import { updateLists } from '../lists/ListService'
interface UserPatchTrigger {
project_id: number
@ -17,30 +18,23 @@ export default class UserPatchJob extends Job {
static async handler({ project_id, user: { external_id, data, ...fields } }: UserPatchTrigger) {
await App.main.db.transaction(async trx => {
// Check for existing user
const existing = await getUserFromExternalId(project_id, external_id)
const existing = await trx('users')
.where('project_id', project_id)
.where('external_id', external_id)
.first()
.then(r => User.fromJson(r))
// If user, update otherwise insert
const user = existing
? await User.updateAndFetch(existing.id, {
data: data ? { ...existing.data, ...data } : undefined,
...fields,
})
: await User.insertAndFetch({
project_id,
external_id,
data,
...fields,
})
if (existing) {
await trx('users')
.update({
data: data ? JSON.stringify({ ...existing.data, ...data }) : undefined,
...fields,
})
.where({ external_id })
} else {
await trx('users')
.insert({
project_id,
external_id,
data: JSON.stringify(data),
...fields,
})
}
})
// Use updated user to check for list membership
await updateLists(user)
}
}

View file

@ -3,8 +3,8 @@ import { User } from '../models/User'
import App from '../app'
import { MessageTrigger } from '../models/MessageTrigger'
import { WebhookTemplate } from '../models/Template'
import { UserEvent } from '../sender/journey/UserEvent'
import { createEvent } from '../sender/journey/UserEventRepository'
import { UserEvent } from '../journey/UserEvent'
import { createEvent } from '../journey/UserEventRepository'
export default class WebhookJob extends Job {
static $name = 'webhook'
@ -26,8 +26,13 @@ export default class WebhookJob extends Job {
await App.main.webhooker.send(template, { user, event })
// Create an event on the user about the email
createEvent(user_id, 'webhook_sent', {
// TODO: Add whatever other attributes
createEvent({
project_id: user.project_id,
user_id: user.id,
name: 'webhook_sent',
data: {
// TODO: Add whatever other attributes
},
})
}
}

View file

@ -1,4 +1,4 @@
import Model from '../../models/Model'
import Model from '../models/Model'
export default class Journey extends Model {

View file

@ -1,20 +1,20 @@
import { add, isFuture } from 'date-fns'
import Model from '../../models/Model'
import { User } from '../../models/User'
import { Rule, check } from './RuleEngine'
import Model from '../models/Model'
import { User } from '../models/User'
import Rule from '../rules/Rule'
import { check } from '../rules/RuleEngine'
import { getJourneyStep, getUserJourneyStep } from './JourneyRepository'
import { UserEvent } from './UserEvent'
import EmailJob from '../../jobs/EmailJob'
import App from '../../app'
import TextJob from '../../jobs/TextJob'
import WebhookJob from '../../jobs/WebhookJob'
import EmailJob from '../jobs/EmailJob'
import App from '../app'
import TextJob from '../jobs/TextJob'
import WebhookJob from '../jobs/WebhookJob'
export class JourneyUserStep extends Model {
user_id!: number
type!: string
journey_id!: number
step_id!: number
created_at!: Date
static tableName = 'journey_user_step'
}
@ -27,6 +27,7 @@ export class JourneyStep extends Model {
static tableName = 'journey_steps'
get $name(): string { return this.constructor.name }
static jsonAttributes = ['data']
async step(user: User, type: string) {
await JourneyUserStep.insert({
@ -134,13 +135,11 @@ export class JourneyStep extends Model {
export class JourneyEntrance extends JourneyStep {
entrance_type!: 'user' | 'event'
rules: Rule[] = []
parseJson(json: any) {
super.parseJson(json)
this.entrance_type = json?.data?.entrance_type
this.rules = json?.data.rules
}
@ -157,15 +156,10 @@ export class JourneyEntrance extends JourneyStep {
}
async condition(user: User, event?: UserEvent): Promise<boolean> {
// Based on entrance type get flattened user or event
const obj = this.entrance_type === 'user' ? user.flatten() : event?.flatten()
// If entrance is event based and we don't have an event, break
if (!obj) return false
// Check that all rules are met
return check(obj, this.rules)
return check({
user: user.flatten(),
event: event?.flatten(),
}, this.rules)
}
}
@ -272,15 +266,10 @@ export class JourneyGate extends JourneyStep {
}
async condition(user: User, event?: UserEvent): Promise<boolean> {
// Based on entrance type get flattened user or event
const obj = this.entrance_type === 'user' ? user.flatten() : event?.flatten()
// If entrance is event based and we don't have an event, break
if (!obj) return false
// Check that all rules are met
return check(obj, [this.rule])
return check({
user: user.flatten(),
event: event?.flatten(),
}, [this.rule])
}
}

View file

@ -1,20 +1,23 @@
import Model from '../../models/Model'
import Model, { ModelParams } from '../models/Model'
export interface TemplateEvent extends Record<string, any> {
name: string
}
export class UserEvent extends Model {
project_id!: number
user_id!: number
name!: string
properties!: Record<string, any>
created_at!: Date
updated_at!: Date
data!: Record<string, unknown>
static jsonAttributes = ['data']
flatten(): TemplateEvent {
return {
...this.properties,
...this.data,
name: this.name,
}
}
}
export type UserEventParams = Omit<UserEvent, ModelParams | 'flatten'>

View file

@ -0,0 +1,5 @@
import { UserEvent, UserEventParams } from './UserEvent'
export const createEvent = async (event: UserEventParams): Promise<UserEvent> => {
return await UserEvent.insertAndFetch(event)
}

View file

@ -0,0 +1,8 @@
import { User } from '../models/User'
export const getUserFromExternalId = async (projectId: number, externalId: string): Promise<User | undefined> => {
return await User.first(
qb => qb.where('external_id', externalId)
.where('project_id', projectId),
)
}

View file

@ -1,5 +1,5 @@
import { Project } from '../../../models/Project'
import { User } from '../../../models/User'
import { Project } from '../../models/Project'
import { User } from '../../models/User'
import Journey from '../Journey'
import { lastJourneyStep } from '../JourneyRepository'
import JourneyService from '../JourneyService'
@ -16,6 +16,7 @@ describe('Run', () => {
}, journey.id)
await JourneyEntrance.create('user', [{
type: 'string',
group: 'user',
path: '$.language',
operator: '=',
value: 'en',

View file

@ -1,4 +1,4 @@
import { User } from '../../../models/User'
import { User } from '../../models/User'
import { JourneyStep, JourneyEntrance, JourneyMap } from '../JourneyStep'
describe('JourneyEntrance', () => {
@ -9,6 +9,7 @@ describe('JourneyEntrance', () => {
const user = User.fromJson({ email })
const entrance = await JourneyEntrance.create('user', [{
type: 'string',
group: 'user',
path: '$.email',
operator: '=',
value: email,
@ -24,6 +25,7 @@ describe('JourneyEntrance', () => {
const user = User.fromJson({ email })
const entrance = await JourneyEntrance.create('user', [{
type: 'string',
group: 'user',
path: '$.email',
operator: '=',
value: 'notequal@test.com',
@ -39,6 +41,7 @@ describe('JourneyEntrance', () => {
const user = User.fromJson({ email })
const entrance = await JourneyEntrance.create('user', [{
type: 'string',
group: 'user',
path: '$.email',
operator: '!=',
value: 'notequal@test.com',
@ -54,6 +57,7 @@ describe('JourneyEntrance', () => {
const user = User.fromJson({ email })
const entrance = await JourneyEntrance.create('user', [{
type: 'string',
group: 'user',
path: '$.email',
operator: 'is set',
}])

16
src/lists/List.ts Normal file
View file

@ -0,0 +1,16 @@
import Model from '../models/Model'
import Rule from '../rules/Rule'
export default class List extends Model {
project_id!: number
name!: string
rules!: Rule[]
}
export class UserList extends Model {
user_id!: number
list_id!: number
event_id!: number
static tableName = 'user_list'
}

35
src/lists/ListService.ts Normal file
View file

@ -0,0 +1,35 @@
import { UserEvent } from '../journey/UserEvent'
import { User } from '../models/User'
import { check } from '../rules/RuleEngine'
import List, { UserList } from './List'
const getUserListIds = async (user_id: number): Promise<number[]> => {
const relations = await UserList.all(qb => qb.where('user_id', user_id))
return relations.map(item => item.list_id)
}
export const updateLists = async (user: User, event?: UserEvent) => {
const lists = await List.all(qb => qb.where('project_id', user.project_id))
const existingLists = await getUserListIds(user.id)
for (const list of lists) {
// TODO: Check that the user wasn't previously unsubscribed from list
// Check to see if user condition matches list requirements
const result = check({
user: user.flatten(),
event: event?.flatten(),
}, list.rules)
// If check passes and user isn't already in the list, add
if (result && !existingLists.includes(list.id)) {
await UserList.insert({
user_id: user.id,
list_id: list.id,
event_id: event?.id ?? undefined,
})
}
}
}

View file

@ -5,6 +5,10 @@ import { pascalToSnakeCase, pluralize } from '../utilities'
export default class Model {
id!: number
created_at: Date = new Date()
updated_at: Date = new Date()
static jsonAttributes: string[] = []
static fromJson<T extends typeof Model>(this: T, json: Partial<InstanceType<T>>): InstanceType<T> {
const model = new this()
@ -16,6 +20,13 @@ export default class Model {
Object.assign(this, json)
}
static formatJson(json: any): Record<string, unknown> {
for (const attribute of this.jsonAttributes) {
json[attribute] = JSON.stringify(json[attribute])
}
return json
}
static query<T extends typeof Model>(this: T, db: Knex = App.main.db): Knex.QueryBuilder<InstanceType<T>> {
return this.table(db)
}
@ -55,7 +66,8 @@ export default class Model {
data: Partial<InstanceType<T>> = {},
db: Knex = App.main.db,
): Promise<number> {
return await this.table(db).insert(data)
const formattedData = this.formatJson(data)
return await this.table(db).insert(formattedData)
}
static async insertAndFetch<T extends typeof Model>(
@ -63,17 +75,31 @@ export default class Model {
data: Partial<InstanceType<T>> = {},
db: Knex = App.main.db,
): Promise<InstanceType<T>> {
const id: number = await this.table(db).insert(data)
const formattedData = this.formatJson(data)
const id: number = await this.table(db).insert(formattedData)
return await this.find(id) as InstanceType<T>
}
static async update<T extends typeof Model>(
this: T,
where: (builder: Knex.QueryBuilder<any>) => Knex.QueryBuilder<any>,
data: any = {},
data: Partial<InstanceType<T>> = {},
db: Knex = App.main.db,
): Promise<number> {
return await where(this.table(db)).update(data)
const formattedData = this.formatJson(data)
return await where(this.table(db)).update(formattedData)
}
static async updateAndFetch<T extends typeof Model>(
this: T,
id: number,
data: Partial<InstanceType<T>> = {},
db: Knex = App.main.db,
): Promise<InstanceType<T>> {
const formattedData = this.formatJson(data)
console.log(formattedData)
await this.table(db).where('id', id).update(formattedData)
return await this.find(id) as InstanceType<T>
}
static async delete<T extends typeof Model>(
@ -92,3 +118,5 @@ export default class Model {
return db(this.tableName)
}
}
export type ModelParams = 'id' | 'created_at' | 'updated_at' | 'parseJson'

View file

@ -4,26 +4,16 @@ export class Project extends Model {
public name!: string
public description?: string
public created_at!: Date
public updated_at!: Date
public deleted_at?: Date
}
export type ProjectParams = Omit<Project, 'id' | 'created_at' | 'updated_at' | 'deleted_at' | 'parseJson'>
export class ProjectApiKey {
export class ProjectApiKey extends Model {
id!: number
project_id!: number
value!: string
name!: string
description?: string
created_at!: Date
updated_at!: Date
deleted_at?: Date
constructor(json: any) {
Object.assign(this, json)
}
}

View file

@ -32,8 +32,8 @@ export class User extends Model {
devices!: Device[]
data!: Record<string, any> // first_name, last_name live in data
attributes!: UserAttribute[] //???
created_at!: Date
updated_at!: Date
static jsonAttributes = ['data']
flatten(): TemplateUser {
return {

View file

@ -4,12 +4,6 @@ import MemoryQueueProvider, { MemoryConfig } from './MemoryQueueProvider'
import { LoggerConfig } from '../config/logger'
import QueueProvider from './QueueProvider'
import { DriverConfig } from '../config/env'
import EmailJob from '../jobs/EmailJob'
import UserPatchJob from '../jobs/UserPatchJob'
import UserDeleteJob from '../jobs/UserDeleteJob'
import EventPostJob from '../jobs/EventPostJob'
import TextJob from '../jobs/TextJob'
import WebhookJob from '../jobs/WebhookJob'
export type QueueDriver = 'sqs' | 'memory' | 'logger'
export type QueueConfig = SQSConfig | MemoryConfig | LoggerConfig
@ -30,13 +24,6 @@ export default class Queue {
} else {
throw new Error('A valid queue must be defined!')
}
this.register(EmailJob)
this.register(TextJob)
this.register(WebhookJob)
this.register(UserPatchJob)
this.register(UserDeleteJob)
this.register(EventPostJob)
}
async dequeue(job: EncodedJob): Promise<boolean> {

12
src/rules/Rule.ts Normal file
View file

@ -0,0 +1,12 @@
export type Operator = '=' | '!=' | '<' |'<=' | '>' | '>=' | '=' | 'is set' | 'is not set' | 'or' | 'and' | 'xor'
export type RuleType = 'wrapper' | 'string' | 'number' | 'boolean' | 'date' | 'array'
export type RuleGroup = 'user' | 'event'
export default interface Rule {
type: RuleType
group: RuleGroup
path: string
operator: Operator
value?: unknown
children?: Rule[]
}

View file

@ -1,15 +1,7 @@
import jsonpath from 'jsonpath'
export type Operator = '=' | '!=' | '<' |'<=' | '>' | '>=' | '=' | 'is set' | 'is not set' | 'or' | 'and' | 'xor'
export type RuleType = 'wrapper' | 'string' | 'number' | 'boolean' | 'date' | 'array'
export interface Rule {
type: RuleType
path: string
operator: Operator
value?: unknown
children?: Rule[]
}
import { TemplateEvent } from '../journey/UserEvent'
import { TemplateUser } from '../models/User'
import Rule, { Operator, RuleGroup, RuleType } from './Rule'
class Registry<T> {
#registered: { [key: string]: T } = {}
@ -24,8 +16,13 @@ class Registry<T> {
}
}
interface RuleCheckInput {
user: TemplateUser
event?: TemplateEvent
}
interface RuleCheck {
check(value: Record<string, unknown>, rule: Rule): boolean
check(value: RuleCheckInput, rule: Rule): boolean
}
const ruleRegistry = new Registry<RuleCheck>()
@ -36,8 +33,16 @@ class RuleEvalException extends Error {
}
}
const queryValue = <T>(input: RuleCheckInput, rule: Rule, cast: (item: any) => T): T | undefined => {
const inputValue = input[rule.group]
if (!inputValue) return undefined
const pathValue = jsonpath.query(input[rule.group], rule.path)
if (!pathValue) return undefined
return cast(pathValue)
}
const WrapperRule: RuleCheck = {
check(input: Record<string, unknown>, rule: Rule) {
check(input: RuleCheckInput, rule: Rule) {
const predicate = (child: Rule) => ruleRegistry.get(child.type)?.check(input, child)
if (!rule.children) return true
@ -58,8 +63,10 @@ const WrapperRule: RuleCheck = {
}
const StringRule: RuleCheck = {
check(input: Record<string, unknown>, rule: Rule) {
const value = String(jsonpath.query(input, rule.path))
check(input: RuleCheckInput, rule: Rule) {
const value = queryValue(input, rule, item => String(item))
if (!value) return false
if (rule.operator === '=') {
return value === rule.value
}
@ -77,8 +84,9 @@ const StringRule: RuleCheck = {
}
const NumberRule: RuleCheck = {
check(input: Record<string, unknown>, rule: Rule) {
const value = Number(jsonpath.query(input, rule.path))
check(input: RuleCheckInput, rule: Rule) {
const value = queryValue(input, rule, item => Number(item))
if (!value) return false
if (rule.operator === 'is set') {
return value != null
@ -122,8 +130,8 @@ const NumberRule: RuleCheck = {
}
const BooleanRule: RuleCheck = {
check(input: Record<string, unknown>, rule: Rule) {
const value = Boolean(jsonpath.query(input, rule.path))
check(input: RuleCheckInput, rule: Rule) {
const value = queryValue(input, rule, item => Boolean(item))
return value === rule.value
},
}
@ -134,13 +142,22 @@ ruleRegistry.register('boolean', BooleanRule)
// TODO: Add dates ruleset
ruleRegistry.register('wrapper', WrapperRule)
export const check = (value: Record<string, unknown>, rules: Rule[]) => {
const baseRule = make('wrapper', '$', 'and', undefined, rules)
export const check = (value: RuleCheckInput, rules: Rule[]) => {
const baseRule = make({ type: 'wrapper', operator: 'and', children: rules })
return ruleRegistry.get('wrapper').check(value, baseRule)
}
export const make = (type: RuleType, path = '$', operator: Operator = '=', value?: unknown, children?: Rule[]): Rule => {
interface RuleMake {
type: RuleType
group?: RuleGroup
path?: string
operator?: Operator
value?: unknown
children?: Rule[]
}
export const make = ({ type, group = 'user', path = '$', operator = '=', value, children }: RuleMake): Rule => {
return {
type, path, operator, value, children,
type, group, path, operator, value, children,
}
}

View file

@ -0,0 +1,106 @@
import { check, make } from '../RuleEngine'
describe('RuleEngine', () => {
describe('string', () => {
test('equals', () => {
const email = 'test@test.com'
const value = {
user: {
id: 'abcd',
email,
name: 'Name',
},
}
const shouldPass = check(value, [
make({ type: 'string', path: '$.email', value: email }),
])
expect(shouldPass).toBeTruthy()
})
test('does not equals', () => {
const email = 'test@test.com'
const value = {
user: {
id: 'abcd',
email,
name: 'Name',
},
}
const shouldPass = check(value, [
make({ type: 'string', path: '$.email', operator: '!=', value: email }),
])
expect(shouldPass).toBeFalsy()
})
test('is set', () => {
const value = {
user: {
id: 'abcd',
email: 'test@test.com',
name: 'Name',
},
}
const shouldPass = check(value, [
make({ type: 'string', path: '$.project', operator: 'is set' }),
])
expect(shouldPass).toBeFalsy()
})
})
describe('multiple', () => {
test('combination event and user and types', () => {
const value = {
user: {
id: 'abcd',
email: 'test@test.com',
name: 'Name',
project: 'Parcelvoy',
},
event: {
name: 'beat-game',
score: {
total: 5,
isRecord: true,
},
},
}
const shouldPass = check(value, [
make({ type: 'string', path: '$.project', operator: 'is set' }),
make({ type: 'number', group: 'event', path: '$.score.total', operator: '<=', value: 5 }),
make({ type: 'boolean', group: 'event', path: '$.score.isRecord', value: true }),
])
expect(shouldPass).toBeTruthy()
})
test('combination of conditional clauses on rules', () => {
const value = {
user: {
id: 'abcd',
email: 'test@test.com',
name: 'Name',
project: 'Parcelvoy',
},
event: {
name: 'beat-game',
score: {
total: 5,
isRecord: true,
},
},
}
const shouldPass = check(value, [
make({ type: 'string', path: '$.project', operator: 'is set' }),
make({
type: 'wrapper',
operator: 'or',
children: [
make({ type: 'number', group: 'event', path: '$.score.total', operator: '<', value: 5 }),
make({ type: 'boolean', group: 'event', path: '$.score.isRecord', value: true }),
],
}),
])
expect(shouldPass).toBeTruthy()
})
})
})

View file

@ -1,13 +0,0 @@
import { UserEvent } from './UserEvent'
export const createEvent = async (
user_id: number,
name: string,
properties: Record<string, any>,
): Promise<void> => {
UserEvent.insert({
user_id,
name,
properties,
})
}