diff --git a/app.js b/app.js index dc876977..244854a7 100644 --- a/app.js +++ b/app.js @@ -52,6 +52,7 @@ const reportsRest = require('./routes/rest/reports'); const campaignsRest = require('./routes/rest/campaigns'); const listsRest = require('./routes/rest/lists'); const formsRest = require('./routes/rest/forms'); +const fieldsRest = require('./routes/rest/fields'); const sharesRest = require('./routes/rest/shares'); const namespacesLegacyIntegration = require('./routes/namespaces-legacy-integration'); @@ -274,6 +275,7 @@ app.use('/rest', accountRest); app.use('/rest', campaignsRest); app.use('/rest', listsRest); app.use('/rest', formsRest); +app.use('/rest', fieldsRest); app.use('/rest', sharesRest); if (config.reports && config.reports.enabled === true) { diff --git a/client/src/lib/page.js b/client/src/lib/page.js index 653a3382..fa48a796 100644 --- a/client/src/lib/page.js +++ b/client/src/lib/page.js @@ -23,16 +23,17 @@ class PageContent extends Component { const structure = children[routeKey]; let path = urlPrefix + routeKey; + let pathWithParams = path; if (structure.params) { - path = path + '/' + structure.params.join('/'); + pathWithParams = pathWithParams + '/' + structure.params.join('/'); } if (structure.component || structure.render) { const route = { component: structure.component, render: structure.render, - path: (path === '' ? '/' : path) + path: (pathWithParams === '' ? '/' : pathWithParams) }; routes.push(route); diff --git a/client/src/lists/List.js b/client/src/lists/List.js index 52587a2a..6744bdff 100644 --- a/client/src/lists/List.js +++ b/client/src/lists/List.js @@ -59,6 +59,13 @@ export default class List extends Component { }); } + if (perms.includes('manageFields')) { + actions.push({ + label: , + link: '/lists/fields/' + data[0] + }); + } + return actions; }; diff --git a/client/src/lists/fields/CUD.js b/client/src/lists/fields/CUD.js new file mode 100644 index 00000000..76b42d6d --- /dev/null +++ b/client/src/lists/fields/CUD.js @@ -0,0 +1,224 @@ +'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 +} 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'; + +@translate() +@withForm +@withPageHelpers +@withErrorHandling +@requiresAuthenticatedUser +export default class CUD extends Component { + constructor(props) { + super(props); + + this.state = {}; + + this.state.listId = parseInt(props.match.params.listId); + + if (props.edit) { + this.state.entityId = parseInt(props.match.params.fieldId); + } + + this.fieldTypes = getFieldTypes(props.t); + + this.initForm({ + serverValidation: { + url: `/rest/fields-validate/${this.state.listId}`, + changed: ['key'], + extra: ['id'] + } + }); + } + + static propTypes = { + edit: PropTypes.bool + } + + @withAsyncErrorHandler + async loadFormValues() { + await this.getFormValuesFromURL(`/rest/fields/${this.state.listId}/${this.state.entityId}`, data => { + if (data.default_value === null) { + data.default_value = ''; + } + }); + } + + @withAsyncErrorHandler + async loadOrderOptions() { + const t = this.props.t; + + const flds = await axios.get(`/rest/fields/${this.state.listId}`); + + const getOrderOptions = fld => { + return [ + {key: 'none', label: t('Not visible')}, + ...flds.data.filter(x => x.id !== this.state.entityId && x[fld] !== null).sort((x, y) => x[fld] - y[fld]).map(x => ({ key: x.id, 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.edit) { + this.loadFormValues(); + } else { + this.populateFormValues({ + name: '', + type: 'text', + key: '', + default_value: '', + group: null, + orderListBefore: 'end', // possible values are / 'end' / 'none' + orderSubscribeBefore: 'end', + orderManageBefore: 'end', + orderListOptions: [], + orderSubscribeOptions: [], + orderManageOptions: [] + }); + } + + this.loadOrderOptions(); + } + + localValidateFormValues(state) { + const t = this.props.t; + const edit = this.props.edit; + + 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); + } + } + + async submitHandler() { + const t = this.props.t; + const edit = this.props.edit; + + let sendMethod, url; + if (edit) { + sendMethod = FormSendMethod.PUT; + url = `/rest/fields/${this.state.listId}/${this.state.entityId}` + } else { + sendMethod = FormSendMethod.POST; + url = `/rest/fields/${this.state.listId}` + } + + 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 (submitSuccessful) { + this.navigateToWithFlashMessage(`/rest/fields/${this.state.listId}`, '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 edit = this.props.edit; + +/* + const orderColumns = [ + { data: 1, title: t('Field name'), sortable: false, searchable: false } + ]; +*/ + + const typeOptions = Object.keys(this.fieldTypes).map(key => ({key, label:this.fieldTypes[key].label})); + + const type = this.getFormValue('type'); + + // + + return ( +
+ {edit && + + } + + {edit ? t('Edit Field') : t('Create Field')} + +
+ + + + + + + {/* type && this.fieldTypes[type].renderSettings */} +
+ +
+
+ + + +
+ + +
+ ); + } +} \ No newline at end of file diff --git a/client/src/lists/fields/List.js b/client/src/lists/fields/List.js new file mode 100644 index 00000000..da50988a --- /dev/null +++ b/client/src/lists/fields/List.js @@ -0,0 +1,54 @@ +'use strict'; + +import React, { Component } from 'react'; +import { translate } from 'react-i18next'; +import {requiresAuthenticatedUser, withPageHelpers, Title, Toolbar, NavButton} from '../../lib/page'; +import { withErrorHandling } from '../../lib/error-handling'; +import { Table } from '../../lib/table'; +import { getFieldTypes } from './field-types'; + +@translate() +@withPageHelpers +@withErrorHandling +@requiresAuthenticatedUser +export default class List extends Component { + constructor(props) { + super(props); + + this.state = {}; + + this.state.listId = parseInt(props.match.params.listId); + this.fieldTypes = getFieldTypes(props.t); + } + + componentDidMount() { + } + + render() { + const t = this.props.t; + + const actions = data => [{ + label: , + link: `/lists/fields/edit/${this.state.listId}/${data[0]}` + }]; + + const columns = [ + { 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') } + ]; + + return ( +
+ + + + + {t('Fields')} + + + + ); + } +} \ No newline at end of file diff --git a/client/src/lists/fields/field-types.js b/client/src/lists/fields/field-types.js new file mode 100644 index 00000000..eac9b0b9 --- /dev/null +++ b/client/src/lists/fields/field-types.js @@ -0,0 +1,59 @@ +'use strict'; + +import React from 'react'; +import {Fieldset, InputField} from "../../lib/form"; + +export function getFieldTypes(t) { + + const fieldTypes = { + text: { + label: t('Text'), + renderSettings: +
+ +
+ }, + website: { + label: t('Website') + }, + longtext: { + label: t('Multi-line text') + }, + gpg: { + label: t('GPG Public Key') + }, + number: { + label: t('Number') + }, + checkbox: { + label: t('Checkboxes (from option fields)') + }, + 'radio-grouped': { + label: t('Radio Buttons (from option fields)') + }, + 'dropdown-grouped': { + label: t('Drop Down (from option fields)') + }, + 'radio-enum': { + label: t('Radio Buttons (enumerated)') + }, + 'dropdown-enum': { + label: t('Drop Down (enumerated)') + }, + 'date': { + label: t('Date') + }, + 'birthday': { + label: t('Birthday') + }, + json: { + label: t('JSON value for custom rendering') + }, + option: { + label: t('Option') + } + }; + + return fieldTypes; +} + diff --git a/client/src/lists/root.js b/client/src/lists/root.js index 28e08c0b..c1995c3b 100644 --- a/client/src/lists/root.js +++ b/client/src/lists/root.js @@ -10,6 +10,8 @@ import ListsList from './List'; import ListsCUD from './CUD'; import FormsList from './forms/List'; import FormsCUD from './forms/CUD'; +import FieldsList from './fields/List'; +import FieldsCUD from './fields/CUD'; import Share from '../shares/Share'; @@ -26,6 +28,29 @@ const getStructure = t => { link: '/lists', component: ListsList, children: { +/* FIXME + ':listId': { + title: resolved => t('List "{{name}}"', {name: resolved.list.name}), + resolve: { + list: match => `/rest/lists/${match.params.listId}` + }, + actions: { + edit: { + title: t('Edit'), + params: [':action?'], + render: props => () + }, + create: { + title: t('Create'), + render: props => () + }, + share: { + title: t('Share'), + render: props => () + } + } + }, +*/ edit: { title: t('Edit List'), params: [':id', ':action?'], @@ -40,6 +65,24 @@ const getStructure = t => { params: [':id'], render: props => ( t('Share List "{{name}}"', {name: entity.name})} getUrl={id => `/rest/lists/${id}`} entityTypeId="list" {...props} />) }, + fields: { + title: t('Fields'), + params: [':listId'], + link: match => `/lists/fields/${match.params.listId}`, + component: FieldsList, + children: { + edit: { + title: t('Edit Field'), + params: [':listId', ':fieldId', ':action?'], + render: props => () + }, + create: { + title: t('Create Field'), + params: [':listId'], + render: props => () + }, + } + }, forms: { title: t('Custom Forms'), link: '/lists/forms', diff --git a/config/default.toml b/config/default.toml index 43908b66..abbf9c28 100644 --- a/config/default.toml +++ b/config/default.toml @@ -206,8 +206,8 @@ description="All permissions" permissions=["view", "edit", "delete", "share", "createNamespace", "createList", "createCustomForm", "createReport", "createReportTemplate", "manageUsers"] [roles.namespace.master.children] -list=["view", "edit", "delete"] -customForm=["view", "edit", "delete"] +list=["view", "edit", "delete", "share", "manageFields"] +customForm=["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,12 +215,12 @@ namespace=["view", "edit", "delete", "share", "createNamespace", "createList", " [roles.list.master] name="Master" description="All permissions" -permissions=["view", "edit", "delete"] +permissions=["view", "edit", "delete", "share", "manageFields"] [roles.customForm.master] name="Master" description="All permissions" -permissions=["view", "edit", "delete"] +permissions=["view", "edit", "delete", "share"] [roles.report.master] name="Master" diff --git a/lib/dt-helpers.js b/lib/dt-helpers.js index 801f9953..26514c86 100644 --- a/lib/dt-helpers.js +++ b/lib/dt-helpers.js @@ -3,7 +3,9 @@ const knex = require('../lib/knex'); const permissions = require('../lib/permissions'); -async function ajaxList(params, queryFun, columns, mapFun) { +async function ajaxList(params, queryFun, columns, options) { + options = options || {}; + return await knex.transaction(async (tx) => { const columnsNames = []; const columnsSelect = []; @@ -69,7 +71,11 @@ async function ajaxList(params, queryFun, columns, mapFun) { query.select(columnsSelect); for (const order of params.order) { - query.orderBy(columnsNames[params.columns[order.column].data], order.dir); + 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}); @@ -78,8 +84,8 @@ async function ajaxList(params, queryFun, columns, mapFun) { const rowsOfArray = rows.map(row => { const arr = Object.keys(row).map(field => row[field]); - if (mapFun) { - const result = mapFun(arr); + if (options.mapFun) { + const result = options.mapFun(arr); return result || arr; } else { return arr; @@ -98,7 +104,9 @@ async function ajaxList(params, queryFun, columns, mapFun) { }); } -async function ajaxListWithPermissions(context, fetchSpecs, params, queryFun, columns, map) { +async function ajaxListWithPermissions(context, fetchSpecs, params, queryFun, columns, options) { + options = options || {}; + const permCols = []; for (const fetchSpec of fetchSpecs) { const entityType = permissions.getEntityType(fetchSpec.entityTypeId); @@ -136,10 +144,21 @@ async function ajaxListWithPermissions(context, fetchSpecs, params, queryFun, co ...columns, ...permCols ], - data => { - for (let idx = 0; idx < fetchSpecs.length; idx++) { - data[columns.length + idx] = data[columns.length + idx].split(';'); - } + { + mapFun: data => { + for (let idx = 0; idx < fetchSpecs.length; idx++) { + data[columns.length + idx] = data[columns.length + idx].split(';'); + } + + if (options.mapFun) { + const result = options.mapFun(data); + return result || data; + } else { + return data; + } + }, + + orderByBuilder: options.orderByBuilder } ); } diff --git a/models/fields.js b/models/fields.js index 40d50404..8719d280 100644 --- a/models/fields.js +++ b/models/fields.js @@ -1,11 +1,342 @@ 'use strict'; const knex = require('../lib/knex'); +const hasher = require('node-object-hash')(); +const slugify = require('slugify'); +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 allowedKeysCreate = new Set(['name', 'key', 'default_value', 'type', 'group', 'settings']); +const allowedKeysUpdate = new Set(['name', 'key', 'default_value', 'group', 'settings']); +const hashKeys = allowedKeysCreate; + +const fieldTypes = {}; + +fieldTypes.text = fieldTypes.website = { + validate: entity => {}, + addColumn: (table, name) => table.string(name), + indexed: true, + grouped: false +}; + +fieldTypes.longtext = fieldTypes.gpg = { + validate: entity => {}, + addColumn: (table, name) => table.text(name), + indexed: false, + grouped: false +}; + +fieldTypes.json = { + validate: entity => {}, + addColumn: (table, name) => table.json(name), + indexed: false, + grouped: false +}; + +fieldTypes.number = { + validate: entity => {}, + addColumn: (table, name) => table.integer(name), + indexed: true, + grouped: false +}; + +fieldTypes.checkbox = fieldTypes['radio-grouped'] = fieldTypes['dropdown-grouped'] = { + validate: entity => {}, + indexed: true, + grouped: true +}; + +fieldTypes['radio-enum'] = fieldTypes['dropdown-enum'] = { + validate: entity => { + enforce(entity.settings.options, 'Options missing in settings'); + enforce(Object.keys(entity.settings.options).includes(entity.default_value), 'Default value not present in options'); + }, + addColumn: (table, name) => table.string(name), + indexed: true, + grouped: false +}; + +fieldTypes.option = { + validate: entity => [], + addColumn: (table, name) => table.boolean(name), + indexed: true, + grouped: false +}; + +fieldTypes['date'] = fieldTypes['birthday'] = { + validate: entity => { + enforce(['eur', 'us'].includes(entity.settings.dateFormat), 'Date format incorrect'); + }, + addColumn: (table, name) => table.dateTime(name), + indexed: true, + grouped: false +}; + + +function hash(entity) { + return hasher.hash(filterObject(entity, hashKeys)); +} + +async function getById(context, listId, id) { + let entity; + + 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 orderFields = { + order_list: 'orderListBefore', + order_subscribe: 'orderSubscribeBefore', + order_manage: 'orderManageBefore' + }; + + for (const key in orderFields) { + if (entity[key] !== null) { + const orderIdRow = await tx('custom_fields').where('list', listId).where(key, '>', entity[key]).orderBy(key, 'asc').select(['id']).first(); + if (orderIdRow) { + entity[orderFields[key]] = orderIdRow.id; + } else { + entity[orderFields[key]] = 'end'; + } + } else { + entity[orderFields[key]] = 'none'; + } + } + }); + + return entity; +} + +async function list(context, listId) { + let rows; + + 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 rows; +} + +async function listDTAjax(context, listId, params) { + return await dtHelpers.ajaxListWithPermissions( + context, + [{ entityTypeId: 'list', requiredOperations: ['manageFields'] }], + params, + builder => builder + .from('custom_fields') + .innerJoin('lists', 'custom_fields.list', 'lists.id') + .where('custom_fields.list', listId), + [ 'custom_fields.id', 'custom_fields.name', 'custom_fields.type', 'custom_fields.key', 'custom_fields.order_list' ], + { + orderByBuilder: (builder, orderColumn, orderDir) => { + if (orderColumn === 'custom_fields.order_list') { + builder.orderBy(knex.raw('-custom_fields.order_list'), orderDir === 'asc' ? 'desc' : 'asc'); // This is MySQL speciality. It sorts the rows in ascending order with NULL values coming last + } else { + builder.orderBy(orderColumn, orderDir); + } + } + } + ); +} + +async function serverValidate(context, listId, data) { + const result = {}; + + await knex.transaction(async tx => { + await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageFields'); + + if (data.key) { + const existing = await tx('custom_fields').where({ + list: listId, + key: data.key + }).whereNot('id', data.id).first(); + + result.key = { + exists: !!existing + }; + } + }); + + return result; +} + +async function _validateAndPreprocess(tx, listId, entity, isCreate) { + enforce(entity.type === 'option' || !entity.group, 'Only option may have a group assigned'); + enforce(entity.type !== 'option' || entity.group, 'Option must have a group assigned.'); + enforce(!entity.group || await tx('custom_fields').where({list: listId, id: entity.group}).first(), 'Group field does not exist'); + enforce(entity.name, 'Name must be present'); + + const fieldType = fieldTypes[entity.type]; + enforce(fieldType, 'Unknown field type'); + + const validateErrs = fieldType.validate(entity); + enforce(!validateErrs.length, 'Invalid field'); + + enforce(validators.mergeTagValid(entity.key), 'Merge tag is not valid.'); + + const existingWithKeyQuery = knex('custom_fields').where({ + list: listId, + key: entity.key + }); + if (!isCreate) { + existingWithKeyQuery.whereNot('id', data.id); + } + const existingWithKey = await existingWithKeyQuery.first(); + if (existingWithKey) { + throw new interoperableErrors.DuplicitKeyError(); + } + + entity.settings = JSON.stringify(entity.settings); +} + +async function _sortIn(tx, listId, entityId, orderListBefore, orderSubscribeBefore, orderManageBefore) { + const flds = await tx('custom_fields').where('list', listId).whereNot('id', entityId); + + const order = {}; + for (const row of flds) { + order[row.id] = { + order_list: null, + order_subscribe: null, + order_manage: null + }; + } + + order[entityId] = { + order_list: null, + order_subscribe: null, + order_manage: null + }; + + function computeOrder(fldName, sortInBefore) { + flds.sort((x, y) => x[fldName] - y[fldName]); + const ids = flds.filter(x => x[fldName] !== null).map(x => x.id); + + let sortedIn = false; + let idx = 1; + for (const id of ids) { + if (sortInBefore === id) { + order[entityId][fldName] = idx; + sortedIn = true; + idx += 1; + } + + order[id][fldName] = idx; + idx += 1; + } + + if (!sortedIn && sortInBefore !== 'none') { + order[entityId][fldName] = idx; + } + } + + computeOrder('order_list', orderListBefore); + computeOrder('order_subscribe', orderSubscribeBefore); + computeOrder('order_manage', orderManageBefore); + + for (const id in order) { + await tx('custom_fields').where({list: listId, id}).update(order[id]); + } +} + +async function create(context, listId, entity) { + let id; + await knex.transaction(async tx => { + await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageFields'); + + await _validateAndPreprocess(tx, listId, entity, true); + + let columnName; + if (!fieldType.grouped) { + columnName = ('custom_' + slugify(name, '_') + '_' + shortid.generate()).toLowerCase().replace(/[^a-z0-9_]/g, ''); + } + + const filteredEntity = filterObject(entity, allowedKeysCreate); + filteredEntity.list = listId; + filteredEntity.column = columnName; + + const ids = await tx('custom_fields').insert(filteredEntity); + id = ids[0]; + + _sortIn(tx, listId, id, entity.orderListBefore, entity.orderSubscribeBefore, entity.orderManageBefore); + + if (columnName) { + await knex.schema.table('subscription__' + listId, table => { + fieldType.addColumn(table, columnName); + if (fieldType.indexed) { + table.index(columnName); + } + }); + } + }); + + return id; +} + +async function updateWithConsistencyCheck(context, listId, entity) { + await knex.transaction(async tx => { + await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageFields'); + + const existing = await tx('custom_fields').where({list: listId, id: entity.id}).first(); + if (!existing) { + throw new interoperableErrors.NotFoundError(); + } + + const existingHash = hash(existing); + if (existingHash !== entity.originalHash) { + throw new interoperableErrors.ChangedError(); + } + + enforce(entity.type === existing.type, 'Field type cannot be changed'); + await _validateAndPreprocess(tx, listId, entity, true); + + await tx('custom_fields').where('id', entity.id).update(filterObject(entity, allowedKeysUpdate)); + _sortIn(tx, listId, entity.id, entity.orderListBefore, entity.orderSubscribeBefore, entity.orderManageBefore); + }); +} + +async function remove(context, listId, id) { + await knex.transaction(async tx => { + await shares.enforceEntityPermissionTx(tx, context, 'list', listId, 'manageFields'); + + const existing = await tx('custom_fields').where({list: listId, id: id}).first(); + if (!existing) { + throw new interoperableErrors.NotFoundError(); + } + + const fieldType = fieldTypes[existing.type]; + + await tx('custom_fields').where({list: listId, id}).del(); + + if (fieldType.grouped) { + await tx('custom_fields').where({list: listId, group: id}).del(); + + } else { + await knex.schema.table('subscription__' + listId, table => { + table.dropColumn(existing.column); + }); + + await tx('segemnt_rules').where({column: existing.column}).del(); + } + }); +} module.exports = { - list: bluebird.promisify(fieldsLegacy.list) + hash, + getById, + list, + listDTAjax, + create, + updateWithConsistencyCheck, + remove, + serverValidate }; \ No newline at end of file diff --git a/models/forms.js b/models/forms.js index efdb0c0e..1a8109eb 100644 --- a/models/forms.js +++ b/models/forms.js @@ -85,10 +85,9 @@ async function _getById(tx, id) { async function getById(context, id) { - shares.enforceEntityPermission(context, 'customForm', id, 'view'); - let entity; await knex.transaction(async tx => { + shares.enforceEntityPermissionTx(tx, context, 'customForm', id, 'view'); entity = await _getById(tx, id); }); @@ -114,10 +113,10 @@ async function serverValidate(context, data) { async function create(context, entity) { - await shares.enforceEntityPermission(context, 'namespace', entity.namespace, 'createCustomForm'); - let id; await knex.transaction(async tx => { + await shares.enforceEntityPermissionTx(tx, context, 'namespace', entity.namespace, 'createCustomForm'); + await namespaceHelpers.validateEntity(tx, entity); const form = filterObject(entity, allowedFormKeys); @@ -141,13 +140,13 @@ async function create(context, entity) { } async function updateWithConsistencyCheck(context, entity) { - await shares.enforceEntityPermission(context, 'customForm', entity.id, 'edit'); - await knex.transaction(async tx => { + await shares.enforceEntityPermissionTx(tx, context, 'customForm', entity.id, 'edit'); + const existing = await _getById(tx, entity.id); const existingHash = hash(existing); - if (existingHash != entity.originalHash) { + if (texistingHash !== entity.originalHash) { throw new interoperableErrors.ChangedError(); } @@ -173,9 +172,9 @@ async function updateWithConsistencyCheck(context, entity) { } async function remove(context, id) { - shares.enforceEntityPermission(context, 'customForm', id, 'delete'); - await knex.transaction(async tx => { + shares.enforceEntityPermissionTx(tx, context, 'customForm', id, 'delete'); + const entity = await tx('custom_forms').where('id', id).first(); if (!entity) { diff --git a/models/lists.js b/models/lists.js index e8bde8dd..84d21642 100644 --- a/models/lists.js +++ b/models/lists.js @@ -42,10 +42,10 @@ async function getById(context, id) { } async function create(context, entity) { - await shares.enforceEntityPermission(context, 'namespace', entity.namespace, 'createList'); - let id; await knex.transaction(async tx => { + await shares.enforceEntityPermissionTx(tx, context, 'namespace', entity.namespace, 'createList'); + await namespaceHelpers.validateEntity(tx, entity); enforce(entity.unsubscription_mode >= 0 && entity.unsubscription_mode < UnsubscriptionMode.MAX, 'Unknown unsubscription mode'); @@ -64,16 +64,16 @@ async function create(context, entity) { } async function updateWithConsistencyCheck(context, entity) { - await shares.enforceEntityPermission(context, 'list', entity.id, 'edit'); - await knex.transaction(async tx => { + await shares.enforceEntityPermissionTx(tx, context, 'list', entity.id, 'edit'); + const existing = await tx('lists').where('id', entity.id).first(); if (!existing) { throw new interoperableErrors.NotFoundError(); } const existingHash = hash(existing); - if (existingHash != entity.originalHash) { + if (texistingHash !== entity.originalHash) { throw new interoperableErrors.ChangedError(); } @@ -88,9 +88,9 @@ async function updateWithConsistencyCheck(context, entity) { } async function remove(context, id) { - await shares.enforceEntityPermission(context, 'list', id, 'delete'); - await knex.transaction(async tx => { + await shares.enforceEntityPermissionTx(tx, context, 'list', id, 'delete'); + await tx('lists').where('id', id).del(); await knex.schema.dropTableIfExists('subscription__' + id); }); diff --git a/models/namespaces.js b/models/namespaces.js index f4585afb..ba3b86cd 100644 --- a/models/namespaces.js +++ b/models/namespaces.js @@ -118,13 +118,10 @@ async function getById(context, id) { async function create(context, entity) { enforce(entity.namespace, 'Parent namespace must be set'); - await shares.enforceEntityPermission(context, 'namespace', entity.namespace, 'createNamespace'); let id; await knex.transaction(async tx => { - if (!await tx('namespaces').select(['id']).where('id', entity.namespace).first()) { - throw new interoperableErrors.DependencyNotFoundError(); - } + await shares.enforceEntityPermissionTx(tx, context, 'namespace', entity.namespace, 'createNamespace'); const ids = await tx('namespaces').insert(filterObject(entity, allowedKeys)); id = ids[0]; @@ -138,16 +135,17 @@ async function create(context, entity) { async function updateWithConsistencyCheck(context, entity) { enforce(entity.id !== 1 || entity.namespace === null, 'Cannot assign a parent to the root namespace.'); - await shares.enforceEntityPermission(context, 'namespace', entity.id, 'edit'); await knex.transaction(async tx => { + await shares.enforceEntityPermissionTx(tx, context, 'namespace', entity.id, 'edit'); + const existing = await tx('namespaces').where('id', entity.id).first(); if (!existing) { throw new interoperableErrors.NotFoundError(); } const existingHash = hash(existing); - if (existingHash != entity.originalHash) { + if (texistingHash !== entity.originalHash) { throw new interoperableErrors.ChangedError(); } @@ -175,9 +173,10 @@ async function updateWithConsistencyCheck(context, entity) { async function remove(context, id) { enforce(id !== 1, 'Cannot delete the root namespace.'); - await shares.enforceEntityPermission(context, 'namespace', id, 'delete'); await knex.transaction(async tx => { + await shares.enforceEntityPermissionTx(tx, context, 'namespace', id, 'delete'); + const childNs = await tx('namespaces').where('namespace', id).first(); if (childNs) { throw new interoperableErrors.ChildDetectedError(); diff --git a/models/report-templates.js b/models/report-templates.js index 29362ba2..cbd737d2 100644 --- a/models/report-templates.js +++ b/models/report-templates.js @@ -36,10 +36,9 @@ async function listDTAjax(context, params) { } async function create(context, entity) { - await shares.enforceEntityPermission(context, 'namespace', entity.namespace, 'createReportTemplate'); - let id; await knex.transaction(async tx => { + await shares.enforceEntityPermissionTx(tx, context, 'namespace', entity.namespace, 'createReportTemplate'); await namespaceHelpers.validateEntity(tx, entity); const ids = await tx('report_templates').insert(filterObject(entity, allowedKeys)); @@ -52,16 +51,16 @@ async function create(context, entity) { } async function updateWithConsistencyCheck(context, entity) { - await shares.enforceEntityPermission(context, 'reportTemplate', entity.id, 'edit'); - await knex.transaction(async tx => { + await shares.enforceEntityPermissionTx(tx, context, 'reportTemplate', entity.id, 'edit'); + const existing = await tx('report_templates').where('id', entity.id).first(); if (!existing) { throw new interoperableErrors.NotFoundError(); } const existingHash = hash(existing); - if (existingHash != entity.originalHash) { + if (texistingHash !== entity.originalHash) { throw new interoperableErrors.ChangedError(); } diff --git a/models/reports.js b/models/reports.js index 503e604a..2c47d365 100644 --- a/models/reports.js +++ b/models/reports.js @@ -56,16 +56,12 @@ async function listDTAjax(context, params) { } async function create(context, entity) { - await shares.enforceEntityPermission(context, 'namespace', entity.namespace, 'createReport'); - await shares.enforceEntityPermission(context, 'reportTemplate', entity.report_template, 'execute'); - let id; await knex.transaction(async tx => { - await namespaceHelpers.validateEntity(tx, entity); + await shares.enforceEntityPermissionTx(tx, context, 'namespace', entity.namespace, 'createReport'); + await shares.enforceEntityPermissionTx(tx, context, 'reportTemplate', entity.report_template, 'execute'); - if (!await tx('report_templates').select(['id']).where('id', entity.report_template).first()) { - throw new interoperableErrors.DependencyNotFoundError(); - } + await namespaceHelpers.validateEntity(tx, entity); entity.params = JSON.stringify(entity.params); @@ -81,10 +77,10 @@ async function create(context, entity) { } async function updateWithConsistencyCheck(context, entity) { - await shares.enforceEntityPermission(context, 'report', entity.id, 'edit'); - await shares.enforceEntityPermission(context, 'reportTemplate', entity.report_template, 'execute'); - await knex.transaction(async tx => { + await shares.enforceEntityPermissionTx(tx, context, 'report', entity.id, 'edit'); + await shares.enforceEntityPermissionTx(tx, context, 'reportTemplate', entity.report_template, 'execute'); + const existing = await tx('reports').where('id', entity.id).first(); if (!existing) { throw new interoperableErrors.NotFoundError(); @@ -93,14 +89,10 @@ async function updateWithConsistencyCheck(context, entity) { existing.params = JSON.parse(existing.params); const existingHash = hash(existing); - if (existingHash != entity.originalHash) { + if (texistingHash !== entity.originalHash) { throw new interoperableErrors.ChangedError(); } - if (!await tx('report_templates').select(['id']).where('id', entity.report_template).first()) { - throw new interoperableErrors.DependencyNotFoundError(); - } - await namespaceHelpers.validateEntity(tx, entity); await namespaceHelpers.validateMove(context, entity, existing, 'report', 'createReport', 'delete'); diff --git a/models/shares.js b/models/shares.js index 714ef2fd..ba66d826 100644 --- a/models/shares.js +++ b/models/shares.js @@ -83,9 +83,9 @@ async function listRolesDTAjax(context, entityTypeId, params) { async function assign(context, entityTypeId, entityId, userId, role) { const entityType = permissions.getEntityType(entityTypeId); - await enforceEntityPermission(context, entityTypeId, entityId, 'share'); - await knex.transaction(async tx => { + await enforceEntityPermissionTx(tx, context, entityTypeId, entityId, 'share'); + enforce(await tx('users').where('id', userId).select('id').first(), 'Invalid user id'); enforce(await tx(entityType.entitiesTable).where('id', entityId).select('id').first(), 'Invalid entity id'); @@ -427,28 +427,37 @@ function enforceGlobalPermission(context, requiredOperations) { throwPermissionDenied(); } -async function _checkPermission(context, entityTypeId, entityId, requiredOperations) { - if (context.user.admin) { // This handles the getAdminContext() case - return true; - } - +async function _checkPermissionTx(tx, context, entityTypeId, entityId, requiredOperations) { const entityType = permissions.getEntityType(entityTypeId); - if (typeof requiredOperations === 'string') { - requiredOperations = [ requiredOperations ]; + if (context.user.admin) { // This handles the getAdminContext() case. In this case we don't check the permission, but just the existence. + const existsQuery = tx(entityType.entitiesTable); + + if (entityId) { + existsQuery.where('id', entityId); + } + + const exists = await existsQuery.first(); + + return !!exists; + + } else { + if (typeof requiredOperations === 'string') { + requiredOperations = [ requiredOperations ]; + } + + const permsQuery = tx(entityType.permissionsTable) + .where('user', context.user.id) + .whereIn('operation', requiredOperations); + + if (entityId) { + permsQuery.andWhere('entity', entityId); + } + + const perms = await permsQuery.first(); + + return !!perms; } - - const permsQuery = knex(entityType.permissionsTable) - .where('user', context.user.id) - .whereIn('operation', requiredOperations); - - if (entityId) { - permsQuery.andWhere('entity', entityId); - } - - const perms = await permsQuery.first(); - - return !!perms; } async function checkEntityPermission(context, entityTypeId, entityId, requiredOperations) { @@ -456,23 +465,55 @@ async function checkEntityPermission(context, entityTypeId, entityId, requiredOp return false; } - return await _checkPermission(context, entityTypeId, entityId, requiredOperations); + let result; + await knex.transaction(async tx => { + result = await _checkPermissionTx(tx, context, entityTypeId, entityId, requiredOperations); + }); + return result; } async function checkTypePermission(context, entityTypeId, requiredOperations) { - return await _checkPermission(context, entityTypeId, null, requiredOperations); + let result; + await knex.transaction(async tx => { + result = await _checkPermissionTx(tx, context, entityTypeId, null, requiredOperations); + }); + return result; } async function enforceEntityPermission(context, entityTypeId, entityId, requiredOperations) { - const perms = await checkEntityPermission(context, entityTypeId, entityId, requiredOperations); - if (!perms) { + if (!entityId) { + throwPermissionDenied(); + } + await knex.transaction(async tx => { + const result = await _checkPermissionTx(tx, context, entityTypeId, entityId, requiredOperations); + if (!result) { + throwPermissionDenied(); + } + }); +} + +async function enforceEntityPermissionTx(tx, context, entityTypeId, entityId, requiredOperations) { + if (!entityId) { + throwPermissionDenied(); + } + const result = await _checkPermissionTx(tx, context, entityTypeId, entityId, requiredOperations); + if (!result) { throwPermissionDenied(); } } async function enforceTypePermission(context, entityTypeId, requiredOperations) { - const perms = await checkTypePermission(context, entityTypeId, requiredOperations); - if (!perms) { + await knex.transaction(async tx => { + const result = await _checkPermissionTx(tx, context, entityTypeId, null, requiredOperations); + if (!result) { + throwPermissionDenied(); + } + }); +} + +async function enforceTypePermissionTx(tx, context, entityTypeId, requiredOperations) { + const result = await _checkPermissionTx(tx, context, entityTypeId, null, requiredOperations); + if (!result) { throwPermissionDenied(); } } @@ -487,7 +528,9 @@ module.exports = { rebuildPermissions, removeDefaultShares, enforceEntityPermission, + enforceEntityPermissionTx, enforceTypePermission, + enforceTypePermissionTx, checkEntityPermission, checkTypePermission, enforceGlobalPermission, diff --git a/models/users.js b/models/users.js index fe431e9b..3a1c8d44 100644 --- a/models/users.js +++ b/models/users.js @@ -123,14 +123,14 @@ async function listDTAjax(context, params) { ); } -async function _validateAndPreprocess(tx, user, isCreate, isOwnAccount) { - enforce(await tools.validateEmail(user.email) === 0, 'Invalid email'); +async function _validateAndPreprocess(tx, entity, isCreate, isOwnAccount) { + enforce(await tools.validateEmail(entity.email) === 0, 'Invalid email'); - await namespaceHelpers.validateEntity(tx, user); + await namespaceHelpers.validateEntity(tx, entity); - const otherUserWithSameEmailQuery = tx('users').where('email', user.email); - if (user.id) { - otherUserWithSameEmailQuery.andWhereNot('id', user.id); + const otherUserWithSameEmailQuery = tx('users').where('email', entity.email); + if (entity.id) { + otherUserWithSameEmailQuery.andWhereNot('id', entity.id); } if (await otherUserWithSameEmailQuery.first()) { @@ -139,9 +139,9 @@ async function _validateAndPreprocess(tx, user, isCreate, isOwnAccount) { if (!isOwnAccount) { - const otherUserWithSameUsernameQuery = tx('users').where('username', user.username); - if (user.id) { - otherUserWithSameUsernameQuery.andWhereNot('id', user.id); + const otherUserWithSameUsernameQuery = tx('users').where('username', entity.username); + if (entity.id) { + otherUserWithSameUsernameQuery.andWhereNot('id', entity.id); } if (await otherUserWithSameUsernameQuery.first()) { @@ -149,30 +149,28 @@ async function _validateAndPreprocess(tx, user, isCreate, isOwnAccount) { } } - enforce(user.role in config.roles.global, 'Unknown role'); + enforce(entity.role in config.roles.global, 'Unknown role'); - enforce(!isCreate || user.password.length > 0, 'Password not set'); + enforce(!isCreate || entity.password.length > 0, 'Password not set'); - if (user.password) { - const passwordValidatorResults = passwordValidator.test(user.password); + if (entity.password) { + const passwordValidatorResults = passwordValidator.test(entity.password); if (passwordValidatorResults.errors.length > 0) { // This is not an interoperable error because this is not supposed to happen unless the client is tampered with. throw new Error('Invalid password'); } - user.password = await bcryptHash(user.password, null, null); + entity.password = await bcryptHash(entity.password, null, null); } else { - delete user.password; + delete entity.password; } } async function create(context, user) { - if (context) { // Is also called internally from ldap handling in passport - await shares.enforceEntityPermission(context, 'namespace', user.namespace, 'manageUsers'); - } - let id; await knex.transaction(async tx => { + await shares.enforceEntityPermissionTx(tx, context, 'namespace', user.namespace, 'manageUsers'); + if (passport.isAuthMethodLocal) { await _validateAndPreprocess(tx, user, true); @@ -208,8 +206,8 @@ async function updateWithConsistencyCheck(context, user, isOwnAccount) { } if (!isOwnAccount) { - await shares.enforceEntityPermission(context, 'namespace', user.namespace, 'manageUsers'); - await shares.enforceEntityPermission(context, 'namespace', existing.namespace, 'manageUsers'); + await shares.enforceEntityPermissionTx(tx, context, 'namespace', user.namespace, 'manageUsers'); + await shares.enforceEntityPermissionTx(tx, context, 'namespace', existing.namespace, 'manageUsers'); } if (passport.isAuthMethodLocal) { @@ -251,7 +249,7 @@ async function remove(context, userId) { shares.throwPermissionDenied(); } - await shares.enforceEntityPermission(context, 'namespace', existing.namespace, 'manageUsers'); + await shares.enforceEntityPermissionTx(tx, context, 'namespace', existing.namespace, 'manageUsers'); await tx('users').where('id', userId).del(); }); diff --git a/routes/rest/fields.js b/routes/rest/fields.js new file mode 100644 index 00000000..79b0db01 --- /dev/null +++ b/routes/rest/fields.js @@ -0,0 +1,47 @@ +'use strict'; + +const passport = require('../../lib/passport'); +const fields = require('../../models/fields'); + +const router = require('../../lib/router-async').create(); + + +router.postAsync('/fields-table/:listId', passport.loggedIn, async (req, res) => { + return res.json(await fields.listDTAjax(req.context, req.params.listId, req.body)); +}); + +router.getAsync('/fields/:listId/:fieldId', passport.loggedIn, async (req, res) => { + const entity = await fields.getById(req.context, req.params.listId, req.params.fieldId); + entity.hash = fields.hash(entity); + return res.json(entity); +}); + +router.getAsync('/fields/:listId', passport.loggedIn, async (req, res) => { + const rows = await fields.list(req.context, req.params.listId); + return res.json(rows); +}); + +router.postAsync('/fields/:listId', passport.loggedIn, passport.csrfProtection, async (req, res) => { + await fields.create(req.context, req.params.listId, req.body); + return res.json(); +}); + +router.putAsync('/fields/:listId/:fieldId', passport.loggedIn, passport.csrfProtection, async (req, res) => { + const entity = req.body; + entity.id = parseInt(req.params.fieldId); + + await fields.updateWithConsistencyCheck(req.context, req.params.listId, entity); + return res.json(); +}); + +router.deleteAsync('/fields/:listId/:fieldId', passport.loggedIn, passport.csrfProtection, async (req, res) => { + await fields.remove(req.context, req.params.listId, req.params.fieldId); + return res.json(); +}); + +router.postAsync('/fields-validate/:listId', passport.loggedIn, async (req, res) => { + return res.json(await fields.serverValidate(req.context, req.params.listId, req.body)); +}); + + +module.exports = router; \ No newline at end of file diff --git a/setup/knex/migrations/20170506102634_base.js b/setup/knex/migrations/20170506102634_base.js index b6b31ab7..a528dbbc 100644 --- a/setup/knex/migrations/20170506102634_base.js +++ b/setup/knex/migrations/20170506102634_base.js @@ -1,4 +1,4 @@ -exports.up = function(knex, Promise) { +exports.up = (knex, Promise) => (async() => { /* This is shows what it would look like when we specify the "users" table with Knex. In some sense, this is probably the most complicated table we have in Mailtrain. @@ -36,25 +36,22 @@ exports.up = function(knex, Promise) { // 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. - return knex('settings').where({key: 'db_schema_version'}).first('value') - .then(row => { - if (!row || Number(row.value) !== 29) { - throw new Error('Unsupported DB schema version: ' + row.value); - } - }) + 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) ). - .then(() => knex.schema - .raw('ALTER TABLE `users` MODIFY `id` int unsigned not null auto_increment') - .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') - ) + await knex.schema + .raw('ALTER TABLE `users` MODIFY `id` int unsigned not null auto_increment') + .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: @@ -68,8 +65,8 @@ exports.up = function(knex, Promise) { custom_forms_data form custom_forms id report_template report_template report_templates id */ -}; +})(); -exports.down = function(knex, Promise) { +exports.down = (knex, Promise) => (async() => { // return knex.schema.dropTable('users'); -}; \ No newline at end of file +})(); \ No newline at end of file diff --git a/setup/knex/migrations/20170507083345_create_namespaces.js b/setup/knex/migrations/20170507083345_create_namespaces.js index c6c43195..2f38a196 100644 --- a/setup/knex/migrations/20170507083345_create_namespaces.js +++ b/setup/knex/migrations/20170507083345_create_namespaces.js @@ -1,33 +1,33 @@ -exports.up = function(knex, Promise) { +exports.up = (knex, Promise) => (async() => { const entityTypesAddNamespace = ['list', 'custom_form', 'report', 'report_template', 'user']; - let promise = knex.schema.createTable('namespaces', table => { - table.increments('id').primary(); - table.string('name'); - table.text('description'); - table.integer('namespace').unsigned().references('namespaces.id').onDelete('CASCADE'); - }) - .then(() => knex('namespaces').insert({ - id: 1, /* Global namespace id */ - name: 'Root', - description: 'Root namespace' - })); + 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'); + }); + + await knex('namespaces').insert({ + id: 1, /* Global namespace id */ + name: 'Root', + description: 'Root namespace' + }); for (const entityType of entityTypesAddNamespace) { - promise = promise - .then(() => knex.schema.table(`${entityType}s`, table => { - table.integer('namespace').unsigned().notNullable(); - })) - .then(() => knex(`${entityType}s`).update({ - namespace: 1 /* Global namespace id */ - })) - .then(() => knex.schema.table(`${entityType}s`, table => { - table.foreign('namespace').references('namespaces.id').onDelete('CASCADE'); - })); + await knex.schema.table(`${entityType}s`, table => { + table.integer('namespace').unsigned().notNullable(); + }); + + await knex(`${entityType}s`).update({ + namespace: 1 /* Global namespace id */ + }); + + await knex.schema.table(`${entityType}s`, table => { + table.foreign('namespace').references('namespaces.id').onDelete('CASCADE'); + }); } +})(); - return promise; -}; - -exports.down = function(knex, Promise) { - return knex.schema.dropTable('namespaces'); -}; \ No newline at end of file +exports.down = (knex, Promise) => (async() => { + await knex.schema.dropTable('namespaces'); +})(); \ No newline at end of file diff --git a/setup/knex/migrations/20170507084114_create_permissions.js b/setup/knex/migrations/20170507084114_create_permissions.js index de91a9e7..a3f6ef8c 100644 --- a/setup/knex/migrations/20170507084114_create_permissions.js +++ b/setup/knex/migrations/20170507084114_create_permissions.js @@ -1,10 +1,9 @@ const shareableEntityTypes = ['list', 'custom_form', 'report', 'report_template', 'namespace']; -exports.up = function(knex, Promise) { - let schema = knex.schema; +exports.up = (knex, Promise) => (async() => { for (const entityType of shareableEntityTypes) { - schema = schema + await knex.schema .createTable(`shares_${entityType}`, table => { table.integer('entity').unsigned().notNullable().references(`${entityType}s.id`).onDelete('CASCADE'); table.integer('user').unsigned().notNullable().references('users.id').onDelete('CASCADE'); @@ -21,7 +20,7 @@ exports.up = function(knex, Promise) { } /* The global share for admin is set automatically in rebuildPermissions, which is called upon every start */ - schema = schema + await knex.schema .createTable('generated_role_names', table => { table.string('entity_type', 32).notNullable(); table.string('role', 128).notNullable(); @@ -30,18 +29,12 @@ exports.up = function(knex, Promise) { table.primary(['entity_type', 'role']); }); /* The generate_role_names table is repopulated in regenerateRoleNamesTable, which is called upon every start */ +})(); - return schema; -}; - -exports.down = function(knex, Promise) { - let schema = knex.schema; - +exports.down = (knex, Promise) => (async() => { for (const entityType of shareableEntityTypes) { - schema = schema + await knex.schema .dropTable(`shares_${entityType}`) .dropTable(`permissions_${entityType}`); } - - return schema; -}; \ No newline at end of file +})(); diff --git a/setup/knex/migrations/20170617123450_create_user_name.js b/setup/knex/migrations/20170617123450_create_user_name.js index f3bcb8e8..86b8b10f 100644 --- a/setup/knex/migrations/20170617123450_create_user_name.js +++ b/setup/knex/migrations/20170617123450_create_user_name.js @@ -1,13 +1,14 @@ -exports.up = function(knex, Promise) { - return knex.schema.table('users', table => { +exports.up = (knex, Promise) => (async() => { + await knex.schema.table('users', table => { // name and password can be null in case of LDAP login table.string('name'); table.string('password').alter(); - }) - .then(() => knex('users').where('id', 1 /* Admin user id */).update({ - name: 'Administrator' - })); -}; + }); -exports.down = function(knex, Promise) { -}; \ No newline at end of file + await knex('users').where('id', 1 /* Admin user id */).update({ + name: 'Administrator' + }); +})(); + +exports.down = (knex, Promise) => (async() => { +})(); \ No newline at end of file diff --git a/setup/knex/migrations/20170726155118_create_user_role.js b/setup/knex/migrations/20170726155118_create_user_role.js index ecdb33ba..0c5a3569 100644 --- a/setup/knex/migrations/20170726155118_create_user_role.js +++ b/setup/knex/migrations/20170726155118_create_user_role.js @@ -1,9 +1,9 @@ -exports.up = function(knex, Promise) { - return knex.schema.table('users', table => { +exports.up = (knex, Promise) => (async() => { + await knex.schema.table('users', table => { table.string('role'); }); /* The user role is set automatically in rebuild permissions, which is called upon every start */ -}; +})(); -exports.down = function(knex, Promise) { -}; \ No newline at end of file +exports.down = (knex, Promise) => (async() => { +})(); \ No newline at end of file diff --git a/setup/knex/migrations/20170728220422_drop_id_in_custom_forms_data.js b/setup/knex/migrations/20170728220422_drop_id_in_custom_forms_data.js index 9b20f2b3..1d075bdb 100644 --- a/setup/knex/migrations/20170728220422_drop_id_in_custom_forms_data.js +++ b/setup/knex/migrations/20170728220422_drop_id_in_custom_forms_data.js @@ -1,10 +1,10 @@ -exports.up = function(knex, Promise) { - return knex.schema.table('custom_forms_data', table => { +exports.up = (knex, Promise) => (async() => { + await knex.schema.table('custom_forms_data', table => { table.dropColumn('id'); table.string('data_key', 128).alter(); table.primary(['form', 'data_key']); }) -}; +})(); -exports.down = function(knex, Promise) { -}; \ No newline at end of file +exports.down = (knex, Promise) => (async() => { +})(); \ No newline at end of file diff --git a/setup/knex/migrations/20170729160135_make_custom_forms_independent_of_list.js b/setup/knex/migrations/20170729160135_make_custom_forms_independent_of_list.js index dca90e04..b9d4fe1d 100644 --- a/setup/knex/migrations/20170729160135_make_custom_forms_independent_of_list.js +++ b/setup/knex/migrations/20170729160135_make_custom_forms_independent_of_list.js @@ -1,9 +1,9 @@ -exports.up = function(knex, Promise) { - return knex.schema.table('custom_forms', table => { +exports.up = (knex, Promise) => (async() => { + await knex.schema.table('custom_forms', table => { table.dropForeign('list', 'custom_forms_ibfk_1'); table.dropColumn('list'); }) -}; +})(); -exports.down = function(knex, Promise) { -}; \ No newline at end of file +exports.down = (knex, Promise) => (async() => { +})(); \ No newline at end of file diff --git a/setup/knex/migrations/20170729160422_move_form_field_order_to_custom_fields_and_make_all_fields_configurable.js b/setup/knex/migrations/20170729160422_move_form_field_order_to_custom_fields_and_make_all_fields_configurable.js index 224b0100..d2de8813 100644 --- a/setup/knex/migrations/20170729160422_move_form_field_order_to_custom_fields_and_make_all_fields_configurable.js +++ b/setup/knex/migrations/20170729160422_move_form_field_order_to_custom_fields_and_make_all_fields_configurable.js @@ -25,7 +25,7 @@ exports.up = (knex, Promise) => (async() => { key: 'FIRST_NAME', type: 'text', column: 'first_name', - visible: 1 // FIXME - Revise the need for this field + visible: 1 }); const [lastNameFieldId] = await knex('custom_fields').insert({ @@ -34,7 +34,7 @@ exports.up = (knex, Promise) => (async() => { key: 'LAST_NAME', type: 'text', column: 'last_name', - visible: 1 // FIXME - Revise the need for this field + visible: 1 }); let orderSubscribe; @@ -66,6 +66,11 @@ exports.up = (knex, Promise) => (async() => { } const orderList = [firstNameFieldId, lastNameFieldId]; + for (const fld of fields) { + if (fld.visible && fld.type === 'text') { + orderList.push(fld.id); + } + } let idx = 0; for (const fldId of orderSubscribe) { @@ -90,8 +95,12 @@ exports.up = (knex, Promise) => (async() => { table.dropColumn('fields_shown_on_subscribe'); table.dropColumn('fields_shown_on_manage'); }); + + await knex.schema.table('custom_fields', table => { + table.dropColumn('visible'); + }); })(); -exports.down = function(knex, Promise) { -}; \ No newline at end of file +exports.down = (knex, Promise) => (async() => { +})(); \ No newline at end of file diff --git a/setup/knex/migrations/20170731072050_change_field_group_template_to_settings_and_simplify_types.js b/setup/knex/migrations/20170731072050_change_field_group_template_to_settings_and_simplify_types.js new file mode 100644 index 00000000..a599a15b --- /dev/null +++ b/setup/knex/migrations/20170731072050_change_field_group_template_to_settings_and_simplify_types.js @@ -0,0 +1,57 @@ +"use strict"; + +exports.up = (knex, Promise) => (async() => { + await knex.schema.table('custom_fields', table => { + table.json('settings'); + }); + + const fields = await knex('custom_fields'); + + for (const field of fields) { + const settings = {}; + let type = field.type; + + if (type === 'json') { + settings.groupTemplate = field.group_template; + } + + if (type === 'checkbox') { + settings.groupTemplate = field.group_template; + } + + if (['dropdown', 'radio'].includes(type)) { + settings.groupTemplate = field.group_template; + type = type + '-grouped'; + } + + if (type === 'date-eur') { + type = 'date'; + settings.dateFormat = 'eur'; + } + + if (type === 'date-us') { + type = 'date'; + settings.dateFormat = 'us'; + } + + if (type === 'birthday-eur') { + type = 'birthday'; + settings.dateFormat = 'eur'; + } + + if (type === 'birthday-us') { + type = 'birthday'; + settings.dateFormat = 'us'; + } + + await knex('custom_fields').where('id', field.id).update({type, settings: JSON.stringify(settings)}); + } + + await knex.schema.table('custom_fields', table => { + table.dropColumn('group_template'); + }); +})(); + + +exports.down = (knex, Promise) => (async() => { +})(); \ No newline at end of file diff --git a/shared/interoperable-errors.js b/shared/interoperable-errors.js index cb42ddb0..3c867634 100644 --- a/shared/interoperable-errors.js +++ b/shared/interoperable-errors.js @@ -50,6 +50,12 @@ class DuplicitEmailError extends InteroperableError { } } +class DuplicitKeyError extends InteroperableError { + constructor(msg, data) { + super('DuplicitKeyError', msg, data); + } +} + class IncorrectPasswordError extends InteroperableError { constructor(msg, data) { super('IncorrectPasswordError', msg, data); @@ -84,6 +90,7 @@ const errorTypes = { ChildDetectedError, DuplicitNameError, DuplicitEmailError, + DuplicitKeyError, IncorrectPasswordError, InvalidTokenError, DependencyNotFoundError, diff --git a/shared/validators.js b/shared/validators.js new file mode 100644 index 00000000..0fcfbcf4 --- /dev/null +++ b/shared/validators.js @@ -0,0 +1,9 @@ +'use strict'; + +function mergeTagValid(mergeTag) { + return /^[A-Z][A-Z0-9_]*$/.test(mergeTag); +} + +module.exports = { + mergeTagValid +}; \ No newline at end of file