mailtrain/server/models/segments.js
Tomas Bures 0d7f962c86 Fix - subscriber custom data were not listed in correct order in the subcribers list
"Test user" field added to segment rules
Configuration option to automatically share arbitrary namespace based on user role.
2019-01-12 11:21:38 +01:00

435 lines
14 KiB
JavaScript

'use strict';
const knex = require('../lib/knex');
const dtHelpers = require('../lib/dt-helpers');
const interoperableErrors = require('../../shared/interoperable-errors');
const shares = require('./shares');
const { enforce, filterObject } = require('../lib/helpers');
const hasher = require('node-object-hash')();
const moment = require('moment');
const fields = require('./fields');
const subscriptions = require('./subscriptions');
const dependencyHelpers = require('../lib/dependency-helpers');
const allowedKeys = new Set(['name', 'settings']);
const predefColumns = [
{
column: 'email',
type: 'text'
},
{
column: 'opt_in_country',
type: 'text'
},
{
column: 'created',
type: 'date'
},
{
column: 'latest_open',
type: 'date'
},
{
column: 'latest_click',
type: 'date'
},
{
column: 'is_test',
type: 'option'
}
];
const compositeRuleTypes = {
all: {
addQuery: (query, rules, addSubQuery) => {
for (const rule of rules) {
query.where(function() {
addSubQuery(this, rule);
});
}
}
},
some: {
addQuery: (query, rules, addSubQuery) => {
for (const rule of rules) {
query.orWhere(function() {
addSubQuery(this, rule);
});
}
}
},
none: {
addQuery: (query, rules, addSubQuery) => {
for (const rule of rules) {
query.whereNot(function() {
addSubQuery(this, rule);
});
}
}
},
};
const primitiveRuleTypes = {
text: {},
website: {},
number: {},
date: {},
birthday: {},
option: {},
'dropdown-enum': {},
'radio-enum': {}
};
function stringValueSettings(sqlOperator, allowEmpty) {
return {
validate: rule => {
enforce(typeof rule.value === 'string', 'Invalid value type in rule');
enforce(allowEmpty || rule.value, 'Value in rule must not be empty');
},
addQuery: (subsTableName, query, rule) => query.where(subsTableName + '. ' + rule.column, sqlOperator, rule.value)
};
}
function numberValueSettings(sqlOperator) {
return {
validate: rule => {
enforce(typeof rule.value === 'number', 'Invalid value type in rule');
},
addQuery: (subsTableName, query, rule) => query.where(subsTableName + '. ' + rule.column, sqlOperator, rule.value)
};
}
function dateValueSettings(thisDaySqlOperator, nextDaySqlOperator) {
return {
validate: rule => {
const date = moment.utc(rule.value);
enforce(date.isValid(), 'Invalid date value');
},
addQuery: (subsTableName, query, rule) => {
const thisDay = moment.utc(rule.value).startOf('day');
const nextDay = moment(thisDay).add(1, 'days');
if (thisDaySqlOperator) {
query.where(subsTableName + '. ' + rule.column, thisDaySqlOperator, thisDay.toDate())
}
if (nextDaySqlOperator) {
query.where(subsTableName + '. ' + rule.column, nextDaySqlOperator, nextDay.toDate());
}
}
};
}
function dateRelativeValueSettings(todaySqlOperator, tomorrowSqlOperator) {
return {
validate: rule => {
enforce(typeof rule.value === 'number', 'Invalid value type in rule');
},
addQuery: (subsTableName, query, rule) => {
const todayWithOffset = moment.utc().startOf('day').add(rule.value, 'days');
const tomorrowWithOffset = moment(todayWithOffset).add(1, 'days');
if (todaySqlOperator) {
query.where(subsTableName + '. ' + rule.column, todaySqlOperator, todayWithOffset.toDate())
}
if (tomorrowSqlOperator) {
query.where(subsTableName + '. ' + rule.column, tomorrowSqlOperator, tomorrowWithOffset.toDate());
}
}
};
}
function optionValueSettings(value) {
return {
validate: rule => {},
addQuery: (subsTableName, query, rule) => query.where(subsTableName + '. ' + rule.column, value)
};
}
primitiveRuleTypes.text.eq = stringValueSettings('=', true);
primitiveRuleTypes.text.like = stringValueSettings('LIKE', true);
primitiveRuleTypes.text.re = stringValueSettings('REGEXP', true);
primitiveRuleTypes.text.lt = stringValueSettings('<', false);
primitiveRuleTypes.text.le = stringValueSettings('<=', false);
primitiveRuleTypes.text.gt = stringValueSettings('>', false);
primitiveRuleTypes.text.ge = stringValueSettings('>=', false);
primitiveRuleTypes.website.eq = stringValueSettings('=', true);
primitiveRuleTypes.website.like = stringValueSettings('LIKE', true);
primitiveRuleTypes.website.re = stringValueSettings('REGEXP', true);
primitiveRuleTypes.number.eq = numberValueSettings('=');
primitiveRuleTypes.number.lt = numberValueSettings('<');
primitiveRuleTypes.number.le = numberValueSettings('<=');
primitiveRuleTypes.number.gt = numberValueSettings('>');
primitiveRuleTypes.number.ge = numberValueSettings('>=');
primitiveRuleTypes.date.eq = dateValueSettings('>=', '<');
primitiveRuleTypes.date.lt = dateValueSettings('<', null);
primitiveRuleTypes.date.le = dateValueSettings(null, '<');
primitiveRuleTypes.date.gt = dateValueSettings(null, '>=');
primitiveRuleTypes.date.ge = dateValueSettings('>=', null);
primitiveRuleTypes.date.eqTodayPlusDays = dateRelativeValueSettings('>=', '<');
primitiveRuleTypes.date.ltTodayPlusDays = dateRelativeValueSettings('<', null);
primitiveRuleTypes.date.leTodayPlusDays = dateRelativeValueSettings(null, '<');
primitiveRuleTypes.date.gtTodayPlusDays = dateRelativeValueSettings(null, '>=');
primitiveRuleTypes.date.geTodayPlusDays = dateRelativeValueSettings('>=', null);
primitiveRuleTypes.birthday.eq = dateValueSettings('>=', '<');
primitiveRuleTypes.birthday.lt = dateValueSettings('<', null);
primitiveRuleTypes.birthday.le = dateValueSettings(null, '<');
primitiveRuleTypes.birthday.gt = dateValueSettings(null, '>=');
primitiveRuleTypes.birthday.ge = dateValueSettings('>=', null);
primitiveRuleTypes.option.isTrue = optionValueSettings(true);
primitiveRuleTypes.option.isFalse = optionValueSettings(false);
primitiveRuleTypes['dropdown-enum'].eq = stringValueSettings('=', true);
primitiveRuleTypes['dropdown-enum'].like = stringValueSettings('LIKE', true);
primitiveRuleTypes['dropdown-enum'].re = stringValueSettings('REGEXP', true);
primitiveRuleTypes['dropdown-enum'].lt = stringValueSettings('<', false);
primitiveRuleTypes['dropdown-enum'].le = stringValueSettings('<=', false);
primitiveRuleTypes['dropdown-enum'].gt = stringValueSettings('>', false);
primitiveRuleTypes['dropdown-enum'].ge = stringValueSettings('>=', false);
primitiveRuleTypes['radio-enum'].eq = stringValueSettings('=', true);
primitiveRuleTypes['radio-enum'].like = stringValueSettings('LIKE', true);
primitiveRuleTypes['radio-enum'].re = stringValueSettings('REGEXP', true);
primitiveRuleTypes['radio-enum'].lt = stringValueSettings('<', false);
primitiveRuleTypes['radio-enum'].le = stringValueSettings('<=', false);
primitiveRuleTypes['radio-enum'].gt = stringValueSettings('>', false);
primitiveRuleTypes['radio-enum'].ge = stringValueSettings('>=', false);
function hash(entity) {
return hasher.hash(filterObject(entity, allowedKeys));
}
async function listDTAjax(context, listId, params) {
return await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'viewSegments');
return await dtHelpers.ajaxListTx(
tx,
params,
builder => builder
.from('segments')
.where('list', listId),
['id', 'name']
);
});
}
async function listIdName(context, listId) {
return await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, ['viewSegments']);
return await tx('segments').select(['id', 'name']).where('list', listId).orderBy('name', 'asc');
});
}
async function getByIdTx(tx, context, listId, id) {
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'viewSegments');
const entity = await tx('segments').where({id, list: listId}).first();
if (!entity) {
throw new interoperableErrors.NotFoundError();
}
entity.settings = JSON.parse(entity.settings);
return entity;
}
async function getById(context, listId, id) {
return await knex.transaction(async tx => {
return getByIdTx(tx, context, listId, id);
});
}
async function _validateAndPreprocess(tx, listId, entity, isCreate) {
enforce(entity.name, 'Name must be present');
enforce(entity.settings, 'Settings must be present');
enforce(entity.settings.rootRule, 'Root rule must be present in setting');
enforce(entity.settings.rootRule.type in compositeRuleTypes, 'Root rule must be composite');
const flds = await fields.listTx(tx, listId);
const allowedFlds = [
...predefColumns,
...flds.filter(fld => fld.type in primitiveRuleTypes)
];
const fieldsByColumn = {};
for (const fld of allowedFlds) {
fieldsByColumn[fld.column] = fld;
}
function validateRule(rule) {
if (rule.type in compositeRuleTypes) {
for (const childRule of rule.rules) {
validateRule(childRule);
}
} else {
const colType = fieldsByColumn[rule.column].type;
primitiveRuleTypes[colType][rule.type].validate(rule);
}
}
validateRule(entity.settings.rootRule);
entity.settings = JSON.stringify(entity.settings);
}
async function create(context, listId, entity) {
return await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageSegments');
await _validateAndPreprocess(tx, listId, entity, true);
const filteredEntity = filterObject(entity, allowedKeys);
filteredEntity.list = listId;
const ids = await tx('segments').insert(filteredEntity);
const id = ids[0];
return id;
});
}
async function updateWithConsistencyCheck(context, listId, entity) {
await knex.transaction(async tx => {
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageSegments');
const existing = await tx('segments').where({list: listId, id: entity.id}).first();
if (!existing) {
throw new interoperableErrors.NotFoundError();
}
existing.settings = JSON.parse(existing.settings);
const existingHash = hash(existing);
if (existingHash !== entity.originalHash) {
throw new interoperableErrors.ChangedError();
}
await _validateAndPreprocess(tx, listId, entity, false);
await tx('segments').where({list: listId, id: entity.id}).update(filterObject(entity, allowedKeys));
});
}
async function removeTx(tx, context, listId, id) {
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageSegments');
await dependencyHelpers.ensureNoDependencies(tx, context, id, [
{
entityTypeId: 'campaign',
query: tx => tx('campaign_lists')
.where('campaign_lists.segment', id)
.innerJoin('campaigns', 'campaign_lists.campaign', 'campaigns.id')
.select(['campaigns.id', 'campaigns.name'])
}
]);
// The listId "where" is here to prevent deleting segment of a list for which a user does not have permission
await tx('segments').where({list: listId, id}).del();
}
async function remove(context, listId, id) {
await knex.transaction(async tx => {
await removeTx(tx, context, listId, id);
});
}
async function removeAllByListIdTx(tx, context, listId) {
const entities = await tx('segments').where('list', listId).select(['id']);
for (const entity of entities) {
await removeTx(tx, context, listId, entity.id);
}
}
async function removeRulesByColumnTx(tx, context, listId, column) {
await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageSegments');
function pruneChildRules(rule) {
if (rule.type in compositeRuleTypes) {
const newRules = [];
for (const childRule of rule.rules) {
if (childRule.column !== column) {
pruneChildRules(childRule);
newRules.push(childRule);
}
}
rule.rules = newRules;
}
}
const entities = await tx('segments').where({list: listId});
for (const entity of entities) {
const settings = JSON.parse(entity.settings);
pruneChildRules(settings.rootRule);
await tx('segments').where({list: listId, id: entity.id}).update('settings', JSON.stringify(settings));
}
}
async function getQueryGeneratorTx(tx, listId, id) {
const flds = await fields.listTx(tx, listId);
const allowedFlds = [
...predefColumns,
...flds.filter(fld => fld.type in primitiveRuleTypes)
];
const fieldsByColumn = {};
for (const fld of allowedFlds) {
fieldsByColumn[fld.column] = fld;
}
const entity = await tx('segments').where({id, list: listId}).first();
const settings = JSON.parse(entity.settings);
const subsTableName = subscriptions.getSubscriptionTableName(listId);
function processRule(query, rule) {
if (rule.type in compositeRuleTypes) {
compositeRuleTypes[rule.type].addQuery(query, rule.rules, (subQuery, childRule) => {
processRule(subQuery, childRule);
});
} else {
const colType = fieldsByColumn[rule.column].type;
primitiveRuleTypes[colType][rule.type].addQuery(subsTableName, query, rule);
}
}
return query => processRule(query, settings.rootRule);
}
// This is to handle circular dependency with fields.js
module.exports.hash = hash;
module.exports.listDTAjax = listDTAjax;
module.exports.listIdName = listIdName;
module.exports.getById = getById;
module.exports.getByIdTx = getByIdTx;
module.exports.create = create;
module.exports.updateWithConsistencyCheck = updateWithConsistencyCheck;
module.exports.remove = remove;
module.exports.removeAllByListIdTx = removeAllByListIdTx;
module.exports.removeRulesByColumnTx = removeRulesByColumnTx;
module.exports.getQueryGeneratorTx = getQueryGeneratorTx;