CUD operations on reports and report templates seem to work

Execution of reports is TBD
This commit is contained in:
Tomas Bures 2017-07-11 11:28:44 +02:00
parent 38cf3e49c0
commit 6d95fa515e
18 changed files with 273 additions and 46 deletions

24
app.js
View file

@ -40,11 +40,13 @@ const editorapi = require('./routes/editorapi');
const grapejs = require('./routes/grapejs'); const grapejs = require('./routes/grapejs');
const mosaico = require('./routes/mosaico'); const mosaico = require('./routes/mosaico');
const namespaces = require('./routes/rest/namespaces'); const namespacesRest = require('./routes/rest/namespaces');
const users = require('./routes/rest/users'); const usersRest = require('./routes/rest/users');
const account = require('./routes/rest/account'); const accountRest = require('./routes/rest/account');
const reportTemplates = require('./routes/rest/report-templates'); const reportTemplatesRest = require('./routes/rest/report-templates');
const reports = require('./routes/rest/reports'); const reportsRest = require('./routes/rest/reports');
const campaignsRest = require('./routes/rest/campaigns');
const listsRest = require('./routes/rest/lists');
const namespacesLegacyIntegration = require('./routes/namespaces-legacy-integration'); const namespacesLegacyIntegration = require('./routes/namespaces-legacy-integration');
const usersLegacyIntegration = require('./routes/users-legacy-integration'); const usersLegacyIntegration = require('./routes/users-legacy-integration');
@ -257,13 +259,15 @@ app.all('/rest/*', (req, res, next) => {
next(); next();
}); });
app.use('/rest', namespaces); app.use('/rest', namespacesRest);
app.use('/rest', users); app.use('/rest', usersRest);
app.use('/rest', account); app.use('/rest', accountRest);
app.use('/rest', campaignsRest);
app.use('/rest', listsRest);
if (config.reports && config.reports.enabled === true) { if (config.reports && config.reports.enabled === true) {
app.use('/rest', reportTemplates); app.use('/rest', reportTemplatesRest);
app.use('/rest', reports); app.use('/rest', reportsRest);
} }
// catch 404 and forward to error handler // catch 404 and forward to error handler

View file

@ -402,6 +402,7 @@ class TableSelect extends Component {
columns: PropTypes.array, columns: PropTypes.array,
selectionKeyIndex: PropTypes.number, selectionKeyIndex: PropTypes.number,
selectionLabelIndex: PropTypes.number, selectionLabelIndex: PropTypes.number,
selectionAsArray: PropTypes.bool,
selectMode: PropTypes.number, selectMode: PropTypes.number,
withHeader: PropTypes.bool, withHeader: PropTypes.bool,
dropdown: PropTypes.bool, dropdown: PropTypes.bool,
@ -432,9 +433,19 @@ class TableSelect extends Component {
} }
async onSelectionDataAsync(sel, data) { async onSelectionDataAsync(sel, data) {
if (this.props.selectMode === TableSelectMode.SINGLE && this.props.dropdown) { if (this.props.dropdown) {
let label;
if (!data) {
label = '';
} else if (this.props.selectMode === TableSelectMode.SINGLE && !this.props.selectionAsArray) {
label = data[this.props.selectionLabelIndex];
} else {
label = data.map(entry => entry[this.props.selectionLabelIndex]).join('; ');
}
this.setState({ this.setState({
selectedLabel: data ? data[this.props.selectionLabelIndex] : '' selectedLabel: label
}); });
} }
} }
@ -462,7 +473,7 @@ class TableSelect extends Component {
</span> </span>
</div> </div>
<div className={'mt-tableselect-table' + (this.state.open ? '' : ' mt-tableselect-table-hidden')}> <div className={'mt-tableselect-table' + (this.state.open ? '' : ' mt-tableselect-table-hidden')}>
<Table dataUrl={props.dataUrl} columns={props.columns} selectMode={props.selectMode} withHeader={props.withHeader} selection={owner.getFormValue(id)} onSelectionDataAsync={::this.onSelectionDataAsync} onSelectionChangedAsync={::this.onSelectionChangedAsync}/> <Table dataUrl={props.dataUrl} columns={props.columns} selectMode={props.selectMode} selectionAsArray={this.props.selectionAsArray} withHeader={props.withHeader} selection={owner.getFormValue(id)} onSelectionDataAsync={::this.onSelectionDataAsync} onSelectionChangedAsync={::this.onSelectionChangedAsync}/>
</div> </div>
</div> </div>
); );
@ -470,7 +481,7 @@ class TableSelect extends Component {
return wrapInput(id, htmlId, owner, props.label, props.help, return wrapInput(id, htmlId, owner, props.label, props.help,
<div> <div>
<div> <div>
<Table dataUrl={props.dataUrl} columns={props.columns} selectMode={props.selectMode} withHeader={props.withHeader} selection={owner.getFormValue(id)} onSelectionChangedAsync={::this.onSelectionChangedAsync}/> <Table dataUrl={props.dataUrl} columns={props.columns} selectMode={props.selectMode} selectionAsArray={this.props.selectionAsArray} withHeader={props.withHeader} selection={owner.getFormValue(id)} onSelectionChangedAsync={::this.onSelectionChangedAsync}/>
</div> </div>
</div> </div>
); );
@ -706,12 +717,24 @@ function withForm(target) {
}; };
inst.updateFormValue = function(key, value) { inst.updateFormValue = function(key, value) {
this.setState(previousState => ({ this.setState(previousState => {
const oldValue = previousState.formState.getIn(['data', key, 'value']);
let newState = {
formState: previousState.formState.withMutations(mutState => { formState: previousState.formState.withMutations(mutState => {
mutState.setIn(['data', key, 'value'], value); mutState.setIn(['data', key, 'value'], value);
validateFormState(this, mutState); validateFormState(this, mutState);
}) })
})); };
const onChangeCallbacks = this.state.formSettings.onChange || {};
if (onChangeCallbacks[key]) {
onChangeCallbacks[key](newState, key, oldValue, value);
}
return newState;
});
}; };
inst.getFormValue = function(name) { inst.getFormValue = function(name) {

View file

@ -205,8 +205,10 @@ class SectionContent extends Component {
/* FIXME, once we turn Mailtrain to single-page application, this should become navigateTo */ /* FIXME, once we turn Mailtrain to single-page application, this should become navigateTo */
window.location = '/account/login?next=' + encodeURIComponent(this.props.root); window.location = '/account/login?next=' + encodeURIComponent(this.props.root);
} else if (error.response && error.response.data && error.response.data.message) { } else if (error.response && error.response.data && error.response.data.message) {
console.error(error);
this.navigateToWithFlashMessage(this.props.root, 'danger', error.response.data.message); this.navigateToWithFlashMessage(this.props.root, 'danger', error.response.data.message);
} else { } else {
console.error(error);
this.navigateToWithFlashMessage(this.props.root, 'danger', error.message); this.navigateToWithFlashMessage(this.props.root, 'danger', error.message);
} }
return true; return true;

View file

@ -45,6 +45,7 @@ class Table extends Component {
selectMode: PropTypes.number, selectMode: PropTypes.number,
selection: PropTypes.oneOfType([PropTypes.array, PropTypes.string, PropTypes.number]), selection: PropTypes.oneOfType([PropTypes.array, PropTypes.string, PropTypes.number]),
selectionKeyIndex: PropTypes.number, selectionKeyIndex: PropTypes.number,
selectionAsArray: PropTypes.bool,
onSelectionChangedAsync: PropTypes.func, onSelectionChangedAsync: PropTypes.func,
onSelectionDataAsync: PropTypes.func, onSelectionDataAsync: PropTypes.func,
actionLinks: PropTypes.array, actionLinks: PropTypes.array,
@ -58,14 +59,14 @@ class Table extends Component {
getSelectionMap(props) { getSelectionMap(props) {
let selArray = []; let selArray = [];
if (props.selectMode === TableSelectMode.SINGLE) { if (props.selectMode === TableSelectMode.SINGLE && !this.props.selectionAsArray) {
if (props.selection !== null && props.selection !== undefined) { if (props.selection !== null && props.selection !== undefined) {
selArray = [props.selection]; selArray = [props.selection];
} else { } else {
selArray = []; selArray = [];
} }
} else if (props.selectMode === TableSelectMode.MULTI) { } else if ((props.selectMode === TableSelectMode.SINGLE && this.props.selectionAsArray) || props.selectMode === TableSelectMode.MULTI) {
selArray = props.selection; selArray = props.selection || [];
} }
const selMap = new Map(); const selMap = new Map();
@ -126,8 +127,6 @@ class Table extends Component {
values: keysToFetch values: keysToFetch
}); });
console.log(response.data);
for (const row of response.data) { for (const row of response.data) {
const key = row[this.props.selectionKeyIndex]; const key = row[this.props.selectionKeyIndex];
if (this.selectionMap.has(key)) { if (this.selectionMap.has(key)) {
@ -270,7 +269,7 @@ class Table extends Component {
let data = selPairs.map(entry => entry[1]); let data = selPairs.map(entry => entry[1]);
let sel = selPairs.map(entry => entry[0]); let sel = selPairs.map(entry => entry[0]);
if (this.props.selectMode === TableSelectMode.SINGLE) { if (this.props.selectMode === TableSelectMode.SINGLE && !this.props.selectionAsArray) {
if (sel.length) { if (sel.length) {
sel = sel[0]; sel = sel[0];
data = data[0]; data = data[0];

View file

@ -3,7 +3,10 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { translate, Trans } from 'react-i18next'; import { translate, Trans } from 'react-i18next';
import { withPageHelpers, Title } from '../lib/page' import { withPageHelpers, Title } from '../lib/page'
import { withForm, Form, FormSendMethod, InputField, TextArea, TableSelect, TableSelectMode, ButtonRow, Button } from '../lib/form'; import {
withForm, Form, FormSendMethod, InputField, TextArea, TableSelect, TableSelectMode, ButtonRow, Button,
Fieldset
} from '../lib/form';
import axios from '../lib/axios'; import axios from '../lib/axios';
import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling'; import { withErrorHandling, withAsyncErrorHandler } from '../lib/error-handling';
import { ModalDialog } from '../lib/bootstrap-components'; import { ModalDialog } from '../lib/bootstrap-components';
@ -23,16 +26,40 @@ export default class CUD extends Component {
this.state.entityId = parseInt(props.match.params.id); this.state.entityId = parseInt(props.match.params.id);
} }
this.initForm(); this.initForm({
onChange: {
report_template: ::this.onReportTemplateChange
}
});
} }
isDelete() { isDelete() {
return this.props.match.params.action === 'delete'; return this.props.match.params.action === 'delete';
} }
@withAsyncErrorHandler
async fetchUserFields(reportTemplateId) {
const result = await axios.get(`/rest/report-template-user-fields/${reportTemplateId}`);
this.updateFormValue('user_fields', result.data);
}
onReportTemplateChange(state, key, oldVal, newVal) {
if (oldVal !== newVal) {
state.formState = state.formState.setIn(['data', 'user_fields', 'value'], '');
if (newVal) {
this.fetchUserFields(newVal);
}
}
}
@withAsyncErrorHandler @withAsyncErrorHandler
async loadFormValues() { async loadFormValues() {
await this.getFormValuesFromURL(`/rest/reports/${this.state.entityId}`); await this.getFormValuesFromURL(`/rest/reports/${this.state.entityId}`, data => {
for (const key in data.params) {
data[`param_${key}`] = data.params[key];
}
});
} }
componentDidMount() { componentDidMount() {
@ -40,9 +67,10 @@ export default class CUD extends Component {
this.loadFormValues(); this.loadFormValues();
} else { } else {
this.populateFormValues({ this.populateFormValues({
report_template: null,
name: '', name: '',
description: '' description: '',
report_template: null,
user_fields: null
}); });
} }
} }
@ -62,12 +90,43 @@ export default class CUD extends Component {
} else { } else {
state.setIn(['report_template', 'error'], null); state.setIn(['report_template', 'error'], null);
} }
for (const paramId of state.keys()) {
if (paramId.startsWith('param_')) {
state.deleteIn([paramId, 'error']);
}
}
const userFieldsSpec = state.getIn(['user_fields', 'value']);
if (userFieldsSpec) {
for (const spec of userFieldsSpec) {
const fldId = `param_${spec.id}`;
const selection = state.getIn([fldId, 'value']) || [];
if (spec.maxOccurences === 1) {
if (spec.minOccurences === 1 && (selection === null || selection === undefined)) {
state.setIn([fldId, 'error'], t('Exactly one item has to be selected'));
}
} else {
if (selection.length < spec.minOccurences) {
state.setIn([fldId, 'error'], t('At least {{ count }} item(s) have to be selected', { count: spec.minOccurences }));
} else if (selection.length > spec.maxOccurences) {
state.setIn([fldId, 'error'], t('At most {{ count }} item(s) can to be selected', { count: spec.maxOccurences }));
}
}
}
}
} }
async submitHandler() { async submitHandler() {
const t = this.props.t; const t = this.props.t;
const edit = this.props.edit; const edit = this.props.edit;
if (!this.getFormValue('user_fields')) {
this.setFormStatusMessage('warning', t('Report parameters are not selected. Wait for them to get displayed and then fill them in.'));
return;
}
let sendMethod, url; let sendMethod, url;
if (edit) { if (edit) {
sendMethod = FormSendMethod.PUT; sendMethod = FormSendMethod.PUT;
@ -81,7 +140,16 @@ export default class CUD extends Component {
this.setFormStatusMessage('info', t('Saving report template ...')); this.setFormStatusMessage('info', t('Saving report template ...'));
const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url, data => { const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url, data => {
delete data.password2; const params = {};
for (const spec of data.user_fields) {
const fldId = `param_${spec.id}`;
params[spec.id] = data[fldId];
delete data[fldId];
}
delete data.user_fields;
data.params = params;
}); });
if (submitSuccessful) { if (submitSuccessful) {
@ -124,6 +192,49 @@ export default class CUD extends Component {
{ data: 3, title: t('Created'), render: data => moment(data).fromNow() } { data: 3, title: t('Created'), render: data => moment(data).fromNow() }
]; ];
const userFieldsSpec = this.getFormValue('user_fields');
const userFields = [];
function addUserFieldTableSelect(spec, dataUrl, selIndex, columns) {
let dropdown, selectMode;
if (spec.maxOccurences === 1) {
dropdown = true;
selectMode = TableSelectMode.SINGLE;
} else {
dropdown = true;
selectMode = TableSelectMode.MULTI;
}
const fld = <TableSelect key={spec.id} id={`param_${spec.id}`} label={spec.name} selectionAsArray withHeader dropdown={dropdown} selectMode={selectMode} dataUrl={dataUrl} columns={columns} selectionLabelIndex={selIndex}/>;
userFields.push(fld);
}
if (userFieldsSpec) {
for (const spec of userFieldsSpec) {
if (spec.type === 'campaign') {
addUserFieldTableSelect(spec, '/rest/campaigns-table', 1,[
{data: 0, title: "#"},
{data: 1, title: t('Name')},
{data: 2, title: t('Description')},
{data: 3, title: t('Status')},
{data: 4, title: t('Created'), render: data => moment(data).fromNow()}
]);
} else if (spec.type === 'list') {
addUserFieldTableSelect(spec, '/rest/lists-table', 1,[
{data: 0, title: "#"},
{data: 1, title: t('Name')},
{data: 2, title: t('ID')},
{data: 3, title: t('Subscribers')},
{data: 4, title: t('Description')}
]);
} else {
userFields.push(<div className="alert alert-danger" role="alert">{t('Unknown field type "{{type}}"', { type: spec.type })}</div>)
}
}
}
return ( return (
<div> <div>
{edit && {edit &&
@ -141,7 +252,17 @@ export default class CUD extends Component {
<InputField id="name" label={t('Name')}/> <InputField id="name" label={t('Name')}/>
<TextArea id="description" label={t('Description')} help={t('HTML is allowed')}/> <TextArea id="description" label={t('Description')} help={t('HTML is allowed')}/>
<TableSelect id="report_template" label={t('Report Template')} withHeader dropdown dataUrl="/rest/report-templates-table" columns={columns} selectionLabelIndex={1} /> <TableSelect id="report_template" label={t('Report Template')} withHeader dropdown dataUrl="/rest/report-templates-table" columns={columns} selectionLabelIndex={1}/>
{userFieldsSpec ?
userFields.length > 0 &&
<Fieldset label={t('Report parameters')}>
{userFields}
</Fieldset>
:
this.getFormValue('report_template') &&
<div className="alert alert-info" role="alert">{t('Loading report template...')}</div>
}
<ButtonRow> <ButtonRow>
<Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/> <Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/>

View file

@ -21,7 +21,7 @@ export default class List extends Component {
{ data: 1, title: t('Name') }, { data: 1, title: t('Name') },
{ data: 2, title: t('Template') }, { data: 2, title: t('Template') },
{ data: 3, title: t('Description') }, { data: 3, title: t('Description') },
{ data: 4, title: t('Created'), render: data => moment(data).fromNow() } { data: 4, title: t('Last Run'), render: data => data ? moment(data).fromNow() : t('Not run yet') }
]; ];
return ( return (

View file

@ -269,9 +269,7 @@ export default class CUD extends Component {
this.disableForm(); this.disableForm();
this.setFormStatusMessage('info', t('Saving report template ...')); this.setFormStatusMessage('info', t('Saving report template ...'));
const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url, data => { const submitSuccessful = await this.validateAndSendFormValuesToURL(sendMethod, url);
delete data.password2;
});
if (submitSuccessful) { if (submitSuccessful) {
if (stay) { if (stay) {

View file

@ -44,6 +44,8 @@ async function ajaxList(params, queryFun, columns) {
query.orderBy(columns[params.columns[order.column].data], order.dir); query.orderBy(columns[params.columns[order.column].data], order.dir);
} }
query.options({rowsAsArray:true});
const rows = await query; const rows = await query;
const rowsOfArray = rows.map(row => Object.keys(row).map(field => row[field])); const rowsOfArray = rows.map(row => Object.keys(row).map(field => row[field]));

View file

@ -3,7 +3,7 @@
const config = require('config'); const config = require('config');
const knex = require('knex')({ const knex = require('knex')({
client: 'mysql', client: 'mysql2',
connection: config.mysql, connection: config.mysql,
migrations: { migrations: {
directory: __dirname + '/../setup/knex/migrations' directory: __dirname + '/../setup/knex/migrations'

13
models/campaigns.js Normal file
View file

@ -0,0 +1,13 @@
'use strict';
const knex = require('../lib/knex');
const dtHelpers = require('../lib/dt-helpers');
async function listDTAjax(params) {
return await dtHelpers.ajaxList(params, tx => tx('campaigns'), ['campaigns.id', 'campaigns.name', 'campaigns.description', 'campaigns.status', 'campaigns.created']);
}
module.exports = {
listDTAjax
};

13
models/lists.js Normal file
View file

@ -0,0 +1,13 @@
'use strict';
const knex = require('../lib/knex');
const dtHelpers = require('../lib/dt-helpers');
async function listDTAjax(params) {
return await dtHelpers.ajaxList(params, tx => tx('lists'), ['lists.id', 'lists.name', 'lists.cid', 'lists.subscribers', 'lists.description']);
}
module.exports = {
listDTAjax
};

View file

@ -50,11 +50,21 @@ async function remove(id) {
await knex('report_templates').where('id', id).del(); await knex('report_templates').where('id', id).del();
} }
async function getUserFieldsById(id) {
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);
}
module.exports = { module.exports = {
hash, hash,
getById, getById,
listDTAjax, listDTAjax,
create, create,
updateWithConsistencyCheck, updateWithConsistencyCheck,
remove remove,
getUserFieldsById
}; };

View file

@ -21,12 +21,15 @@ function hash(entity) {
return hasher.hash(filterObject(entity, allowedKeys)); return hasher.hash(filterObject(entity, allowedKeys));
} }
async function getById(id) { async function getByIdWithUserFields(id) {
const entity = await knex('reports').where('id', id).first(); const entity = await knex('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', 'report_templates.user_fields']).first();
if (!entity) { if (!entity) {
throw new interoperableErrors.NotFoundError(); throw new interoperableErrors.NotFoundError();
} }
entity.user_fields = JSON.parse(entity.user_fields);
entity.params = JSON.parse(entity.params);
return entity; return entity;
} }
@ -36,12 +39,14 @@ async function listDTAjax(params) {
async function create(entity) { async function create(entity) {
await knex.transaction(async tx => { await knex.transaction(async tx => {
const id = await tx('reports').insert(filterObject(entity, allowedKeys));
if (!await tx('report_templates').select(['id']).where('id', entity.report_template).first()) { if (!await tx('report_templates').select(['id']).where('id', entity.report_template).first()) {
throw new interoperableErrors.DependencyNotFoundError(); throw new interoperableErrors.DependencyNotFoundError();
} }
entity.params = JSON.stringify(entity.params);
const id = await tx('reports').insert(filterObject(entity, allowedKeys));
return id; return id;
}); });
} }
@ -53,6 +58,8 @@ async function updateWithConsistencyCheck(entity) {
throw new interoperableErrors.NotFoundError(); throw new interoperableErrors.NotFoundError();
} }
existing.params = JSON.parse(existing.params);
const existingHash = hash(existing); const existingHash = hash(existing);
if (existingHash != entity.originalHash) { if (existingHash != entity.originalHash) {
throw new interoperableErrors.ChangedError(); throw new interoperableErrors.ChangedError();
@ -62,6 +69,8 @@ async function updateWithConsistencyCheck(entity) {
throw new interoperableErrors.DependencyNotFoundError(); throw new interoperableErrors.DependencyNotFoundError();
} }
entity.params = JSON.stringify(entity.params);
await tx('reports').where('id', entity.id).update(filterObject(entity, allowedKeys)); await tx('reports').where('id', entity.id).update(filterObject(entity, allowedKeys));
}); });
} }
@ -87,7 +96,7 @@ async function bulkChangeState(oldState, newState) {
module.exports = { module.exports = {
ReportState, ReportState,
hash, hash,
getById, getByIdWithUserFields,
listDTAjax, listDTAjax,
create, create,
updateWithConsistencyCheck, updateWithConsistencyCheck,

View file

@ -93,7 +93,7 @@
"morgan": "^1.8.1", "morgan": "^1.8.1",
"multer": "^1.3.0", "multer": "^1.3.0",
"multiparty": "^4.1.3", "multiparty": "^4.1.3",
"mysql": "^2.13.0", "mysql2": "^1.3.5",
"node-gettext": "^2.0.0-rc.1", "node-gettext": "^2.0.0-rc.1",
"node-mocks-http": "^1.6.1", "node-mocks-http": "^1.6.1",
"node-object-hash": "^1.2.0", "node-object-hash": "^1.2.0",

14
routes/rest/campaigns.js Normal file
View file

@ -0,0 +1,14 @@
'use strict';
const passport = require('../../lib/passport');
const campaigns = require('../../models/campaigns');
const router = require('../../lib/router-async').create();
router.postAsync('/campaigns-table', passport.loggedIn, async (req, res) => {
return res.json(await campaigns.listDTAjax(req.body));
});
module.exports = router;

14
routes/rest/lists.js Normal file
View file

@ -0,0 +1,14 @@
'use strict';
const passport = require('../../lib/passport');
const lists = require('../../models/lists');
const router = require('../../lib/router-async').create();
router.postAsync('/lists-table', passport.loggedIn, async (req, res) => {
return res.json(await lists.listDTAjax(req.body));
});
module.exports = router;

View file

@ -35,5 +35,10 @@ router.postAsync('/report-templates-table', passport.loggedIn, async (req, res)
return res.json(await reportTemplates.listDTAjax(req.body)); return res.json(await reportTemplates.listDTAjax(req.body));
}); });
router.getAsync('/report-template-user-fields/:reportTemplateId', passport.loggedIn, async (req, res) => {
const userFields = await reportTemplates.getUserFieldsById(req.params.reportTemplateId);
return res.json(userFields);
});
module.exports = router; module.exports = router;

View file

@ -9,7 +9,7 @@ const router = require('../../lib/router-async').create();
router.getAsync('/reports/:reportId', passport.loggedIn, async (req, res) => { router.getAsync('/reports/:reportId', passport.loggedIn, async (req, res) => {
const report = await reports.getById(req.params.reportId); const report = await reports.getByIdWithUserFields(req.params.reportId);
report.hash = reports.hash(report); report.hash = reports.hash(report);
return res.json(report); return res.json(report);
}); });