Fixed issue #524 Table now displays horizontal scrollbar when the viewport is too narrow (typically on mobile)
443 lines
15 KiB
JavaScript
443 lines
15 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 {ListActivityType} = require('../../shared/activity-log');
|
|
const activityLog = require('../lib/activity-log');
|
|
|
|
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];
|
|
|
|
await activityLog.logEntityActivity('list', ListActivityType.CREATE_SEGMENT, listId, {segmentId: id});
|
|
|
|
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));
|
|
|
|
await activityLog.logEntityActivity('list', ListActivityType.UPDATE_SEGMENT, listId, {segmentId: entity.id});
|
|
});
|
|
}
|
|
|
|
|
|
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();
|
|
|
|
await activityLog.logEntityActivity('list', ListActivityType.REMOVE_SEGMENT, listId, {segmentId: id});
|
|
}
|
|
|
|
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;
|