From e73c0a8b287ea095d55845d577369a75c43834a3 Mon Sep 17 00:00:00 2001 From: Tomas Bures Date: Sun, 13 Aug 2017 20:11:58 +0200 Subject: [PATCH] Work in progress on subscriptions --- client/src/lib/form.js | 76 ++- client/src/lib/page.css | 2 +- client/src/lib/table.js | 148 +++--- client/src/lib/tree.js | 6 +- client/src/lists/List.js | 82 +-- client/src/lists/fields/List.js | 23 +- client/src/lists/forms/List.js | 45 +- client/src/lists/root.js | 16 +- client/src/lists/subscriptions/CUD.js | 486 ++++++++++++++++++ client/src/lists/subscriptions/List.js | 122 +++++ client/src/reports/List.js | 175 +++---- client/src/reports/root.js | 3 +- client/src/reports/templates/CUD.js | 6 +- client/src/reports/templates/List.js | 50 +- client/src/shares/Share.js | 31 +- client/src/shares/UserShares.js | 35 +- client/src/users/List.js | 28 +- config/default.toml | 73 ++- lib/client-helpers.js | 4 +- lib/dt-helpers.js | 203 ++++---- lib/models/subscriptions.js | 2 +- lib/permissions.js | 10 + models/campaigns.js | 25 +- models/fields.js | 44 +- models/forms.js | 18 +- models/lists.js | 28 +- models/namespaces.js | 28 +- models/report-templates.js | 41 +- models/reports.js | 33 +- models/segments.js | 49 ++ models/shares.js | 144 +++--- models/subscriptions.js | 47 +- models/users.js | 8 +- routes/rest/campaigns.js | 2 +- routes/rest/lists.js | 14 + routes/rest/shares.js | 2 +- setup/knex/migrations/20170506102634_base.js | 96 +++- .../20170507083345_create_namespaces.js | 6 +- .../20170507084114_create_permissions.js | 2 +- ...04_create_foreign_keys_in_custom_fields.js | 8 + shared/lists.js | 11 +- workers/reports/report-processor.js | 4 +- 42 files changed, 1558 insertions(+), 678 deletions(-) create mode 100644 client/src/lists/subscriptions/CUD.js create mode 100644 client/src/lists/subscriptions/List.js create mode 100644 models/segments.js create mode 100644 setup/knex/migrations/20170813143004_create_foreign_keys_in_custom_fields.js diff --git a/client/src/lib/form.js b/client/src/lib/form.js index b39ac952..b2d9fc7d 100644 --- a/client/src/lib/form.js +++ b/client/src/lib/form.js @@ -36,7 +36,8 @@ const FormSendMethod = { class Form extends Component { static propTypes = { stateOwner: PropTypes.object.isRequired, - onSubmitAsync: PropTypes.func + onSubmitAsync: PropTypes.func, + inline: PropTypes.bool } static childContextTypes = { @@ -77,7 +78,7 @@ class Form extends Component { } } else { return ( -
+
{props.children}
@@ -105,25 +106,53 @@ class Fieldset extends Component { } } -function wrapInput(id, htmlId, owner, label, help, input) { - const helpBlock = help ?
{help}
: ''; +function wrapInput(id, htmlId, owner, label, help, input, inline) { + const className = id ? owner.addFormValidationClass('form-group', id) : 'form-group'; - return ( -
-
- + let helpBlock = null; + if (help) { + helpBlock =
{help}
; + } + + let validationBlock = null; + if (id) { + const validationMsg = id && owner.getFormValidationMessage(id); + if (validationMsg) { + validationBlock =
{validationMsg}
; + } + } + + const labelBlock = ; + + if (inline) { + return ( +
+ {labelBlock}   {input} + {helpBlock} + {validationBlock}
-
- {input} + ); + } else { + return ( +
+
+ {labelBlock} +
+
+ {input} +
+ {helpBlock} + {validationBlock}
- {helpBlock} - {id &&
{owner.getFormValidationMessage(id)}
} -
- ); + ); + } } -function wrapInputInline(id, htmlId, owner, containerClass, label, text, help, input) { +function wrapInputWithText(id, htmlId, owner, containerClass, label, text, help, input) { const helpBlock = help ?
{help}
: ''; + const validationMsg = id && owner.getFormValidationMessage(id); return (
@@ -134,7 +163,7 @@ function wrapInputInline(id, htmlId, owner, containerClass, label, text, help, i
{helpBlock} - {id &&
{owner.getFormValidationMessage(id)}
} + {id && validationMsg &&
{validationMsg}
}
); } @@ -216,7 +245,7 @@ class CheckBox extends Component { const id = this.props.id; const htmlId = 'form_' + id; - return wrapInputInline(id, htmlId, owner, 'checkbox', props.label, props.text, props.help, + return wrapInputWithText(id, htmlId, owner, 'checkbox', props.label, props.text, props.help, owner.updateFormValue(id, !owner.getFormValue(id))}/> ); } @@ -251,7 +280,9 @@ class Dropdown extends Component { label: PropTypes.string.isRequired, help: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), options: PropTypes.array, - optGroups: PropTypes.array + optGroups: PropTypes.array, + className: PropTypes.string, + inline: PropTypes.bool } static contextTypes = { @@ -276,11 +307,16 @@ class Dropdown extends Component { ); } + let className = 'form-control'; + if (props.className) { + className += ' ' + props.className; + } return wrapInput(id, htmlId, owner, props.label, props.help, - owner.updateFormValue(id, evt.target.value)}> {options} - + , + props.inline ); } } diff --git a/client/src/lib/page.css b/client/src/lib/page.css index f67709fb..e67794fa 100644 --- a/client/src/lib/page.css +++ b/client/src/lib/page.css @@ -60,7 +60,7 @@ h3.legend { .mt-secondary-nav { margin: 0px; background-color: #f5f5f5; - padding: 8px 5px; + padding: 5px 5px; border-radius: 4px; } } diff --git a/client/src/lib/table.js b/client/src/lib/table.js index 807710f0..23f056a6 100644 --- a/client/src/lib/table.js +++ b/client/src/lib/table.js @@ -47,7 +47,6 @@ class Table extends Component { selectionAsArray: PropTypes.bool, onSelectionChangedAsync: PropTypes.func, onSelectionDataAsync: PropTypes.func, - actions: PropTypes.func, withHeader: PropTypes.bool, refreshInterval: PropTypes.number } @@ -177,81 +176,82 @@ class Table extends Component { componentDidMount() { const columns = this.props.columns.slice(); - if (this.props.actions) { - const createdCellFn = (td, data) => { - const linksContainer = jQuery(''); - - let actions = this.props.actions(data); - let options = {}; - - if (!Array.isArray(actions)) { - options = actions; - actions = actions.actions; - } - - for (const action of actions) { - if (action.action) { - const html = ReactDOMServer.renderToStaticMarkup({action.label}); - const elem = jQuery(html); - elem.click((evt) => { evt.preventDefault(); action.action(this) }); - linksContainer.append(elem); - - } else if (action.link) { - const html = ReactDOMServer.renderToStaticMarkup({action.label}); - const elem = jQuery(html); - elem.click((evt) => { evt.preventDefault(); this.navigateTo(action.link) }); - linksContainer.append(elem); - - } else if (action.href) { - const html = ReactDOMServer.renderToStaticMarkup({action.label}); - const elem = jQuery(html); - linksContainer.append(elem); - - } else { - const html = ReactDOMServer.renderToStaticMarkup(action.label); - const elem = jQuery(html); - linksContainer.append(elem); - } - } - - if (options.refreshTimeout) { - const currentMS = Date.now(); - - if (!this.refreshTimeoutAt || this.refreshTimeoutAt > currentMS + options.refreshTimeout) { - clearTimeout(this.refreshTimeoutId); - - this.refreshTimeoutAt = currentMS + options.refreshTimeout; - - this.refreshTimeoutId = setTimeout(() => { - this.refreshTimeoutAt = 0; - this.refresh(); - }, options.refreshTimeout); - } - } - - jQuery(td).html(linksContainer); - }; - - columns.push({ - data: null, - orderable: false, - searchable: false, - type: 'html', - createdCell: createdCellFn - }); - } - - // XSS protection + // XSS protection and actions rendering for (const column of columns) { - const originalRender = column.render; - column.render = (data, ...rest) => { - if (originalRender) { - const markup = originalRender(data, ...rest); - return ReactDOMServer.renderToStaticMarkup(
{markup}
); - } else { - return ReactDOMServer.renderToStaticMarkup(
{data}
) + if (column.actions) { + const createdCellFn = (td, data, rowData) => { + const linksContainer = jQuery(''); + + let actions = column.actions(rowData); + let options = {}; + + if (!Array.isArray(actions)) { + options = actions; + actions = actions.actions; + } + + for (const action of actions) { + if (action.action) { + const html = ReactDOMServer.renderToStaticMarkup({action.label}); + const elem = jQuery(html); + elem.click((evt) => { evt.preventDefault(); action.action(this) }); + linksContainer.append(elem); + + } else if (action.link) { + const html = ReactDOMServer.renderToStaticMarkup({action.label}); + const elem = jQuery(html); + elem.click((evt) => { evt.preventDefault(); this.navigateTo(action.link) }); + linksContainer.append(elem); + + } else if (action.href) { + const html = ReactDOMServer.renderToStaticMarkup({action.label}); + const elem = jQuery(html); + linksContainer.append(elem); + + } else { + const html = ReactDOMServer.renderToStaticMarkup({action.label}); + const elem = jQuery(html); + linksContainer.append(elem); + } + } + + if (options.refreshTimeout) { + const currentMS = Date.now(); + + if (!this.refreshTimeoutAt || this.refreshTimeoutAt > currentMS + options.refreshTimeout) { + clearTimeout(this.refreshTimeoutId); + + this.refreshTimeoutAt = currentMS + options.refreshTimeout; + + this.refreshTimeoutId = setTimeout(() => { + this.refreshTimeoutAt = 0; + this.refresh(); + }, options.refreshTimeout); + } + } + + jQuery(td).html(linksContainer); + }; + + column.type = 'html'; + column.createdCell = createdCellFn; + + if (!('data' in column)) { + column.data = null; + column.orderable = false; + column.searchable = false; } - }; + } else { + const originalRender = column.render; + column.render = (data, ...rest) => { + if (originalRender) { + const markup = originalRender(data, ...rest); + return ReactDOMServer.renderToStaticMarkup(
{markup}
); + } else { + return ReactDOMServer.renderToStaticMarkup(
{data}
) + } + }; + } column.title = ReactDOMServer.renderToStaticMarkup(
{column.title}
); } diff --git a/client/src/lib/tree.js b/client/src/lib/tree.js index ac4dabde..a0fd819c 100644 --- a/client/src/lib/tree.js +++ b/client/src/lib/tree.js @@ -88,11 +88,13 @@ class TreeTable extends Component { // XSS protection sanitizeTreeData(unsafeData) { - const data = unsafeData.slice(); - for (const entry of data) { + const data = []; + for (const unsafeEntry of unsafeData) { + const entry = Object.assign({}, unsafeEntry); entry.title = ReactDOMServer.renderToStaticMarkup(
{entry.title}
) entry.description = ReactDOMServer.renderToStaticMarkup(
{entry.description}
) entry.children = this.sanitizeTreeData(entry.children); + data.push(entry); } return data; } diff --git a/client/src/lists/List.js b/client/src/lists/List.js index 340ff883..34b48727 100644 --- a/client/src/lists/List.js +++ b/client/src/lists/List.js @@ -6,6 +6,7 @@ import {requiresAuthenticatedUser, withPageHelpers, Title, Toolbar, NavButton} f import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling'; import { Table } from '../lib/table'; import axios from '../lib/axios'; +import {Link} from "react-router-dom"; @translate() @withPageHelpers @@ -41,40 +42,59 @@ export default class List extends Component { render() { const t = this.props.t; - const actions = data => { - const actions = []; - const perms = data[6]; - - if (perms.includes('edit')) { - actions.push({ - label: , - link: `/lists/${data[0]}/edit` - }); - } - - if (perms.includes('manageFields')) { - actions.push({ - label: , - link: `/lists/${data[0]}/fields` - }); - } - - if (perms.includes('share')) { - actions.push({ - label: , - link: `/lists/${data[0]}/share` - }); - } - - return actions; - }; - const columns = [ - { data: 1, title: t('Name') }, + { + data: 1, + title: t('Name'), + actions: data => { + const perms = data[6]; + if (perms.includes('viewSubscriptions')) { + return [{label: data[1], link: `/lists/${data[0]}/subscriptions`}]; + } else { + return [{label: data[1]}]; + } + } + }, { data: 2, title: t('ID'), render: data => {data} }, { data: 3, title: t('Subscribers') }, { data: 4, title: t('Description') }, - { data: 5, title: t('Namespace') } + { data: 5, title: t('Namespace') }, + { + actions: data => { + const actions = []; + const perms = data[6]; + + if (perms.includes('viewSubscriptions')) { + actions.push({ + label: , + link: `/lists/${data[0]}/subscriptions` + }); + } + + if (perms.includes('edit')) { + actions.push({ + label: , + link: `/lists/${data[0]}/edit` + }); + } + + if (perms.includes('manageFields')) { + actions.push({ + label: , + link: `/lists/${data[0]}/fields` + }); + } + + if (perms.includes('share')) { + actions.push({ + label: , + link: `/lists/${data[0]}/share` + }); + } + + return actions; + } + } ]; return ( @@ -88,7 +108,7 @@ export default class List extends Component { {t('Lists')} - +
); } diff --git a/client/src/lists/fields/List.js b/client/src/lists/fields/List.js index 322ad99a..04667ddf 100644 --- a/client/src/lists/fields/List.js +++ b/client/src/lists/fields/List.js @@ -1,6 +1,7 @@ 'use strict'; import React, { Component } from 'react'; +import PropTypes from 'prop-types'; import { translate } from 'react-i18next'; import {requiresAuthenticatedUser, withPageHelpers, Title, Toolbar, NavButton} from '../../lib/page'; import { withErrorHandling } from '../../lib/error-handling'; @@ -17,39 +18,43 @@ export default class List extends Component { this.state = {}; - this.state.listId = parseInt(props.match.params.listId); this.fieldTypes = getFieldTypes(props.t); } + static propTypes = { + list: PropTypes.object + } + componentDidMount() { } render() { const t = this.props.t; - const actions = data => [{ - label: , - link: `/lists/${this.state.listId}/fields/${data[0]}/edit` - }]; - const columns = [ { data: 4, title: "#" }, { data: 1, title: t('Name'), render: (data, cmd, rowData) => rowData[2] === 'option' ? {data} : data }, { data: 2, title: t('Type'), render: data => this.fieldTypes[data].label, sortable: false, searchable: false }, - { data: 3, title: t('Merge Tag') } + { data: 3, title: t('Merge Tag') }, + { + actions: data => [{ + label: , + link: `/lists/${this.props.list.id}/fields/${data[0]}/edit` + }] + } ]; return (
- + {t('Fields')} -
+
); } diff --git a/client/src/lists/forms/List.js b/client/src/lists/forms/List.js index 7a1e3eb9..89cfdce9 100644 --- a/client/src/lists/forms/List.js +++ b/client/src/lists/forms/List.js @@ -41,30 +41,31 @@ export default class List extends Component { render() { const t = this.props.t; - const actions = data => { - const actions = []; - const perms = data[4]; - - if (perms.includes('edit')) { - actions.push({ - label: , - link: `/lists/forms/${data[0]}/edit` - }); - } - if (perms.includes('share')) { - actions.push({ - label: , - link: `/lists/forms/${data[0]}/share` - }); - } - - return actions; - }; - const columns = [ { data: 1, title: t('Name') }, { data: 2, title: t('Description') }, - { data: 3, title: t('Namespace') } + { data: 3, title: t('Namespace') }, + { + actions: data => { + const actions = []; + const perms = data[4]; + + if (perms.includes('edit')) { + actions.push({ + label: , + link: `/lists/forms/${data[0]}/edit` + }); + } + if (perms.includes('share')) { + actions.push({ + label: , + link: `/lists/forms/${data[0]}/share` + }); + } + + return actions; + } + } ]; return ( @@ -77,7 +78,7 @@ export default class List extends Component { {t('Forms')} -
+
); } diff --git a/client/src/lists/root.js b/client/src/lists/root.js index b93ac090..6d277d87 100644 --- a/client/src/lists/root.js +++ b/client/src/lists/root.js @@ -12,6 +12,7 @@ import FormsList from './forms/List'; import FormsCUD from './forms/CUD'; import FieldsList from './fields/List'; import FieldsCUD from './fields/CUD'; +import SubscriptionsList from './subscriptions/List'; import Share from '../shares/Share'; @@ -33,8 +34,14 @@ const getStructure = t => { resolve: { list: params => `/rest/lists/${params.listId}` }, - link: params => `/lists/${params.listId}/edit`, + link: params => `/lists/${params.listId}/subscriptions`, navs: { + subscriptions: { + title: t('Subscribers'), + link: params => `/lists/${params.listId}/subscriptions`, + visible: resolved => resolved.list.permissions.includes('viewSubscriptions'), + render: props => + }, ':action(edit|delete)': { title: t('Edit'), link: params => `/lists/${params.listId}/edit`, @@ -45,7 +52,7 @@ const getStructure = t => { title: t('Fields'), link: params => `/lists/${params.listId}/fields/`, visible: resolved => resolved.list.permissions.includes('manageFields'), - component: FieldsList, + render: props => , children: { ':fieldId([0-9]+)': { title: resolved => t('Field "{{name}}"', {name: resolved.field.name}), @@ -67,6 +74,11 @@ const getStructure = t => { } } }, + segments: { + title: t('Segments'), + link: params => `/lists/${params.listId}/segments`, + visible: resolved => resolved.list.permissions.includes('manageSegments') + }, share: { title: t('Share'), link: params => `/lists/${params.listId}/share`, diff --git a/client/src/lists/subscriptions/CUD.js b/client/src/lists/subscriptions/CUD.js new file mode 100644 index 00000000..834b80b1 --- /dev/null +++ b/client/src/lists/subscriptions/CUD.js @@ -0,0 +1,486 @@ +'use strict'; + +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { translate, Trans } from 'react-i18next'; +import {requiresAuthenticatedUser, withPageHelpers, Title, NavButton} from '../../lib/page'; +import { + withForm, Form, FormSendMethod, InputField, TextArea, TableSelect, ButtonRow, Button, + Fieldset, Dropdown, AlignedRow, ACEEditor, StaticField +} from '../../lib/form'; +import { withErrorHandling, withAsyncErrorHandler } from '../../lib/error-handling'; +import {DeleteModalDialog} from "../../lib/delete"; +import { getFieldTypes } from './field-types'; +import axios from '../../lib/axios'; +import interoperableErrors from '../../../../shared/interoperable-errors'; +import validators from '../../../../shared/validators'; +import slugify from 'slugify'; +import { parseDate, parseBirthday } from '../../../../shared/fields'; + +@translate() +@withForm +@withPageHelpers +@withErrorHandling +@requiresAuthenticatedUser +export default class CUD extends Component { + constructor(props) { + super(props); + + this.state = {}; + + this.fieldTypes = getFieldTypes(props.t); + + this.initForm({ + serverValidation: { + url: `/rest/fields-validate/${this.props.list.id}`, + changed: ['key'], + extra: ['id'] + }, + onChange: { + name: ::this.onChangeName + } + }); + } + + static propTypes = { + action: PropTypes.string.isRequired, + list: PropTypes.object, + entity: PropTypes.object + } + + onChangeName(state, attr, oldValue, newValue) { + const oldComputedKey = ('MERGE_' + slugify(oldValue, '_')).toUpperCase().replace(/[^A-Z0-9_]/g, ''); + const oldKey = state.formState.getIn(['data', 'key', 'value']); + + if (oldKey === '' || oldKey === oldComputedKey) { + const newKey = ('MERGE_' + slugify(newValue, '_')).toUpperCase().replace(/[^A-Z0-9_]/g, ''); + state.formState = state.formState.setIn(['data', 'key', 'value'], newKey); + } + } + + @withAsyncErrorHandler + async loadOrderOptions() { + const t = this.props.t; + + const flds = await axios.get(`/rest/fields/${this.props.list.id}`); + + const getOrderOptions = fld => { + return [ + {key: 'none', label: t('Not visible')}, + ...flds.data.filter(x => (!this.props.entity || x.id !== this.props.entity.id) && x[fld] !== null && x.type !== 'option').sort((x, y) => x[fld] - y[fld]).map(x => ({ key: x.id.toString(), label: `${x.name} (${this.fieldTypes[x.type].label})`})), + {key: 'end', label: t('End of list')} + ]; + }; + + this.setState({ + orderListOptions: getOrderOptions('order_list'), + orderSubscribeOptions: getOrderOptions('order_subscribe'), + orderManageOptions: getOrderOptions('order_manage') + }); + } + + componentDidMount() { + if (this.props.entity) { + this.getFormValuesFromEntity(this.props.entity, data => { + data.settings = data.settings || {}; + + if (data.default_value === null) { + data.default_value = ''; + } + + if (data.type !== 'option') { + data.group = null; + } + + data.enumOptions = ''; + data.dateFormat = 'eur'; + data.renderTemplate = ''; + + switch (data.type) { + case 'checkbox': + case 'radio-grouped': + case 'dropdown-grouped': + case 'json': + data.renderTemplate = data.settings.renderTemplate; + break; + + case 'radio-enum': + case 'dropdown-enum': + data.enumOptions = this.renderEnumOptions(data.settings.enumOptions); + data.renderTemplate = data.settings.renderTemplate; + break; + + case 'date': + case 'birthday': + data.dateFormat = data.settings.dateFormat; + break; + } + + data.orderListBefore = data.orderListBefore.toString(); + data.orderSubscribeBefore = data.orderSubscribeBefore.toString(); + data.orderManageBefore = data.orderManageBefore.toString(); + }); + + } else { + this.populateFormValues({ + name: '', + type: 'text', + key: '', + default_value: '', + group: null, + renderTemplate: '', + enumOptions: '', + dateFormat: 'eur', + orderListBefore: 'end', // possible values are / 'end' / 'none' + orderSubscribeBefore: 'end', + orderManageBefore: 'end', + orderListOptions: [], + orderSubscribeOptions: [], + orderManageOptions: [] + }); + } + + this.loadOrderOptions(); + } + + localValidateFormValues(state) { + const t = this.props.t; + + if (!state.getIn(['name', 'value'])) { + state.setIn(['name', 'error'], t('Name must not be empty')); + } else { + state.setIn(['name', 'error'], null); + } + + const keyServerValidation = state.getIn(['key', 'serverValidation']); + if (!validators.mergeTagValid(state.getIn(['key', 'value']))) { + state.setIn(['key', 'error'], t('Merge tag is invalid. May must be uppercase and contain only characters A-Z, 0-9, _. It must start with a letter.')); + } else if (!keyServerValidation) { + state.setIn(['key', 'error'], t('Validation is in progress...')); + } else if (keyServerValidation.exists) { + state.setIn(['key', 'error'], t('Another field with the same merge tag exists. Please choose another merge tag.')); + } else { + state.setIn(['key', 'error'], null); + } + + const type = state.getIn(['type', 'value']); + + const group = state.getIn(['group', 'value']); + if (type === 'option' && !group) { + state.setIn(['group', 'error'], t('Group has to be selected')); + } else { + state.setIn(['group', 'error'], null); + } + + const defaultValue = state.getIn(['default_value', 'value']); + if (type === 'number' && !/^[0-9]*$/.test(defaultValue.trim())) { + state.setIn(['default_value', 'error'], t('Default value is not integer number')); + } else if (type === 'date' && !parseDate(state.getIn(['dateFormat', 'value']), defaultValue)) { + state.setIn(['default_value', 'error'], t('Default value is not a properly formatted date')); + } else if (type === 'birthday' && !parseBirthday(state.getIn(['dateFormat', 'value']), defaultValue)) { + state.setIn(['default_value', 'error'], t('Default value is not a properly formatted birthday date')); + } else { + state.setIn(['default_value', 'error'], null); + } + + if (type === 'radio-enum' || type === 'dropdown-enum') { + const enumOptions = this.parseEnumOptions(state.getIn(['enumOptions', 'value'])); + if (enumOptions.errors) { + state.setIn(['enumOptions', 'error'],
{enumOptions.errors.map((err, idx) =>
{err}
)}
); + } else { + state.setIn(['enumOptions', 'error'], null); + + if (defaultValue !== '' && !(defaultValue in enumOptions.options)) { + state.setIn(['default_value', 'error'], t('Default value is not one of the allowed options')); + } + } + } else { + state.setIn(['enumOptions', 'error'], null); + } + } + + parseEnumOptions(text) { + const t = this.props.t; + const errors = []; + const options = {}; + + const lines = text.split('\n'); + for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { + const line = lines[lineIdx].trim(); + + if (line != '') { + const matches = line.match(/^([^|]*)[|](.*)$/); + if (matches) { + const key = matches[1].trim(); + const label = matches[2].trim(); + options[key] = label; + } else { + errors.push(t('Errror on line {{ line }}', { line: lineIdx + 1})); + } + } + } + + if (errors.length) { + return { + errors + }; + } else { + return { + options + }; + } + } + + renderEnumOptions(options) { + return Object.keys(options).map(key => `${key}|${options[key]}`).join('\n'); + } + + + async submitHandler() { + const t = this.props.t; + + let sendMethod, url; + if (this.props.entity) { + sendMethod = FormSendMethod.PUT; + url = `/rest/fields/${this.props.list.id}/${this.props.entity.id}` + } else { + sendMethod = FormSendMethod.POST; + url = `/rest/fields/${this.props.list.id}` + } + + try { + this.disableForm(); + this.setFormStatusMessage('info', t('Saving field ...')); + + const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url, data => { + if (data.default_value.trim() === '') { + data.default_value = null; + } + + if (data.type !== 'option') { + data.group = null; + } + + data.settings = {}; + switch (data.type) { + case 'checkbox': + case 'radio-grouped': + case 'dropdown-grouped': + case 'json': + data.settings.renderTemplate = data.renderTemplate; + break; + + case 'radio-enum': + case 'dropdown-enum': + data.settings.enumOptions = this.parseEnumOptions(data.enumOptions).options; + data.settings.renderTemplate = data.renderTemplate; + break; + + case 'date': + case 'birthday': + data.settings.dateFormat = data.dateFormat; + break; + } + + delete data.renderTemplate; + delete data.enumOptions; + delete data.dateFormat; + + if (data.type === 'option') { + data.orderListBefore = data.orderSubscribeBefore = data.orderManageBefore = 'none'; + } else { + data.orderListBefore = Number.parseInt(data.orderListBefore) || data.orderListBefore; + data.orderSubscribeBefore = Number.parseInt(data.orderSubscribeBefore) || data.orderSubscribeBefore; + data.orderManageBefore = Number.parseInt(data.orderManageBefore) || data.orderManageBefore; + } + }); + + if (submitSuccessful) { + this.navigateToWithFlashMessage(`/lists/${this.props.list.id}/fields`, 'success', t('Field saved')); + } else { + this.enableForm(); + this.setFormStatusMessage('warning', t('There are errors in the form. Please fix them and submit again.')); + } + } catch (error) { + if (error instanceof interoperableErrors.DependencyNotFoundError) { + this.setFormStatusMessage('danger', + + {t('Your updates cannot be saved.')}{' '} + {t('It seems that another field upon which sort field order was established has been deleted in the meantime. Refresh your page to start anew. Please note that your changes will be lost.')} + + ); + return; + } + + throw error; + } + } + + render() { + const t = this.props.t; + const isEdit = !!this.props.entity; + + const typeOptions = Object.keys(this.fieldTypes).map(key => ({key, label: this.fieldTypes[key].label})); + + const type = this.getFormValue('type'); + + let fieldSettings = null; + switch (type) { + case 'text': + case 'website': + case 'longtext': + case 'gpg': + case 'number': + fieldSettings = +
+ +
; + break; + + case 'checkbox': + case 'radio-grouped': + case 'dropdown-grouped': + fieldSettings = +
+ You can control the appearance of the merge tag with this template. The template + uses handlebars syntax and you can find all values from {'{{values}}'} array, for + example {'{{#each values}} {{this}} {{/each}}'}. If template is not defined then + multiple values are joined with commas.} + /> +
; + break; + + case 'radio-enum': + case 'dropdown-enum': + fieldSettings = +
+
Specify the options to select from in the following format:key|label. For example:
+
au|Australia
at|Austria
} + /> + Default key (e.g. au used when the field is empty.')}/> + You can control the appearance of the merge tag with this template. The template + uses handlebars syntax and you can find all values from {'{{values}}'} array. + Each entry in the array is an object with attributes key and label. + For example {'{{#each values}} {{this.value}} {{/each}}'}. If template is not defined then + multiple values are joined with commas.} + /> +
; + break; + + case 'date': + fieldSettings = +
+ + Default value used when the field is empty.}/> +
; + break; + + case 'birthday': + fieldSettings = +
+ + Default value used when the field is empty.}/> +
; + break; + + case 'json': + fieldSettings =
+ Default key (e.g. au used when the field is empty.')}/> + You can use this template to render JSON values (if the JSON is an array then the array is + exposed as values, otherwise you can access the JSON keys directly).} + /> +
; + break; + + case 'option': + const fieldsGroupedColumns = [ + { data: 4, title: "#" }, + { data: 1, title: t('Name') }, + { data: 2, title: t('Type'), render: data => this.fieldTypes[data].label, sortable: false, searchable: false }, + { data: 3, title: t('Merge Tag') } + ]; + + fieldSettings = +
+ + +
; + break; + } + + + return ( +
+ {isEdit && + + } + + {isEdit ? t('Edit Field') : t('Create Field')} + + + + + {isEdit ? + {(this.fieldTypes[this.getFormValue('type')] || {}).label} + : + + } + + + + {fieldSettings} + + {type !== 'option' && +
+ + + +
+ } + + +
+ ); + } +} \ No newline at end of file diff --git a/client/src/lists/subscriptions/List.js b/client/src/lists/subscriptions/List.js new file mode 100644 index 00000000..488974fb --- /dev/null +++ b/client/src/lists/subscriptions/List.js @@ -0,0 +1,122 @@ +'use strict'; + +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { translate } from 'react-i18next'; +import {requiresAuthenticatedUser, withPageHelpers, Title, Toolbar, NavButton} from '../../lib/page'; +import {withAsyncErrorHandler, withErrorHandling} from '../../lib/error-handling'; +import { Table } from '../../lib/table'; +import { SubscriptionStatus } from '../../../../shared/lists'; +import moment from 'moment'; +import { + Dropdown, Form, + withForm +} from '../../lib/form'; +import axios from '../../lib/axios'; + +@translate() +@withForm +@withPageHelpers +@withErrorHandling +@requiresAuthenticatedUser +export default class List extends Component { + constructor(props) { + super(props); + + const t = props.t; + this.state = { + segmentOptions: [ + {key: 'none', label: t('All subscriptions')} + ] + }; + + this.subscriptionStatusLabels = { + [SubscriptionStatus.SUBSCRIBED]: t('Subscribed'), + [SubscriptionStatus.UNSUBSCRIBED]: t('Unubscribed'), + [SubscriptionStatus.BOUNCED]: t('Bounced'), + [SubscriptionStatus.COMPLAINED]: t('Complained'), + } + + this.initForm({ + onChange: { + segment: ::this.onSegmentChange + } + }); + } + + static propTypes = { + list: PropTypes.object + } + + onSegmentChange(state, attr, oldValue, newValue) { + // TODO + + this.subscriptionsTable.refresh(); + } + + @withAsyncErrorHandler + async loadSegmentOptions() { + const t = this.props.t; + + const result = await axios.get(`/rest/segments/${this.props.list.id}`); + + this.setState({ + segmentOptions: [ + {key: 'none', label: t('All subscriptions')}, + ...result.data.map(x => ({ key: x.id.toString(), label: x.name})), + ] + }); + } + + componentDidMount() { + this.populateFormValues({ + segment: 'none' + }); + + this.loadSegmentOptions(); + } + + + render() { + const t = this.props.t; + const list = this.props.list; + + const columns = [ + { data: 2, title: t('Email') }, + { data: 3, title: t('Status'), render: data => this.subscriptionStatusLabels[data] }, + { data: 4, title: t('Created'), render: data => data ? moment(data).fromNow() : '' } + ]; + + if (list.permissions.includes('manageSubscriptions')) { + columns.push({ + actions: data => [{ + label: , + link: `/lists/${this.props.list.id}/subscriptions/${data[0]}/edit` + }] + }); + } + + return ( +
+ + + + + {t('Subscribers')} + + {list.description && +
{list.description}
+ } + +
+
+ + +
+ + +
this.subscriptionsTable = node} withHeader dataUrl={`/rest/subscriptions-table/${list.id}`} columns={columns} /> + + ); + } +} \ No newline at end of file diff --git a/client/src/reports/List.js b/client/src/reports/List.js index e6050675..8882fc9c 100644 --- a/client/src/reports/List.js +++ b/client/src/reports/List.js @@ -68,97 +68,98 @@ export default class List extends Component { render() { const t = this.props.t; - const actions = data => { - const actions = []; - const perms = data[8]; - const permsReportTemplate = data[9]; - - let viewContent, startStop, refreshTimeout; - - const state = data[6]; - const id = data[0]; - const mimeType = data[7]; - - if (state === ReportState.PROCESSING || state === ReportState.SCHEDULED) { - viewContent = { - label: , - }; - - startStop = { - label: , - action: (table) => this.stop(table, id) - }; - - refreshTimeout = 1000; - } else if (state === ReportState.FINISHED) { - if (mimeType === 'text/html') { - viewContent = { - label: , - link: `/reports/${id}/view` - }; - } else if (mimeType === 'text/csv') { - viewContent = { - label: , - href: `/reports/${id}/download` - }; - } - - startStop = { - label: , - action: (table) => this.start(table, id) - }; - - } else if (state === ReportState.FAILED) { - viewContent = { - label: , - }; - - startStop = { - label: , - action: (table) => this.start(table, id) - }; - } - - if (perms.includes('viewContent')) { - actions.push(viewContent); - } - - if (perms.includes('viewOutput')) { - actions.push( - { - label: , - link: `/reports/${id}/output` - } - ); - } - - if (perms.includes('execute') && permsReportTemplate.includes('execute')) { - actions.push(startStop); - } - - if (perms.includes('edit') && permsReportTemplate.includes('execute')) { - actions.push({ - label: , - link: `/reports/${id}/edit` - }); - } - - if (perms.includes('share')) { - actions.push({ - label: , - link: `/reports/${id}/share` - }); - } - - return { refreshTimeout, actions }; - }; - const columns = [ { data: 1, title: t('Name') }, { data: 2, title: t('Template') }, { data: 3, title: t('Description') }, { data: 4, title: t('Created'), render: data => data ? moment(data).fromNow() : '' }, - { data: 5, title: t('Namespace') } + { data: 5, title: t('Namespace') }, + { + actions: data => { + const actions = []; + const perms = data[8]; + const permsReportTemplate = data[9]; + + let viewContent, startStop, refreshTimeout; + + const state = data[6]; + const id = data[0]; + const mimeType = data[7]; + + if (state === ReportState.PROCESSING || state === ReportState.SCHEDULED) { + viewContent = { + label: , + }; + + startStop = { + label: , + action: (table) => this.stop(table, id) + }; + + refreshTimeout = 1000; + } else if (state === ReportState.FINISHED) { + if (mimeType === 'text/html') { + viewContent = { + label: , + link: `/reports/${id}/view` + }; + } else if (mimeType === 'text/csv') { + viewContent = { + label: , + href: `/reports/${id}/download` + }; + } + + startStop = { + label: , + action: (table) => this.start(table, id) + }; + + } else if (state === ReportState.FAILED) { + viewContent = { + label: , + }; + + startStop = { + label: , + action: (table) => this.start(table, id) + }; + } + + if (perms.includes('viewContent')) { + actions.push(viewContent); + } + + if (perms.includes('viewOutput')) { + actions.push( + { + label: , + link: `/reports/${id}/output` + } + ); + } + + if (perms.includes('execute') && permsReportTemplate.includes('execute')) { + actions.push(startStop); + } + + if (perms.includes('edit') && permsReportTemplate.includes('execute')) { + actions.push({ + label: , + link: `/reports/${id}/edit` + }); + } + + if (perms.includes('share')) { + actions.push({ + label: , + link: `/reports/${id}/share` + }); + } + + return { refreshTimeout, actions }; + } + } ]; @@ -175,7 +176,7 @@ export default class List extends Component { {t('Reports')} -
+
); } diff --git a/client/src/reports/root.js b/client/src/reports/root.js index 67b00174..d53901b6 100644 --- a/client/src/reports/root.js +++ b/client/src/reports/root.js @@ -14,6 +14,7 @@ import ReportTemplatesCUD from './templates/CUD'; import ReportTemplatesList from './templates/List'; import Share from '../shares/Share'; import { ReportState } from '../../../shared/reports'; +import mailtrainConfig from 'mailtrainConfig'; const getStructure = t => { @@ -86,7 +87,7 @@ const getStructure = t => { ':action(edit|delete)': { title: t('Edit'), link: params => `/reports/templates/${params.templateId}/edit`, - visible: resolved => resolved.template.permissions.includes('edit'), + visible: resolved => mailtrainConfig.globalPermissions.includes('createJavascriptWithROAccess') && resolved.template.permissions.includes('edit'), render: props => }, share: { diff --git a/client/src/reports/templates/CUD.js b/client/src/reports/templates/CUD.js index a739357e..556997d6 100644 --- a/client/src/reports/templates/CUD.js +++ b/client/src/reports/templates/CUD.js @@ -103,10 +103,10 @@ export default class CUD extends Component { ' }\n' + ']', js: - 'const results = await campaigns.getResults(inputs.campaign, ["custom_country"], query =>\n' + + 'const results = await campaigns.getResults(inputs.campaign, ["merge_country"], query =>\n' + ' query.count("* AS count_all")\n' + ' .select(knex.raw("SUM(IF(tracker.count IS NULL, 0, 1)) AS count_opened"))\n' + - ' .groupBy("custom_country")\n' + + ' .groupBy("merge_country")\n' + ');\n' + '\n' + 'for (const row of results) {\n' + @@ -138,7 +138,7 @@ export default class CUD extends Component { ' {{#each results}}\n' + ' \n' + ' \n' + '
\n' + - ' {{custom_country}}\n' + + ' {{merge_country}}\n' + ' \n' + ' {{count_opened}}\n' + diff --git a/client/src/reports/templates/List.js b/client/src/reports/templates/List.js index 0e72296e..157fc1ec 100644 --- a/client/src/reports/templates/List.js +++ b/client/src/reports/templates/List.js @@ -8,6 +8,7 @@ import { withErrorHandling, withAsyncErrorHandler } from '../../lib/error-handli import { Table } from '../../lib/table'; import axios from '../../lib/axios'; import moment from 'moment'; +import mailtrainConfig from 'mailtrainConfig'; @translate() @withPageHelpers @@ -32,7 +33,7 @@ export default class List extends Component { const result = await axios.post('/rest/permissions-check', request); this.setState({ - createPermitted: result.data.createReportTemplate + createPermitted: result.data.createReportTemplate && mailtrainConfig.globalPermissions.includes('createJavascriptWithROAccess') }); } @@ -43,32 +44,33 @@ export default class List extends Component { render() { const t = this.props.t; - const actions = data => { - const actions = []; - const perms = data[5]; - - if (perms.includes('view')) { - actions.push({ - label: , - link: `/reports/templates/${data[0]}/edit` - }); - } - - if (perms.includes('share')) { - actions.push({ - label: , - link: `/reports/templates/${data[0]}/share` - }); - } - - return actions; - }; - const columns = [ { data: 1, title: t('Name') }, { data: 2, title: t('Description') }, { data: 3, title: t('Created'), render: data => moment(data).fromNow() }, - { data: 4, title: t('Namespace') } + { data: 4, title: t('Namespace') }, + { + actions: data => { + const actions = []; + const perms = data[5]; + + if (mailtrainConfig.globalPermissions.includes('createJavascriptWithROAccess') && perms.includes('edit')) { + actions.push({ + label: , + link: `/reports/templates/${data[0]}/edit` + }); + } + + if (perms.includes('share')) { + actions.push({ + label: , + link: `/reports/templates/${data[0]}/share` + }); + } + + return actions; + } + } ]; return ( @@ -86,7 +88,7 @@ export default class List extends Component { {t('Report Templates')} - +
); } diff --git a/client/src/shares/Share.js b/client/src/shares/Share.js index 7a662d9f..a78647cd 100644 --- a/client/src/shares/Share.js +++ b/client/src/shares/Share.js @@ -98,20 +98,6 @@ export default class Share extends Component { render() { const t = this.props.t; - const actions = data => { - const actions = []; - const autoGenerated = data[4]; - - if (!autoGenerated) { - actions.push({ - label: 'Delete', - action: () => this.deleteShare(data[3]) - }); - } - - return actions; - }; - const sharesColumns = []; sharesColumns.push({ data: 0, title: t('Username') }); if (mailtrainConfig.isAuthMethodLocal) { @@ -119,6 +105,21 @@ export default class Share extends Component { } sharesColumns.push({ data: 2, title: t('Role') }); + sharesColumns.push({ + actions: data => { + const actions = []; + const autoGenerated = data[4]; + + if (!autoGenerated) { + actions.push({ + label: 'Delete', + action: () => this.deleteShare(data[3]) + }); + } + + return actions; + } + }) let usersLabelIndex = 1; const usersColumns = [ @@ -155,7 +156,7 @@ export default class Share extends Component {

{t('Existing Users')}

-
this.sharesTable = node} withHeader dataUrl={`/rest/shares-table-by-entity/${this.props.entityTypeId}/${this.props.entity.id}`} columns={sharesColumns} actions={actions}/> +
this.sharesTable = node} withHeader dataUrl={`/rest/shares-table-by-entity/${this.props.entityTypeId}/${this.props.entity.id}`} columns={sharesColumns} /> ); } diff --git a/client/src/shares/UserShares.js b/client/src/shares/UserShares.js index 53dbcab4..bb1a9e57 100644 --- a/client/src/shares/UserShares.js +++ b/client/src/shares/UserShares.js @@ -43,30 +43,31 @@ export default class UserShares extends Component { render() { const renderSharesTable = (entityTypeId, title) => { - const actions = data => { - const actions = []; - const autoGenerated = data[3]; - const perms = data[4]; - - if (!autoGenerated && perms.includes('share')) { - actions.push({ - label: , - action: () => this.deleteShare(entityTypeId, data[2]) - }); - } - - return actions; - }; - const columns = [ { data: 0, title: t('Name') }, - { data: 1, title: t('Role') } + { data: 1, title: t('Role') }, + { + actions: data => { + const actions = []; + const autoGenerated = data[3]; + const perms = data[4]; + + if (!autoGenerated && perms.includes('share')) { + actions.push({ + label: , + action: () => this.deleteShare(entityTypeId, data[2]) + }); + } + + return actions; + } + } ]; return (

{title}

-
this.sharesTables[entityTypeId] = node} withHeader dataUrl={`/rest/shares-table-by-user/${entityTypeId}/${this.props.user.id}`} columns={columns} actions={actions}/> +
this.sharesTables[entityTypeId] = node} withHeader dataUrl={`/rest/shares-table-by-user/${entityTypeId}/${this.props.user.id}`} columns={columns} /> ); }; diff --git a/client/src/users/List.js b/client/src/users/List.js index 952b3b98..e688666d 100644 --- a/client/src/users/List.js +++ b/client/src/users/List.js @@ -20,19 +20,8 @@ export default class List extends Component { const t = this.props.t; - const actions = data => [ - { - label: , - link: `/users/${data[0]}/edit` - }, - { - label: , - link: `/users/${data[0]}/shares` - } - ]; - const columns = [ - { data: 1, title: "Username" } + { data: 1, title: "Username" }, ]; if (mailtrainConfig.isAuthMethodLocal) { @@ -42,6 +31,19 @@ export default class List extends Component { columns.push({ data: 3, title: "Namespace" }); columns.push({ data: 4, title: "Role" }); + columns.push({ + actions: data => [ + { + label: , + link: `/users/${data[0]}/edit` + }, + { + label: , + link: `/users/${data[0]}/shares` + } + ] + }); + return (
@@ -50,7 +52,7 @@ export default class List extends Component { {t('Users')} -
+
); } diff --git a/config/default.toml b/config/default.toml index abbf9c28..4bd91efd 100644 --- a/config/default.toml +++ b/config/default.toml @@ -196,18 +196,19 @@ browser="phantomjs" name="Master" admin=true description="All permissions" -permissions=["rebuildPermissions"] +permissions=["rebuildPermissions", "createJavascriptWithROAccess"] rootNamespaceRole="master" - [roles.namespace.master] name="Master" description="All permissions" permissions=["view", "edit", "delete", "share", "createNamespace", "createList", "createCustomForm", "createReport", "createReportTemplate", "manageUsers"] [roles.namespace.master.children] -list=["view", "edit", "delete", "share", "manageFields"] +list=["view", "edit", "delete", "share", "manageFields", "viewSubscriptions", "manageSubscriptions", "manageSegments"] customForm=["view", "edit", "delete", "share"] +campaign=["view", "edit", "delete", "share"] +template=["view", "edit", "delete", "share"] report=["view", "edit", "delete", "share", "execute", "viewContent", "viewOutput"] reportTemplate=["view", "edit", "delete", "share", "execute"] namespace=["view", "edit", "delete", "share", "createNamespace", "createList", "createCustomForm", "createReport", "createReportTemplate", "manageUsers"] @@ -215,13 +216,23 @@ namespace=["view", "edit", "delete", "share", "createNamespace", "createList", " [roles.list.master] name="Master" description="All permissions" -permissions=["view", "edit", "delete", "share", "manageFields"] +permissions=["view", "edit", "delete", "share", "manageFields", "viewSubscriptions", "manageSubscriptions", "manageSegments"] [roles.customForm.master] name="Master" description="All permissions" permissions=["view", "edit", "delete", "share"] +[roles.campaign.master] +name="Master" +description="All permissions" +permissions=["view", "edit", "delete", "share"] + +[roles.template.master] +name="Master" +description="All permissions" +permissions=["view", "edit", "delete", "share"] + [roles.report.master] name="Master" description="All permissions" @@ -236,33 +247,51 @@ permissions=["view", "edit", "delete", "share", "execute"] [roles.global.editor] name="Editor" -description="Anything under own namespace except operations related to sending and doing reports" +description="XXX" permissions=[] ownNamespaceRole="editor" - -[roles.reportTemplate.editor] +[roles.namespace.editor] name="Editor" -description="Anything under own namespace except operations related to sending and doing reports" +description="XXX" +permissions=["view", "edit", "delete"] + +[roles.namespace.editor.children] +list=[] +customForm=[] +campaign=[] +template=[] +report=[] +reportTemplate=[] +namespace=["view", "edit", "delete"] + +[roles.list.editor] +name="Editor" +description="XXX" +permissions=[] + +[roles.customForm.editor] +name="Editor" +description="All permissions" +permissions=[] + +[roles.campaign.editor] +name="Editor" +description="XXX" +permissions=[] + +[roles.template.editor] +name="Editor" +description="XXX" permissions=[] [roles.report.editor] name="Editor" -description="Anything under own namespace except operations related to sending and doing reports" -permissions=["view", "viewContent", "viewOutput"] - -[roles.list.editor] -name="Editor" -description="Anything under own namespace except operations related to sending and doing reports" +description="XXX" permissions=[] -[roles.namespace.editor] +[roles.reportTemplate.editor] name="Editor" -description="Anything under own namespace except operations related to sending and doing reports" -permissions=["view", "edit", "delete"] +description="XXX" +permissions=[] -[roles.namespace.editor.children] -reportTemplate=[] -report=["view", "viewContent", "viewOutput"] -list=[] -namespace=["view", "edit", "delete"] diff --git a/lib/client-helpers.js b/lib/client-helpers.js index 496cf65d..f4343e96 100644 --- a/lib/client-helpers.js +++ b/lib/client-helpers.js @@ -4,6 +4,7 @@ const passport = require('./passport'); const config = require('config'); const permissions = require('./permissions'); const forms = require('../models/forms'); +const shares = require('../models/shares'); async function getAnonymousConfig(context) { return { @@ -18,7 +19,8 @@ async function getAnonymousConfig(context) { async function getAuthenticatedConfig(context) { return { defaultCustomFormValues: await forms.getDefaultCustomFormValues(), - userId: context.user.id + userId: context.user.id, + globalPermissions: shares.getGlobalPermissions(context) } } diff --git a/lib/dt-helpers.js b/lib/dt-helpers.js index 26514c86..a39d5dc9 100644 --- a/lib/dt-helpers.js +++ b/lib/dt-helpers.js @@ -3,108 +3,106 @@ const knex = require('../lib/knex'); const permissions = require('../lib/permissions'); -async function ajaxList(params, queryFun, columns, options) { +async function ajaxListTx(tx, params, queryFun, columns, options) { options = options || {}; - return await knex.transaction(async (tx) => { - const columnsNames = []; - const columnsSelect = []; - - for (const col of columns) { - if (typeof col === 'string') { - columnsNames.push(col); - columnsSelect.push(col); - } else { - columnsNames.push(col.name); - - if (col.raw) { - columnsSelect.push(tx.raw(col.raw)); - } else if (col.query) { - columnsSelect.push(function () { return col.query(this); }); - } - } - } - - if (params.operation === 'getBy') { - const query = queryFun(tx); - query.whereIn(columnsNames[parseInt(params.column)], params.values); - query.select(columnsSelect); - - const rows = await query; - const rowsOfArray = rows.map(row => Object.keys(row).map(key => row[key])); - return rowsOfArray; + const columnsNames = []; + const columnsSelect = []; + for (const col of columns) { + if (typeof col === 'string') { + columnsNames.push(col); + columnsSelect.push(col); } else { - const whereFun = function() { - let searchVal = '%' + params.search.value.replace(/\\/g, '\\\\').replace(/([%_])/g, '\\$1') + '%'; - for (let colIdx = 0; colIdx < params.columns.length; colIdx++) { - const col = params.columns[colIdx]; - if (col.searchable) { - this.orWhere(columnsNames[parseInt(col.data)], 'like', searchVal); - } - } + columnsNames.push(col.name); + + if (col.raw) { + columnsSelect.push(tx.raw(col.raw)); + } else if (col.query) { + columnsSelect.push(function () { return col.query(this); }); } - - /* There are a few SQL peculiarities that make this query a bit weird: - - Group by (which is used in getting permissions) don't go well with count(*). Thus we run the actual query - as a sub-query and then count the number of results. - - SQL does not like if it have columns with the same name in the subquery. This happens multiple tables are joined. - To circumvent this, we select only the first column (whatever it is). Since this is not "distinct", it is supposed - to give us the right number of rows anyway. - */ - const recordsTotalQuery = tx.count('* as recordsTotal').from(function () { return queryFun(this).select(columnsSelect[0]).as('records'); }).first(); - const recordsTotal = (await recordsTotalQuery).recordsTotal; - - const recordsFilteredQuery = tx.count('* as recordsFiltered').from(function () { return queryFun(this).select(columnsSelect[0]).where(whereFun).as('records'); }).first(); - const recordsFiltered = (await recordsFilteredQuery).recordsFiltered; - - const query = queryFun(tx); - query.where(whereFun); - - query.offset(parseInt(params.start)); - - const limit = parseInt(params.length); - if (limit >= 0) { - query.limit(limit); - } - - query.select(columnsSelect); - - for (const order of params.order) { - if (options.orderByBuilder) { - options.orderByBuilder(query, columnsNames[params.columns[order.column].data], order.dir); - } else { - query.orderBy(columnsNames[params.columns[order.column].data], order.dir); - } - } - - query.options({rowsAsArray:true}); - - const rows = await query; - const rowsOfArray = rows.map(row => { - const arr = Object.keys(row).map(field => row[field]); - - if (options.mapFun) { - const result = options.mapFun(arr); - return result || arr; - } else { - return arr; - } - }); - - const result = { - draw: params.draw, - recordsTotal, - recordsFiltered, - data: rowsOfArray - }; - - return result; } - }); + } + + if (params.operation === 'getBy') { + const query = queryFun(tx); + query.whereIn(columnsNames[parseInt(params.column)], params.values); + query.select(columnsSelect); + + const rows = await query; + const rowsOfArray = rows.map(row => Object.keys(row).map(key => row[key])); + return rowsOfArray; + + } else { + const whereFun = function() { + let searchVal = '%' + params.search.value.replace(/\\/g, '\\\\').replace(/([%_])/g, '\\$1') + '%'; + for (let colIdx = 0; colIdx < params.columns.length; colIdx++) { + const col = params.columns[colIdx]; + if (col.searchable) { + this.orWhere(columnsNames[parseInt(col.data)], 'like', searchVal); + } + } + } + + /* There are a few SQL peculiarities that make this query a bit weird: + - Group by (which is used in getting permissions) don't go well with count(*). Thus we run the actual query + as a sub-query and then count the number of results. + - SQL does not like if it have columns with the same name in the subquery. This happens multiple tables are joined. + To circumvent this, we select only the first column (whatever it is). Since this is not "distinct", it is supposed + to give us the right number of rows anyway. + */ + const recordsTotalQuery = tx.count('* as recordsTotal').from(function () { return queryFun(this).select(columnsSelect[0]).as('records'); }).first(); + const recordsTotal = (await recordsTotalQuery).recordsTotal; + + const recordsFilteredQuery = tx.count('* as recordsFiltered').from(function () { return queryFun(this).select(columnsSelect[0]).where(whereFun).as('records'); }).first(); + const recordsFiltered = (await recordsFilteredQuery).recordsFiltered; + + const query = queryFun(tx); + query.where(whereFun); + + query.offset(parseInt(params.start)); + + const limit = parseInt(params.length); + if (limit >= 0) { + query.limit(limit); + } + + query.select(columnsSelect); + + for (const order of params.order) { + if (options.orderByBuilder) { + options.orderByBuilder(query, columnsNames[params.columns[order.column].data], order.dir); + } else { + query.orderBy(columnsNames[params.columns[order.column].data], order.dir); + } + } + + query.options({rowsAsArray:true}); + + const rows = await query; + const rowsOfArray = rows.map(row => { + const arr = Object.keys(row).map(field => row[field]); + + if (options.mapFun) { + const result = options.mapFun(arr); + return result || arr; + } else { + return arr; + } + }); + + const result = { + draw: params.draw, + recordsTotal, + recordsFiltered, + data: rowsOfArray + }; + + return result; + } } -async function ajaxListWithPermissions(context, fetchSpecs, params, queryFun, columns, options) { +async function ajaxListWithPermissionsTx(tx, context, fetchSpecs, params, queryFun, columns, options) { options = options || {}; const permCols = []; @@ -121,7 +119,8 @@ async function ajaxListWithPermissions(context, fetchSpecs, params, queryFun, co }); } - return await ajaxList( + return await ajaxListTx( + tx, params, builder => { let query = queryFun(builder); @@ -163,7 +162,21 @@ async function ajaxListWithPermissions(context, fetchSpecs, params, queryFun, co ); } +async function ajaxList(params, queryFun, columns, options) { + return await knex.transaction(async tx => { + return ajaxListTx(tx, params, queryFun, columns, options) + }); +} + +async function ajaxListWithPermissions(context, fetchSpecs, params, queryFun, columns, options) { + return await knex.transaction(async tx => { + return ajaxListWithPermissionsTx(tx, context, fetchSpecs, params, queryFun, columns, options) + }); +} + module.exports = { + ajaxListTx, ajaxList, + ajaxListWithPermissionsTx, ajaxListWithPermissions }; \ No newline at end of file diff --git a/lib/models/subscriptions.js b/lib/models/subscriptions.js index 8192036e..87255529 100644 --- a/lib/models/subscriptions.js +++ b/lib/models/subscriptions.js @@ -10,7 +10,7 @@ let segments = require('./segments'); let _ = require('../translate')._; let tableHelpers = require('../table-helpers'); -const Status = require('../../models/subscriptions').Status; +const Status = require('../../shared/lists').SubscriptionStatus; module.exports.Status = Status; module.exports.list = (listId, start, limit, callback) => { diff --git a/lib/permissions.js b/lib/permissions.js index 7305c2c9..31de941f 100644 --- a/lib/permissions.js +++ b/lib/permissions.js @@ -16,6 +16,16 @@ const entityTypes = { sharesTable: 'shares_custom_form', permissionsTable: 'permissions_custom_form' }, + campaign: { + entitiesTable: 'campaigns', + sharesTable: 'shares_campaign', + permissionsTable: 'permissions_campaign' + }, + template: { + entitiesTable: 'templates', + sharesTable: 'shares_template', + permissionsTable: 'permissions_template' + }, report: { entitiesTable: 'reports', sharesTable: 'shares_report', diff --git a/models/campaigns.js b/models/campaigns.js index 2ba07243..75ba996b 100644 --- a/models/campaigns.js +++ b/models/campaigns.js @@ -3,18 +3,27 @@ const knex = require('../lib/knex'); const dtHelpers = require('../lib/dt-helpers'); const interoperableErrors = require('../shared/interoperable-errors'); +const shares = require('./shares'); async function listDTAjax(params) { - return await dtHelpers.ajaxList(params, builder => builder.from('campaigns'), ['campaigns.id', 'campaigns.name', 'campaigns.description', 'campaigns.status', 'campaigns.created']); + return await dtHelpers.ajaxListWithPermissions( + context, + [{ entityTypeId: 'campaign', requiredOperations: ['view'] }], + params, + builder => builder.from('campaigns') + .innerJoin('namespaces', 'namespaces.id', 'campaigns.namespace'), + ['campaigns.id', 'campaigns.name', 'campaigns.description', 'campaigns.status', 'campaigns.created'] + ); + } -async function getById(id) { - const entity = await knex('campaigns').where('id', id).first(); - if (!entity) { - throw new interoperableErrors.NotFoundError(); - } - - return entity; +async function getById(context, id) { + return await knex.transaction(async tx => { + await shares.enforceEntityPermissionTx(tx, context, 'campaign', 'view'); + const entity = await tx('campaigns').where('id', id).first(); + entity.permissions = await shares.getPermissionsTx(tx, context, 'campaign', id); + return entity; + }); } module.exports = { diff --git a/models/fields.js b/models/fields.js index 6bda49a5..7eb6c187 100644 --- a/models/fields.js +++ b/models/fields.js @@ -7,7 +7,6 @@ const { enforce, filterObject } = require('../lib/helpers'); const dtHelpers = require('../lib/dt-helpers'); const interoperableErrors = require('../shared/interoperable-errors'); const shares = require('./shares'); -const fieldsLegacy = require('../lib/models/fields'); const bluebird = require('bluebird'); const validators = require('../shared/validators'); const shortid = require('shortid'); @@ -85,15 +84,10 @@ function hash(entity) { } async function getById(context, listId, id) { - let entity; - - await knex.transaction(async tx => { + return await knex.transaction(async tx => { await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageFields'); - entity = await tx('custom_fields').where({list: listId, id}).first(); - if (!entity) { - throw new interoperableErrors.NotFoundError(); - } + const entity = await tx('custom_fields').where({list: listId, id}).first(); const orderFields = { order_list: 'orderListBefore', @@ -113,20 +107,20 @@ async function getById(context, listId, id) { entity[orderFields[key]] = 'none'; } } - }); - return entity; + return entity; + }); } async function list(context, listId) { - let rows; - - await knex.transaction(async tx => { + return await knex.transaction(async tx => { await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageFields'); - rows = await tx('custom_fields').where({list: listId}).select(['id', 'name', 'type', 'order_list', 'order_subscribe', 'order_manage']); + return await tx('custom_fields').where({list: listId}).select(['id', 'name', 'type', 'key', 'column', 'order_list', 'order_subscribe', 'order_manage']).orderBy('id', 'asc'); }); +} - return rows; +async function listByOrderListTx(tx, listId, extraColumns = []) { + return await tx('custom_fields').where({list: listId}).whereNotNull('order_list').select(['name', ...extraColumns]).orderBy('order_list', 'asc'); } async function listDTAjax(context, listId, params) { @@ -199,9 +193,8 @@ async function listGroupedDTAjax(context, listId, params) { } async function serverValidate(context, listId, data) { - const result = {}; - - await knex.transaction(async tx => { + return await knex.transaction(async tx => { + const result = {}; await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageFields'); if (data.key) { @@ -219,9 +212,9 @@ async function serverValidate(context, listId, data) { exists: !!existingKey }; } - }); - return result; + return result; + }); } async function _validateAndPreprocess(tx, listId, entity, isCreate) { @@ -304,8 +297,7 @@ async function _sortIn(tx, listId, entityId, orderListBefore, orderSubscribeBefo } async function create(context, listId, entity) { - let id; - await knex.transaction(async tx => { + return await knex.transaction(async tx => { await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageFields'); await _validateAndPreprocess(tx, listId, entity, true); @@ -322,8 +314,7 @@ async function create(context, listId, entity) { filteredEntity.column = columnName; const ids = await tx('custom_fields').insert(filteredEntity); - id = ids[0]; - + const id = ids[0]; await _sortIn(tx, listId, id, entity.orderListBefore, entity.orderSubscribeBefore, entity.orderManageBefore); @@ -335,9 +326,9 @@ async function create(context, listId, entity) { } }); } - }); - return id; + return id; + }); } async function updateWithConsistencyCheck(context, listId, entity) { @@ -392,6 +383,7 @@ module.exports = { hash, getById, list, + listByOrderListTx, listDTAjax, listGroupedDTAjax, create, diff --git a/models/forms.js b/models/forms.js index d80fce5d..e3043df2 100644 --- a/models/forms.js +++ b/models/forms.js @@ -85,14 +85,12 @@ async function _getById(tx, id) { async function getById(context, id) { - let entity; - await knex.transaction(async tx => { + return await knex.transaction(async tx => { shares.enforceEntityPermissionTx(tx, context, 'customForm', id, 'view'); - entity = await _getById(tx, id); - entity.permissions = await shares.getPermissions(tx, context, 'customForm', id); + const entity = await _getById(tx, id); + entity.permissions = await shares.getPermissionsTx(tx, context, 'customForm', id); + return entity; }); - - return entity; } @@ -114,8 +112,7 @@ async function serverValidate(context, data) { async function create(context, entity) { - let id; - await knex.transaction(async tx => { + return await knex.transaction(async tx => { await shares.enforceEntityPermissionTx(tx, context, 'namespace', entity.namespace, 'createCustomForm'); await namespaceHelpers.validateEntity(tx, entity); @@ -124,7 +121,7 @@ async function create(context, entity) { enforce(!Object.keys(checkForMjmlErrors(form)).length, 'Error(s) in form templates'); const ids = await tx('custom_forms').insert(filterObject(entity, formAllowedKeys)); - id = ids[0]; + const id = ids[0]; for (const formKey in form) { await tx('custom_forms_data').insert({ @@ -135,9 +132,8 @@ async function create(context, entity) { } await shares.rebuildPermissions(tx, { entityTypeId: 'customForm', entityId: id }); + return id; }); - - return id; } async function updateWithConsistencyCheck(context, entity) { diff --git a/models/lists.js b/models/lists.js index 8a41eb15..dcc97af7 100644 --- a/models/lists.js +++ b/models/lists.js @@ -8,6 +8,7 @@ const { enforce, filterObject } = require('../lib/helpers'); const interoperableErrors = require('../shared/interoperable-errors'); const shares = require('./shares'); const namespaceHelpers = require('../lib/namespace-helpers'); +const fields = require('./fields'); const UnsubscriptionMode = require('../shared/lists').UnsubscriptionMode; @@ -31,26 +32,17 @@ async function listDTAjax(context, params) { } async function getById(context, id) { - let entity; - - await knex.transaction(async tx => { - + return await knex.transaction(async tx => { shares.enforceEntityPermissionTx(tx, context, 'list', id, 'view'); - - entity = await tx('lists').where('id', id).first(); - if (!entity) { - throw new interoperableErrors.NotFoundError(); - } - - entity.permissions = await shares.getPermissions(tx, context, 'list', id); + const entity = await tx('lists').where('id', id).first(); + entity.permissions = await shares.getPermissionsTx(tx, context, 'list', id); + entity.listFields = await fields.listByOrderListTx(tx, id); + return entity; }); - - return entity; } async function create(context, entity) { - let id; - await knex.transaction(async tx => { + return await knex.transaction(async tx => { await shares.enforceEntityPermissionTx(tx, context, 'namespace', entity.namespace, 'createList'); await namespaceHelpers.validateEntity(tx, entity); @@ -60,14 +52,14 @@ async function create(context, entity) { filteredEntity.cid = shortid.generate(); const ids = await tx('lists').insert(filteredEntity); - id = ids[0]; + const id = ids[0]; await knex.schema.raw('CREATE TABLE `subscription__' + id + '` LIKE subscription'); await shares.rebuildPermissions(tx, { entityTypeId: 'list', entityId: id }); - }); - return id; + return id; + }); } async function updateWithConsistencyCheck(context, entity) { diff --git a/models/namespaces.js b/models/namespaces.js index 6ed92169..d4318ef3 100644 --- a/models/namespaces.js +++ b/models/namespaces.js @@ -108,38 +108,28 @@ function hash(entity) { } async function getById(context, id) { - let entity; - - await knex.transaction(async tx => { - + return await knex.transaction(async tx => { await shares.enforceEntityPermissionTx(tx, context, 'namespace', id, 'view'); - - entity = await tx('namespaces').where('id', id).first(); - if (!entity) { - throw new interoperableErrors.NotFoundError(); - } - - entity.permissions = await shares.getPermissions(tx, context, 'namespace', id); + const entity = await tx('namespaces').where('id', id).first(); + entity.permissions = await shares.getPermissionsTx(tx, context, 'namespace', id); + return entity; }); - - return entity; } async function create(context, entity) { enforce(entity.namespace, 'Parent namespace must be set'); - let id; - await knex.transaction(async tx => { + return await knex.transaction(async tx => { await shares.enforceEntityPermissionTx(tx, context, 'namespace', entity.namespace, 'createNamespace'); const ids = await tx('namespaces').insert(filterObject(entity, allowedKeys)); - id = ids[0]; + const id = ids[0]; // We don't have to rebuild all entity types, because no entity can be a child of the namespace at this moment. await shares.rebuildPermissions(tx, { entityTypeId: 'namespace', entityId: id }); - }); - return id; + return id; + }); } async function updateWithConsistencyCheck(context, entity) { @@ -191,6 +181,8 @@ async function remove(context, id) { throw new interoperableErrors.ChildDetectedError(); } + // FIXME - Remove all contained entities first + await tx('namespaces').where('id', id).del(); }); } diff --git a/models/report-templates.js b/models/report-templates.js index 2483c1e2..5d53ffd2 100644 --- a/models/report-templates.js +++ b/models/report-templates.js @@ -15,21 +15,12 @@ function hash(entity) { } async function getById(context, id) { - let entity; - - await knex.transaction(async tx => { - + return await knex.transaction(async tx => { await shares.enforceEntityPermissionTx(tx, context, 'reportTemplate', id, 'view'); - - entity = await tx('report_templates').where('id', id).first(); - if (!entity) { - throw new interoperableErrors.NotFoundError(); - } - - entity.permissions = await shares.getPermissions(tx, context, 'reportTemplate', id); + const entity = await tx('report_templates').where('id', id).first(); + entity.permissions = await shares.getPermissionsTx(tx, context, 'reportTemplate', id); + return entity; }); - - return entity; } async function listDTAjax(context, params) { @@ -43,22 +34,23 @@ async function listDTAjax(context, params) { } async function create(context, entity) { - let id; - await knex.transaction(async tx => { + return await knex.transaction(async tx => { + await shares.enforceGlobalPermission(context, 'createJavascriptWithROAccess'); await shares.enforceEntityPermissionTx(tx, context, 'namespace', entity.namespace, 'createReportTemplate'); await namespaceHelpers.validateEntity(tx, entity); const ids = await tx('report_templates').insert(filterObject(entity, allowedKeys)); - id = ids[0]; + const id = ids[0]; await shares.rebuildPermissions(tx, { entityTypeId: 'reportTemplate', entityId: id }); - }); - return id; + return id; + }); } async function updateWithConsistencyCheck(context, entity) { await knex.transaction(async tx => { + await shares.enforceGlobalPermission(context, 'createJavascriptWithROAccess'); await shares.enforceEntityPermissionTx(tx, context, 'reportTemplate', entity.id, 'edit'); const existing = await tx('report_templates').where('id', entity.id).first(); @@ -87,14 +79,11 @@ async function remove(context, id) { } async function getUserFieldsById(context, id) { - await shares.enforceEntityPermission(context, 'reportTemplate', id, 'view'); - - const entity = await knex('report_templates').select(['user_fields']).where('id', id).first(); - if (!entity) { - throw new interoperableErrors.NotFoundError(); - } - - return JSON.parse(entity.user_fields); + return await knex.transaction(async tx => { + await shares.enforceEntityPermissionTx(tx, context, 'reportTemplate', id, 'view'); + const entity = await tx('report_templates').select(['user_fields']).where('id', id).first(); + return JSON.parse(entity.user_fields); + }); } module.exports = { diff --git a/models/reports.js b/models/reports.js index dc50e0ca..faf561a1 100644 --- a/models/reports.js +++ b/models/reports.js @@ -19,29 +19,22 @@ function hash(entity) { } async function getByIdWithTemplate(context, id) { - let entity; - - await knex.transaction(async tx => { - + return await knex.transaction(async tx => { await shares.enforceEntityPermissionTx(tx, context, 'report', id, 'view'); - entity = await tx('reports') + const entity = await tx('reports') .where('reports.id', id) .innerJoin('report_templates', 'reports.report_template', 'report_templates.id') .select(['reports.id', 'reports.name', 'reports.description', 'reports.report_template', 'reports.params', 'reports.state', 'reports.namespace', 'report_templates.user_fields', 'report_templates.mime_type', 'report_templates.hbs', 'report_templates.js']) .first(); - if (!entity) { - throw new interoperableErrors.NotFoundError(); - } - entity.user_fields = JSON.parse(entity.user_fields); entity.params = JSON.parse(entity.params); - entity.permissions = await shares.getPermissions(tx, context, 'report', id); - }); + entity.permissions = await shares.getPermissionsTx(tx, context, 'report', id); - return entity; + return entity; + }); } async function listDTAjax(context, params) { @@ -147,21 +140,17 @@ const campaignFieldsMapping = { email: 'subscribers.email' }; -function customFieldName(id) { - return id.replace(/MERGE_/, 'CUSTOM_').toLowerCase(); -} - async function getCampaignResults(context, campaign, select, extra) { - const fieldList = await fields.list(campaign.list); + const fieldList = await fields.list(context, campaign.list); - const fieldsMapping = fieldList.reduce((map, field) => { - /* Dropdowns and checkboxes are aggregated. As such, they have field.column == null and the options are in field.options. + const fieldsMapping = Object.assign({}, campaignFieldsMapping); + for (const field of fieldList) { + /* Dropdowns and checkboxes are aggregated. As such, they have field.column == null TODO - For the time being, we ignore groupped fields. */ if (field.column) { - map[customFieldName(field.key)] = 'subscribers.' + field.column; + fieldsMapping[field.key.toLowerCase()] = 'subscribers.' + field.column; } - return map; - }, Object.assign({}, campaignFieldsMapping)); + } let selFields = []; for (let idx = 0; idx < select.length; idx++) { diff --git a/models/segments.js b/models/segments.js new file mode 100644 index 00000000..6f09c6d9 --- /dev/null +++ b/models/segments.js @@ -0,0 +1,49 @@ +'use strict'; + +const knex = require('../lib/knex'); +const dtHelpers = require('../lib/dt-helpers'); +const interoperableErrors = require('../shared/interoperable-errors'); +const shares = require('./shares'); + +//const allowedKeys = new Set(['cid', 'email']); + +/* +function hash(entity) { + const allowedKeys = allowedKeysBase.slice(); + + // TODO add keys from custom fields + + 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, 'viewSubscriptions'); + + const flds = await fields.listByOrderListTx(tx, listId, ['column']); + + return await dtHelpers.ajaxListTx( + tx, + params, + builder => builder + .from('segments') + .where('list', listId), + ['id', 'name', 'type'] + ); + }); +} + + +async function list(context, listId) { + return await knex.transaction(async tx => { + await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'viewSubscriptions'); + + return await tx('segments').select(['id', 'name']).where('list', listId).orderBy('name', 'asc'); + }); +} + +module.exports = { + listDTAjax, + list +}; \ No newline at end of file diff --git a/models/shares.js b/models/shares.js index 99258ab3..8d8443fb 100644 --- a/models/shares.js +++ b/models/shares.js @@ -10,67 +10,75 @@ const interoperableErrors = require('../shared/interoperable-errors'); async function listByEntityDTAjax(context, entityTypeId, entityId, params) { - const entityType = permissions.getEntityType(entityTypeId); + return await knex.transaction(async (tx) => { + const entityType = permissions.getEntityType(entityTypeId); + await enforceEntityPermissionTx(tx, context, entityTypeId, entityId, 'share'); - await enforceEntityPermission(context, entityTypeId, entityId, 'share'); - - return await dtHelpers.ajaxList( - params, - builder => builder - .from(entityType.sharesTable) - .innerJoin('users', entityType.sharesTable + '.user', 'users.id') - .innerJoin('generated_role_names', 'generated_role_names.role', 'users.role') - .where('generated_role_names.entity_type', entityTypeId) - .where(`${entityType.sharesTable}.entity`, entityId), - [ 'users.username', 'users.name', 'generated_role_names.name', 'users.id', entityType.sharesTable + '.auto' ] - ); + return await dtHelpers.ajaxListTx( + tx, + params, + builder => builder + .from(entityType.sharesTable) + .innerJoin('users', entityType.sharesTable + '.user', 'users.id') + .innerJoin('generated_role_names', 'generated_role_names.role', 'users.role') + .where('generated_role_names.entity_type', entityTypeId) + .where(`${entityType.sharesTable}.entity`, entityId), + ['users.username', 'users.name', 'generated_role_names.name', 'users.id', entityType.sharesTable + '.auto'] + ); + }); } async function listByUserDTAjax(context, entityTypeId, userId, params) { - const user = await knex('users').where('id', userId).first(); - if (!user) { - shares.throwPermissionDenied(); - } + return await knex.transaction(async (tx) => { + const user = await tx('users').where('id', userId).first(); + if (!user) { + shares.throwPermissionDenied(); + } - await enforceEntityPermission(context, 'namespace', user.namespace, 'manageUsers'); + await enforceEntityPermissionTx(tx, context, 'namespace', user.namespace, 'manageUsers'); - const entityType = permissions.getEntityType(entityTypeId); + const entityType = permissions.getEntityType(entityTypeId); - return await dtHelpers.ajaxListWithPermissions( - context, - [{ entityTypeId }], - params, - builder => builder - .from(entityType.sharesTable) - .innerJoin(entityType.entitiesTable, entityType.sharesTable + '.entity', entityType.entitiesTable + '.id') - .innerJoin('generated_role_names', 'generated_role_names.role', entityType.sharesTable + '.role') - .where('generated_role_names.entity_type', entityTypeId) - .where(entityType.sharesTable + '.user', userId), - [ entityType.entitiesTable + '.name', 'generated_role_names.name', entityType.entitiesTable + '.id', entityType.sharesTable + '.auto' ] - ); + return await dtHelpers.ajaxListWithPermissionsTx( + tx, + context, + [{entityTypeId}], + params, + builder => builder + .from(entityType.sharesTable) + .innerJoin(entityType.entitiesTable, entityType.sharesTable + '.entity', entityType.entitiesTable + '.id') + .innerJoin('generated_role_names', 'generated_role_names.role', entityType.sharesTable + '.role') + .where('generated_role_names.entity_type', entityTypeId) + .where(entityType.sharesTable + '.user', userId), + [entityType.entitiesTable + '.name', 'generated_role_names.name', entityType.entitiesTable + '.id', entityType.sharesTable + '.auto'] + ); + }); } async function listUnassignedUsersDTAjax(context, entityTypeId, entityId, params) { - const entityType = permissions.getEntityType(entityTypeId); + return await knex.transaction(async (tx) => { + const entityType = permissions.getEntityType(entityTypeId); - await enforceEntityPermission(context, entityTypeId, entityId, 'share'); + await enforceEntityPermissionTx(tx, context, entityTypeId, entityId, 'share'); - return await dtHelpers.ajaxList( - params, - builder => builder - .from('users') - .whereNotExists(function() { - return this - .select('*') - .from(entityType.sharesTable) - .whereRaw(`users.id = ${entityType.sharesTable}.user`) - .andWhere(`${entityType.sharesTable}.entity`, entityId); - }), - ['users.id', 'users.username', 'users.name'] - ); + return await dtHelpers.ajaxListTx( + tx, + params, + builder => builder + .from('users') + .whereNotExists(function () { + return this + .select('*') + .from(entityType.sharesTable) + .whereRaw(`users.id = ${entityType.sharesTable}.user`) + .andWhere(`${entityType.sharesTable}.entity`, entityId); + }), + ['users.id', 'users.username', 'users.name'] + ); + }); } -async function listRolesDTAjax(context, entityTypeId, params) { +async function listRolesDTAjax(entityTypeId, params) { return await dtHelpers.ajaxList( params, builder => builder @@ -406,9 +414,9 @@ async function removeDefaultShares(tx, user) { } } -function enforceGlobalPermission(context, requiredOperations) { +function checkGlobalPermission(context, requiredOperations) { if (context.user.admin) { // This handles the getAdminContext() case - return; + return true; } if (typeof requiredOperations === 'string') { @@ -416,15 +424,23 @@ function enforceGlobalPermission(context, requiredOperations) { } const roleSpec = config.roles.global[context.user.role]; + let success = false; if (roleSpec) { for (const requiredOperation of requiredOperations) { if (roleSpec.permissions.includes(requiredOperation)) { - return; + success = true; + break; } } } - throwPermissionDenied(); + return success; +} + +function enforceGlobalPermission(context, requiredOperations) { + if (!checkGlobalPermission(context, requiredOperations)) { + throwPermissionDenied(); + } } async function _checkPermissionTx(tx, context, entityTypeId, entityId, requiredOperations) { @@ -465,19 +481,15 @@ async function checkEntityPermission(context, entityTypeId, entityId, requiredOp return false; } - let result; - await knex.transaction(async tx => { - result = await _checkPermissionTx(tx, context, entityTypeId, entityId, requiredOperations); + return await knex.transaction(async tx => { + return await _checkPermissionTx(tx, context, entityTypeId, entityId, requiredOperations); }); - return result; } async function checkTypePermission(context, entityTypeId, requiredOperations) { - let result; - await knex.transaction(async tx => { - result = await _checkPermissionTx(tx, context, entityTypeId, null, requiredOperations); + return await knex.transaction(async tx => { + return await _checkPermissionTx(tx, context, entityTypeId, null, requiredOperations); }); - return result; } async function enforceEntityPermission(context, entityTypeId, entityId, requiredOperations) { @@ -518,7 +530,15 @@ async function enforceTypePermissionTx(tx, context, entityTypeId, requiredOperat } } -async function getPermissions(tx, context, entityTypeId, entityId) { +function getGlobalPermissions(context) { + enforce(!context.user.admin, 'getPermissions is not supposed to be called by assumed admin'); + + return (config.roles.global[context.user.role] || {}).permissions || []; +} + +async function getPermissionsTx(tx, context, entityTypeId, entityId) { + enforce(!context.user.admin, 'getPermissions is not supposed to be called by assumed admin'); + const entityType = permissions.getEntityType(entityTypeId); const rows = await tx(entityType.permissionsTable) @@ -545,7 +565,9 @@ module.exports = { checkEntityPermission, checkTypePermission, enforceGlobalPermission, + checkGlobalPermission, throwPermissionDenied, regenerateRoleNamesTable, - getPermissions + getGlobalPermissions, + getPermissionsTx }; \ No newline at end of file diff --git a/models/subscriptions.js b/models/subscriptions.js index 3e2ab07f..ce056e09 100644 --- a/models/subscriptions.js +++ b/models/subscriptions.js @@ -3,21 +3,46 @@ const knex = require('../lib/knex'); const dtHelpers = require('../lib/dt-helpers'); const interoperableErrors = require('../shared/interoperable-errors'); +const shares = require('./shares'); +const fields = require('./fields'); +const { SubscriptionStatus } = require('../shared/lists'); -const Status = { - SUBSCRIBED: 1, - UNSUBSCRIBED: 2, - BOUNCED: 3, - COMPLAINED: 4, - MAX: 5 -}; +const allowedKeysBase = new Set(['cid', 'email']); -async function list(listId) { - return await knex(`subscription__${listId}`); +function hash(entity) { + const allowedKeys = allowedKeysBase.slice(); + + // TODO add keys from custom fields + + 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, 'viewSubscriptions'); + + const flds = await fields.listByOrderListTx(tx, listId, ['column']); + + return await dtHelpers.ajaxListTx( + tx, + params, + builder => builder.from(`subscription__${listId}`), + ['id', 'cid', 'email', 'status', 'created', ...flds.map(fld => fld.column)] + ); + }); +} + +async function list(context, listId) { + return await knex.transaction(async tx => { + await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'viewSubscriptions'); + + return await tx(`subscription__${listId}`); + }); } module.exports = { - Status, - list + list, + listDTAjax }; \ No newline at end of file diff --git a/models/users.js b/models/users.js index 3a1c8d44..a72d4452 100644 --- a/models/users.js +++ b/models/users.js @@ -37,12 +37,8 @@ function hash(entity) { return hasher.hash(filterObject(entity, hashKeys)); } -async function _getBy(context, key, value, extraColumns) { - const columns = ['id', 'username', 'name', 'email', 'namespace', 'role']; - - if (extraColumns) { - columns.push(...extraColumns); - } +async function _getBy(context, key, value, extraColumns = []) { + const columns = ['id', 'username', 'name', 'email', 'namespace', 'role', ...extraColumns]; const user = await knex('users').select(columns).where(key, value).first(); diff --git a/routes/rest/campaigns.js b/routes/rest/campaigns.js index 5a28927c..7acfef93 100644 --- a/routes/rest/campaigns.js +++ b/routes/rest/campaigns.js @@ -7,7 +7,7 @@ const router = require('../../lib/router-async').create(); router.postAsync('/campaigns-table', passport.loggedIn, async (req, res) => { - return res.json(await campaigns.listDTAjax(req.body)); + return res.json(await campaigns.listDTAjax(req.context, req.body)); }); diff --git a/routes/rest/lists.js b/routes/rest/lists.js index dccb5e16..a8208c88 100644 --- a/routes/rest/lists.js +++ b/routes/rest/lists.js @@ -2,6 +2,8 @@ const passport = require('../../lib/passport'); const lists = require('../../models/lists'); +const subscriptions = require('../../models/subscriptions'); +const segments = require('../../models/segments'); const router = require('../../lib/router-async').create(); @@ -34,5 +36,17 @@ router.deleteAsync('/lists/:listId', passport.loggedIn, passport.csrfProtection, return res.json(); }); +router.postAsync('/subscriptions-table/:listId', passport.loggedIn, async (req, res) => { + return res.json(await subscriptions.listDTAjax(req.context, req.params.listId, req.body)); +}); + +router.getAsync('/segments/:listId', passport.loggedIn, async (req, res) => { + return res.json(await segments.list(req.context, req.params.listId)); +}); + +router.postAsync('/segments-table/:listId', passport.loggedIn, async (req, res) => { + return res.json(await segments.listDTAjax(req.context, req.params.listId, req.body)); +}); + module.exports = router; \ No newline at end of file diff --git a/routes/rest/shares.js b/routes/rest/shares.js index 760031b4..122337f3 100644 --- a/routes/rest/shares.js +++ b/routes/rest/shares.js @@ -20,7 +20,7 @@ router.postAsync('/shares-unassigned-users-table/:entityTypeId/:entityId', passp }); router.postAsync('/shares-roles-table/:entityTypeId', passport.loggedIn, async (req, res) => { - return res.json(await shares.listRolesDTAjax(req.context, req.params.entityTypeId, req.body)); + return res.json(await shares.listRolesDTAjax(req.params.entityTypeId, req.body)); }); router.putAsync('/shares', passport.loggedIn, async (req, res) => { diff --git a/setup/knex/migrations/20170506102634_base.js b/setup/knex/migrations/20170506102634_base.js index a528dbbc..52d39e37 100644 --- a/setup/knex/migrations/20170506102634_base.js +++ b/setup/knex/migrations/20170506102634_base.js @@ -34,37 +34,89 @@ exports.up = (knex, Promise) => (async() => { }); */ - // We should check here if the tables already exist and upgrade them to db_schema_version 28, which is the baseline. - // For now, we just check whether our DB is up-to-date based on the existing SQL migration infrastructure in Mailtrain. + // Original Mailtrain migration is executed before this one. So here we check that the original migration + // ended where it should have and we take it from here. const row = await knex('settings').where({key: 'db_schema_version'}).first('value'); if (!row || Number(row.value) !== 29) { throw new Error('Unsupported DB schema version: ' + row.value); } - // We have to update data types of primary keys and related foreign keys. Mailtrain uses unsigned int(11), while - // Knex uses unsigned int (which is unsigned int(10) ). + // We have to update data types of primary keys and related foreign keys. Mailtrain uses unsigned int(11), while + // Knex uses unsigned int (which is unsigned int(10) ). await knex.schema - .raw('ALTER TABLE `users` MODIFY `id` int unsigned not null auto_increment') + .raw('ALTER TABLE `attachments` MODIFY `id` int unsigned not null auto_increment') + .raw('ALTER TABLE `attachments` MODIFY `campaign` int unsigned not null') + + .raw('ALTER TABLE `campaign` MODIFY `id` int unsigned not null auto_increment') + .raw('ALTER TABLE `campaign` MODIFY `list` int unsigned not null') + .raw('ALTER TABLE `campaign` MODIFY `segment` int unsigned not null') + .raw('ALTER TABLE `campaign` MODIFY `subscription` int unsigned not null') + + .raw('ALTER TABLE `campaign_tracker` MODIFY `list` int unsigned not null') + .raw('ALTER TABLE `campaign_tracker` MODIFY `subscriber` int unsigned not null') + + .raw('ALTER TABLE `campaigns` MODIFY `id` int unsigned not null auto_increment') + .raw('ALTER TABLE `campaigns` MODIFY `parent` int unsigned default null') + .raw('ALTER TABLE `campaigns` MODIFY `list` int unsigned not null') + .raw('ALTER TABLE `campaigns` MODIFY `segment` int unsigned default null') + .raw('ALTER TABLE `campaigns` MODIFY `template` int unsigned not null') + + .raw('ALTER TABLE `confirmations` MODIFY `id` int unsigned not null auto_increment') + .raw('ALTER TABLE `confirmations` MODIFY `list` int unsigned not null') + + .raw('ALTER TABLE `custom_fields` MODIFY `id` int unsigned not null auto_increment') + .raw('ALTER TABLE `custom_fields` MODIFY `list` int unsigned not null') + .raw('ALTER TABLE `custom_fields` MODIFY `group` int unsigned default null') + + .raw('ALTER TABLE `custom_forms` MODIFY `id` int unsigned not null auto_increment') + .raw('ALTER TABLE `custom_forms` MODIFY `list` int unsigned not null') + + .raw('ALTER TABLE `custom_forms_data` MODIFY `id` int unsigned not null auto_increment') + .raw('ALTER TABLE `custom_forms_data` MODIFY `form` int unsigned not null') + + .raw('ALTER TABLE `import_failed` MODIFY `id` int unsigned not null auto_increment') + .raw('ALTER TABLE `import_failed` MODIFY `import` int unsigned not null') + + .raw('ALTER TABLE `importer` MODIFY `id` int unsigned not null auto_increment') + .raw('ALTER TABLE `importer` MODIFY `list` int unsigned not null') + + .raw('ALTER TABLE `links` MODIFY `id` int unsigned not null auto_increment') + .raw('ALTER TABLE `links` MODIFY `campaign` int unsigned not null') + .raw('ALTER TABLE `lists` MODIFY `id` int unsigned not null auto_increment') - .raw('ALTER TABLE `confirmations` MODIFY `list` int unsigned not null') - .raw('ALTER TABLE `custom_fields` MODIFY `list` int unsigned not null') - .raw('ALTER TABLE `importer` MODIFY `list` int unsigned not null') - .raw('ALTER TABLE `segments` MODIFY `list` int unsigned not null') - .raw('ALTER TABLE `triggers` MODIFY `list` int unsigned not null') - .raw('ALTER TABLE `custom_forms` MODIFY `list` int unsigned not null'); -/* - Remaining foreign keys: - ----------------------- + .raw('ALTER TABLE `queued` MODIFY `list` int unsigned not null') + .raw('ALTER TABLE `queued` MODIFY `campaign` int unsigned not null') + .raw('ALTER TABLE `queued` MODIFY `subscriber` int unsigned not null') - links campaign campaigns id - segment_rules segment segments id - import_failed import importer id - rss parent campaigns id - attachments campaign campaigns id - custom_forms_data form custom_forms id - report_template report_template report_templates id -*/ + .raw('ALTER TABLE `reports` MODIFY `id` int unsigned not null auto_increment') + .raw('ALTER TABLE `reports` MODIFY `report_template` int unsigned not null') + + .raw('ALTER TABLE `report_templates` MODIFY `id` int unsigned not null auto_increment') + + .raw('ALTER TABLE `rss` MODIFY `id` int unsigned not null auto_increment') + .raw('ALTER TABLE `rss` MODIFY `parent` int unsigned not null') + .raw('ALTER TABLE `rss` MODIFY `campaign` int unsigned default null') + + .raw('ALTER TABLE `segment_rules` MODIFY `id` int unsigned not null auto_increment') + .raw('ALTER TABLE `segment_rules` MODIFY `segment` int unsigned not null') + + .raw('ALTER TABLE `segments` MODIFY `id` int unsigned not null auto_increment') + .raw('ALTER TABLE `segments` MODIFY `list` int unsigned not null') + + .raw('ALTER TABLE `subscription` MODIFY `id` int unsigned not null auto_increment') + + .raw('ALTER TABLE `templates` MODIFY `id` int unsigned not null auto_increment') + + .raw('ALTER TABLE `trigger` MODIFY `list` int unsigned not null') + .raw('ALTER TABLE `trigger` MODIFY `subscription` int unsigned not null') + + .raw('ALTER TABLE `triggers` MODIFY `id` int unsigned not null auto_increment') + .raw('ALTER TABLE `triggers` MODIFY `list` int unsigned not null') + .raw('ALTER TABLE `triggers` MODIFY `source_campaign` int unsigned default null') + .raw('ALTER TABLE `triggers` MODIFY `dest_campaign` int unsigned default null') + + .raw('ALTER TABLE `users` MODIFY `id` int unsigned not null auto_increment'); })(); exports.down = (knex, Promise) => (async() => { diff --git a/setup/knex/migrations/20170507083345_create_namespaces.js b/setup/knex/migrations/20170507083345_create_namespaces.js index 2f38a196..5452b943 100644 --- a/setup/knex/migrations/20170507083345_create_namespaces.js +++ b/setup/knex/migrations/20170507083345_create_namespaces.js @@ -1,10 +1,10 @@ exports.up = (knex, Promise) => (async() => { - const entityTypesAddNamespace = ['list', 'custom_form', 'report', 'report_template', 'user']; + const entityTypesAddNamespace = ['list', 'custom_form', 'template', 'campaign', 'report', 'report_template', 'user']; await knex.schema.createTable('namespaces', table => { table.increments('id').primary(); table.string('name'); table.text('description'); - table.integer('namespace').unsigned().references('namespaces.id').onDelete('CASCADE'); + table.integer('namespace').unsigned().references('namespaces.id'); }); await knex('namespaces').insert({ @@ -23,7 +23,7 @@ exports.up = (knex, Promise) => (async() => { }); await knex.schema.table(`${entityType}s`, table => { - table.foreign('namespace').references('namespaces.id').onDelete('CASCADE'); + table.foreign('namespace').references('namespaces.id'); }); } })(); diff --git a/setup/knex/migrations/20170507084114_create_permissions.js b/setup/knex/migrations/20170507084114_create_permissions.js index a3f6ef8c..b14ee841 100644 --- a/setup/knex/migrations/20170507084114_create_permissions.js +++ b/setup/knex/migrations/20170507084114_create_permissions.js @@ -1,4 +1,4 @@ -const shareableEntityTypes = ['list', 'custom_form', 'report', 'report_template', 'namespace']; +const shareableEntityTypes = ['list', 'custom_form', 'template', 'campaign', 'report', 'report_template', 'namespace']; exports.up = (knex, Promise) => (async() => { diff --git a/setup/knex/migrations/20170813143004_create_foreign_keys_in_custom_fields.js b/setup/knex/migrations/20170813143004_create_foreign_keys_in_custom_fields.js new file mode 100644 index 00000000..5c3924c6 --- /dev/null +++ b/setup/knex/migrations/20170813143004_create_foreign_keys_in_custom_fields.js @@ -0,0 +1,8 @@ +exports.up = (knex, Promise) => (async() => { + await knex.schema.table('custom_fields', table => { + table.foreign('group').references('custom_fields.id').onDelete('CASCADE'); + }); +})(); + +exports.down = (knex, Promise) => (async() => { +})(); \ No newline at end of file diff --git a/shared/lists.js b/shared/lists.js index ff9c2a14..f472dbed 100644 --- a/shared/lists.js +++ b/shared/lists.js @@ -9,6 +9,15 @@ const UnsubscriptionMode = { MAX: 5 }; +const SubscriptionStatus = { + SUBSCRIBED: 1, + UNSUBSCRIBED: 2, + BOUNCED: 3, + COMPLAINED: 4, + MAX: 5 +} + module.exports = { - UnsubscriptionMode + UnsubscriptionMode, + SubscriptionStatus }; \ No newline at end of file diff --git a/workers/reports/report-processor.js b/workers/reports/report-processor.js index 217b610f..579f643f 100644 --- a/workers/reports/report-processor.js +++ b/workers/reports/report-processor.js @@ -53,11 +53,11 @@ async function main() { const campaignsProxy = { getResults: (campaign, select, extra) => reports.getCampaignResults(context, campaign, select, extra), - getById: campaigns.getById + getById: campaignId => campaigns.getById(context, campaignId) }; const subscriptionsProxy = { - list: subscriptions.list + list: listId => subscriptions.list(context, listId) }; const sandbox = {