diff --git a/package.json b/package.json index 051dd4d45..0e273b070 100644 --- a/package.json +++ b/package.json @@ -39,9 +39,9 @@ "@azure/storage-blob": "^12.5.0", "@casl/ability": "^6.5.0", "@casl/mongoose": "^7.2.1", + "@graphql-tools/schema": "^10.0.0", "@turf/helpers": "^6.5.0", "@turf/turf": "^6.5.0", - "@graphql-tools/schema": "^10.0.0", "@ucast/mongo2js": "^1.3.3", "amqplib": "^0.8.0", "axios": "^1.4.0", diff --git a/src/schema/mutation/generateRecords.mutation.ts b/src/schema/mutation/generateRecords.mutation.ts new file mode 100644 index 000000000..428c4fad7 --- /dev/null +++ b/src/schema/mutation/generateRecords.mutation.ts @@ -0,0 +1,121 @@ +import { GraphQLID, GraphQLError, GraphQLList } from 'graphql'; +import GraphQLJSON from 'graphql-type-json'; +import { RecordType } from '../types'; +import { Form, Record, Notification, Channel } from '@models'; +import { getOwnership, getNextId, generateData } from '@utils/form'; +import extendAbilityForRecords from '@security/extendAbilityForRecords'; +import pubsub from '../../server/pubsub'; +import { logger } from '@services/logger.service'; +import { graphQLAuthCheck } from '@schema/shared'; +import { Types } from 'mongoose'; +import { Context } from '@server/apollo/context'; + +/** Arguments for the generateRecords mutation */ +type GenerateRecordArgs = { + form: string | Types.ObjectId; + data: any; +}; + +/** + * Generate up to 50 records using user input or random data + */ +export default { + type: new GraphQLList(RecordType), + args: { + form: { type: GraphQLID }, + data: { type: GraphQLJSON }, + }, + async resolve(parent, args: GenerateRecordArgs, context: Context) { + // check permissions etc + graphQLAuthCheck(context); + try { + const recordsNumber = args.data.recordsNumber; + const formId = args.form; + const fields = args.data.fieldsForm; + const user = context.user; + let records = []; + + const form = await Form.findById(formId); + if (!form) + throw new GraphQLError(context.i18next.t('common.errors.dataNotFound')); + + // Check the ability with permissions for this form + const ability = await extendAbilityForRecords(user, form); + if (ability.cannot('create', 'Record')) { + throw new GraphQLError( + context.i18next.t('common.errors.permissionNotGranted') + ); + } + + for (let i = 0; i < recordsNumber; i++) { + const { incrementalId, incID } = await getNextId( + String(form.resource ? form.resource : args.form) + ); + const generatedData = await generateData(fields, form); + const record = new Record({ + incrementalId, + incID, + form: formId, + data: generatedData, + resource: form.resource ? form.resource : null, + createdBy: { + user: user._id, + roles: user.roles.map((x) => x._id), + positionAttributes: user.positionAttributes.map((x) => { + return { + value: x.value, + category: x.category._id, + }; + }), + }, + lastUpdateForm: form.id, + _createdBy: { + user: { + _id: context.user._id, + name: context.user.name, + username: context.user.username, + }, + }, + _form: { + _id: form._id, + name: form.name, + }, + _lastUpdateForm: { + _id: form._id, + name: form.name, + }, + }); + records.push(record); + // Update the createdBy property if we pass some owner data + const ownership = getOwnership(form.fields, args.data); + if (ownership) { + record.createdBy = { ...record.createdBy, ...ownership }; + } + await record.save(); + } + + // send notifications to channel + const channel = await Channel.findOne({ form: form._id }); + if (channel) { + const notification = new Notification({ + action: `${recordsNumber} generated records - ${form.name}`, + content: records, + channel: channel.id, + seenBy: [], + }); + await notification.save(); + const publisher = await pubsub(); + publisher.publish(channel.id, { notification }); + } + return records; + } catch (err) { + logger.error(err.message, { stack: err.stack }); + if (err instanceof GraphQLError) { + throw new GraphQLError(err.message); + } + throw new GraphQLError( + context.i18next.t('common.errors.internalServerError') + ); + } + }, +}; diff --git a/src/schema/mutation/index.ts b/src/schema/mutation/index.ts index a823192f8..31c0fd032 100644 --- a/src/schema/mutation/index.ts +++ b/src/schema/mutation/index.ts @@ -12,6 +12,7 @@ import deleteRecord from './deleteRecord.mutation'; import deleteRecords from './deleteRecords.mutation'; import convertRecord from './convertRecord.mutation'; import restoreRecord from './restoreRecord.mutation'; +import generateRecords from './generateRecords.mutation'; import addDashboard from './addDashboard.mutation'; import editDashboard from './editDashboard.mutation'; import deleteDashboard from './deleteDashboard.mutation'; @@ -104,6 +105,7 @@ const Mutation = new GraphQLObjectType({ addPositionAttributeCategory, addPullJob, addRecord, + generateRecords, addReferenceData, addRole, addRoleToUsers, diff --git a/src/utils/form/generateData.ts b/src/utils/form/generateData.ts new file mode 100644 index 000000000..b7815f6da --- /dev/null +++ b/src/utils/form/generateData.ts @@ -0,0 +1,519 @@ +import { faker } from '@faker-js/faker'; +import { Record, Role, User, ReferenceData } from '@models'; +/** Generate data for one record */ +export const generateData = async (fields: any, form: any) => { + let data = {}; + const questionsStructure = JSON.parse(form.structure).pages.reduce( + (acc: any, page: any) => acc.concat(page.elements), + [] + ); + const _getChoicesFromQuestion = async (question: any) => { + const questionToBeCopied = questionsStructure.find( + (obj: any) => obj.name === question + ); + if (questionToBeCopied.referenceData) { + const refData = await ReferenceData.findOne({ + _id: questionToBeCopied.referenceData, + }); + if (refData) { + return refData.data.map((item) => item[refData.valueField]); + } + } else if (questionToBeCopied.choices) { + return questionToBeCopied.choices?.map((item) => + typeof item === 'object' ? item.value : item + ); + } + return question.choices?.map((item) => + typeof item === 'object' ? item.value : item + ); + }; + /** Generate data for one field */ + const _generateFieldData = async ( + questionStructure: any, + actions: any + ): Promise => { + const type = questionStructure.inputType ?? questionStructure.type; + let questionChoices = []; + if (questionStructure.referenceData) { + const refData = await ReferenceData.findOne({ + _id: questionStructure.referenceData, + }); + if (refData) { + const refDataChoices = refData.data.map( + (item) => item[refData.valueField] + ); + questionChoices = refDataChoices; + } + } else if (questionStructure.choicesFromQuestion) { + questionChoices = await _getChoicesFromQuestion( + questionStructure.choicesFromQuestion + ); + } else if (questionStructure.choices) { + questionChoices = questionStructure.choices?.map((item) => + typeof item === 'object' ? item.value : item + ); + } + switch (type) { + case 'text': + return faker.lorem.sentence(); + case 'color': + return faker.internet.color(); + case 'date': + return ( + new Date( + faker.date.between( + actions.minDate ?? '2020-01-01T00:00:00.000Z', + actions.maxDate ?? '2030-01-01T00:00:00.000Z' + ) + ) + .toISOString() + .slice(0, 10) + 'T00:00:00.000Z' + ); + case 'datetime-local': + if (actions.maxDate) { + return new Date( + faker.date.between( + actions.minDate ?? '2020-01-01T00:00:00.000Z', + actions.maxDate + 'T23:59:00.000Z' + ) + ).toISOString(); + } + return new Date( + faker.date.between( + actions.minDate ?? '2020-01-01T00:00:00.000Z', + '2030-01-01T00:00:00.000Z' + ) + ).toISOString(); + case 'email': + return faker.internet.email(); + case 'number': + return faker.datatype.number({ + min: actions.minNumber ?? 0, + max: actions.maxNumber ?? 1000, + }); + case 'tel': + return faker.phone.number(); + case 'time': + return ( + '1970-01-01T' + + new Date( + faker.date.between( + actions.minTime ?? '1970-01-01T00:00:00.000Z', + actions.maxTime ?? '1970-01-01T23:59:00.000Z' + ) + ) + .toISOString() + .slice(11, 16) + + ':00.000Z' + ); + case 'url': + return faker.internet.url(); + case 'month': + return faker.date + .between('2020-01-01T00:00:00.000Z', '2030-01-01T00:00:00.000Z') + .toISOString() + .slice(0, 7); + case 'password': + return faker.internet.password(); + case 'range': + return faker.datatype.number({ + min: 0, + max: 100, + }); + case 'week': + return ( + faker.datatype.number({ min: 2000, max: 2030 }) + + '-W' + + faker.datatype.number({ min: 1, max: 52 }) + ); + case undefined: + case 'comment': + return faker.lorem.paragraph(); + case 'radiogroup': + case 'dropdown': + // If the choices are set with values != texts, we only want the values + // If the choices are set with values == texts, choices is an array of strings + return questionChoices[ + faker.datatype.number({ + min: 0, + max: questionChoices.length - 1, + }) + ]; + case 'tagbox': + case 'checkbox': + let choices = []; + questionChoices.forEach((choice: any) => { + if (faker.datatype.boolean()) { + choices.push(choice); + } + }); + if (choices.length === 0) { + choices.push( + questionChoices[ + faker.datatype.number({ + min: 0, + max: questionChoices.length - 1, + }) + ] + ); + } + return choices; + case 'boolean': + return faker.datatype.boolean(); + case 'multipletext': + let items = {}; + questionStructure.items.forEach((item: any) => { + items[item.name] = faker.lorem.sentence(); + }); + return items; + case 'matrix': + let matrixItems = {}; + questionStructure.rows.forEach((row: any) => { + matrixItems[row.value] = + questionStructure.columns[ + faker.datatype.number({ + min: 0, + max: questionStructure.columns.length - 1, + }) + ].value; + }); + return matrixItems; + case 'matrixdropdown': + let matrixDropdownItems = {}; + questionStructure.rows.forEach((row: any) => { + matrixDropdownItems[row.value] = {}; + questionStructure.columns.forEach(async (column: any) => { + let matrixDropdownChoices = []; + if (column.choicesFromQuestion) { + matrixDropdownChoices = await _getChoicesFromQuestion( + column.choicesFromQuestion + ); + } else { + matrixDropdownChoices = column.choices?.map((item) => + typeof item === 'object' ? item.value : item + ); + } + // If choices are set for the column, use them, otherwise use the choices set for the question + const columnChoices = matrixDropdownChoices ?? questionChoices; + switch (column.cellType) { + case null: + case undefined: // Undefined(default) is a dropdown which always uses the question choices + matrixDropdownItems[row.value][column.name] = questionChoices[ + faker.datatype.number({ + min: 0, + max: questionChoices.length - 1, + }) + ].map((item) => (typeof item === 'object' ? item.value : item)); + break; + case 'dropdown': + case 'radiogroup': + matrixDropdownItems[row.value][column.name] = + columnChoices[ + faker.datatype.number({ + min: 0, + max: columnChoices.length - 1, + }) + ]; + break; + case 'checkbox': + case 'tagbox': + let choices = []; + columnChoices.forEach((choice: any) => { + if (faker.datatype.boolean()) { + choices.push(choice); + } + }); + if (choices.length === 0) { + choices.push( + columnChoices[ + faker.datatype.number({ + min: 0, + max: columnChoices.length - 1, + }) + ] + ); + } + matrixDropdownItems[row.value][column.name] = choices; + break; + case 'boolean': + matrixDropdownItems[row.value][column.name] = + faker.datatype.boolean(); + break; + case 'text': + case 'comment': + matrixDropdownItems[row.value][column.name] = + faker.lorem.sentence(); + break; + case 'rating': + matrixDropdownItems[row.value][column.name] = + faker.datatype.number({ min: 1, max: 5 }); + break; + case 'expression': + break; + default: + break; + } + }); + }); + return matrixDropdownItems; + case 'matrixdynamic': + let matrixDynamicItems = []; + // Since we don't know the number of rows, we'll generate a random number of rows between 1 and 5 + for (let i = 0; i < faker.datatype.number({ min: 1, max: 5 }); i++) { + let matrixDynamicItem = {}; + questionStructure.columns.forEach(async (column: any) => { + let matrixDynamicChoices = []; + if (column.choicesFromQuestion) { + matrixDynamicChoices = await _getChoicesFromQuestion( + column.choicesFromQuestion + ); + } else { + matrixDynamicChoices = column.choices?.map((item) => + typeof item === 'object' ? item.value : item + ); + } + const columnChoices = matrixDynamicChoices ?? questionChoices; + switch (column.cellType) { + case null: + case undefined: + matrixDynamicItem[column.name] = + questionChoices[ + faker.datatype.number({ + min: 0, + max: questionChoices.length - 1, + }) + ]; + break; + case 'dropdown': + case 'radiogroup': + matrixDynamicItem[column.name] = + columnChoices[ + faker.datatype.number({ + min: 0, + max: columnChoices.length - 1, + }) + ]; + break; + case 'checkbox': + case 'tagbox': + let choices = []; + columnChoices.forEach((choice: any) => { + if (faker.datatype.boolean()) { + choices.push(choice); + } + }); + if (choices.length === 0) { + choices.push( + columnChoices[ + faker.datatype.number({ + min: 0, + max: columnChoices.length - 1, + }) + ] + ); + } + matrixDynamicItem[column.name] = choices; + break; + case 'boolean': + matrixDynamicItem[column.name] = faker.datatype.boolean(); + break; + case 'text': + case 'comment': + matrixDynamicItem[column.name] = faker.lorem.sentence(); + break; + case 'rating': + matrixDynamicItem[column.name] = faker.datatype.number({ + min: 1, + max: 5, + }); + break; + case 'expression': + break; + default: + break; + } + }); + matrixDynamicItems.push(matrixDynamicItem); + } + return matrixDynamicItems; + case 'expression': + return; + case 'resource': + const record = await Record.findOne({ + resource: questionStructure.resource, + archived: { $ne: true }, + }).skip( + faker.datatype.number({ + min: 0, + max: + (await Record.countDocuments({ + resource: questionStructure.resource, + archived: { $ne: true }, + })) - 1, + }) + ); + return record.id; + + case 'resources': + const records = await Record.find({ + resource: questionStructure.resource, + archived: { $ne: true }, + }).skip( + faker.datatype.number({ + min: 0, + max: + (await Record.countDocuments({ + resource: questionStructure.resource, + archived: { $ne: true }, + })) - 1, + }) + ); + return records.map((x) => x.id); + + case 'owner': + let roles = []; + await Promise.all( + questionStructure.applications?.map(async (application: any) => { + const tempRoles = await Role.find({ + application, + }); + tempRoles.forEach((role: any) => { + if (faker.datatype.boolean()) { + roles.push(role.id); + } + }); + if ( + questionStructure.isRequired && // If the question is required we push a random role if none are generated + roles.length === 0 && + tempRoles.length > 0 + ) { + roles.push( + tempRoles[ + faker.datatype.number({ + min: 0, + max: tempRoles.length - 1, + }) + ].id + ); + } + }) + ); + return roles; + case 'users': + let users = []; + if (questionStructure.applications) { + // If an application is set in the question, we only get users with roles in that application + await Promise.all( + questionStructure.applications.map(async (application: any) => { + const tempRoles = await Role.find({ + application, + }); + for (const role of tempRoles) { + const tempUsers = await User.find({ + roles: role.id, + }); + for (const user of tempUsers) { + if (faker.datatype.boolean() && !users.includes(user.id)) { + users.push(user.id); + } + } + if ( + questionStructure.isRequired && + users.length === 0 && + tempUsers.length > 0 + ) { + users.push( + tempUsers[ + faker.datatype.number({ + min: 0, + max: tempUsers.length - 1, + }) + ].id + ); + } + } + }) + ); + } else { + // If no application is set, we get any users + const tempUsers = await User.find(); + for (const user of tempUsers) { + if (faker.datatype.boolean()) { + users.push(user.id); + } + } + if ( + questionStructure.isRequired && + users.length === 0 && + tempUsers.length > 0 + ) { + users.push( + tempUsers[ + faker.datatype.number({ + min: 0, + max: tempUsers.length - 1, + }) + ].id + ); + } + } + return users; + case 'geospatial': + return { + type: 'Feature', + geometry: { + type: 'Point', + coordinates: [ + Number(faker.address.longitude()), + Number(faker.address.latitude()), + ], + }, + properties: {}, + }; + case 'paneldynamic': + let panelData = []; + for (let i = 0; i < faker.datatype.number({ min: 1, max: 5 }); i++) { + let panelItem = {}; + await Promise.all( + questionStructure.templateElements?.map( + async (panelQuestion: any) => { + panelItem[panelQuestion.name] = await _generateFieldData( + panelQuestion, + {} + ); + } + ) + ); + panelData.push(panelItem); + } + return panelData; + default: + return faker.lorem.sentence(); + } + }; + await Promise.all( + fields.map(async (field: any) => { + const questionStructure = questionsStructure.find( + (obj: any) => obj.name === field.field + ); + const actions = { + minDate: field.minDate, + maxDate: field.maxDate, + minNumber: field.minNumber, + maxNumber: field.maxNumber, + minTime: field.minTime, + maxTime: field.maxTime, + }; + if (field.include) { + if (field.setDefault) { + return field.default; // If default value is set, use it + } + data[questionStructure.name] = await _generateFieldData( + questionStructure, + actions + ); + } + }) + ); + return data; +}; diff --git a/src/utils/form/index.ts b/src/utils/form/index.ts index f7d424a8c..5365f87e2 100644 --- a/src/utils/form/index.ts +++ b/src/utils/form/index.ts @@ -14,3 +14,4 @@ export * from './getDisplayText'; export * from './checkRecordValidation'; export * from './getAccessibleFields'; export * from './checkRecordTriggers'; +export * from './generateData';