diff --git a/packages/vulcan-core/lib/modules/default_mutations.js b/packages/vulcan-core/lib/modules/default_mutations.js index 12bd1a7000..a9a079a8f3 100644 --- a/packages/vulcan-core/lib/modules/default_mutations.js +++ b/packages/vulcan-core/lib/modules/default_mutations.js @@ -1,456 +1,456 @@ -/* - -Default mutations - -*/ - -import { - registerCallback, - createMutator, - updateMutator, - deleteMutator, - Utils, - Connectors, - getTypeName, - getCollectionName, - getCollection, -} from 'meteor/vulcan:lib'; -import Users from 'meteor/vulcan:users'; -import isEmpty from 'lodash/isEmpty'; -import get from 'lodash/get'; - -const defaultOptions = { create: true, update: true, upsert: true, delete: true }; - -const getCreateMutationName = typeName => `create${typeName}`; -const getUpdateMutationName = typeName => `update${typeName}`; -const getDeleteMutationName = typeName => `delete${typeName}`; -const getUpsertMutationName = typeName => `upsert${typeName}`; -//const getMultiQueryName = (typeName) => `multi${typeName}Query`; - -export function getDefaultMutations(options) { - let typeName, collectionName, mutationOptions; - - if (typeof arguments[0] === 'object') { - // new single-argument API - typeName = arguments[0].typeName; - collectionName = arguments[0].collectionName || getCollectionName(typeName); - mutationOptions = { ...defaultOptions, ...arguments[0].options }; - } else { - // OpenCRUD backwards compatibility - collectionName = arguments[0]; - typeName = getTypeName(collectionName); - mutationOptions = { ...defaultOptions, ...arguments[1] }; - } - - // register callbacks for documentation purposes - registerCollectionCallbacks(typeName, mutationOptions); - - const mutations = {}; - - if (mutationOptions.create) { - // mutation for inserting a new document - - const mutationName = getCreateMutationName(typeName); - - const createMutation = { - description: `Mutation for creating new ${typeName} documents`, - name: mutationName, - - // check function called on a user to see if they can perform the operation - check(user, document) { - - // new API - const permissionsCheck = get(getCollection(collectionName), 'options.permissions.canCreate'); - if (typeof permissionsCheck === 'function') { - return permissionsCheck(user, document); - } else if (Array.isArray(permissionsCheck)) { - return Users.isMemberOf(user, permissionsCheck, document); - } - - // OpenCRUD backwards compatibility - const check = mutationOptions.createCheck || mutationOptions.newCheck; - if (check) { - return check(user, document); - } - - // check if they can perform "foo.new" operation (e.g. "movie.new") - // OpenCRUD backwards compatibility - return Users.canDo(user, [ - `${typeName.toLowerCase()}.create`, - `${collectionName.toLowerCase()}.new`, - ]); - }, - - async mutation(root, { data }, context) { - const collection = context[collectionName]; - - // check if current user can pass check function; else throw error - Utils.performCheck( - this.check, - context.currentUser, - data, - '', - `${typeName}.create`, - collectionName - ); - - // pass document to boilerplate newMutator function - return await createMutator({ - collection, - data, - currentUser: context.currentUser, - validate: true, - context, - }); - }, - }; - mutations.create = createMutation; - // OpenCRUD backwards compatibility - mutations.new = createMutation; - } - - if (mutationOptions.update) { - // mutation for editing a specific document - - const mutationName = getUpdateMutationName(typeName); - - const updateMutation = { - description: `Mutation for updating a ${typeName} document`, - name: mutationName, - - // check function called on a user and document to see if they can perform the operation - check(user, document) { - - // new API - const permissionsCheck = get(getCollection(collectionName), 'options.permissions.canUpdate'); - if (typeof permissionsCheck === 'function') { - return permissionsCheck(user, document); - } else if (Array.isArray(permissionsCheck)) { - return Users.isMemberOf(user, permissionsCheck, document); - } +// /* + +// Default mutations + +// */ + +// import { +// registerCallback, +// createMutator, +// updateMutator, +// deleteMutator, +// Utils, +// Connectors, +// getTypeName, +// getCollectionName, +// getCollection, +// } from 'meteor/vulcan:lib'; +// import Users from 'meteor/vulcan:users'; +// import isEmpty from 'lodash/isEmpty'; +// import get from 'lodash/get'; + +// const defaultOptions = { create: true, update: true, upsert: true, delete: true }; + +// const getCreateMutationName = typeName => `create${typeName}`; +// const getUpdateMutationName = typeName => `update${typeName}`; +// const getDeleteMutationName = typeName => `delete${typeName}`; +// const getUpsertMutationName = typeName => `upsert${typeName}`; +// //const getMultiQueryName = (typeName) => `multi${typeName}Query`; + +// export function getDefaultMutations(options) { +// let typeName, collectionName, mutationOptions; + +// if (typeof arguments[0] === 'object') { +// // new single-argument API +// typeName = arguments[0].typeName; +// collectionName = arguments[0].collectionName || getCollectionName(typeName); +// mutationOptions = { ...defaultOptions, ...arguments[0].options }; +// } else { +// // OpenCRUD backwards compatibility +// collectionName = arguments[0]; +// typeName = getTypeName(collectionName); +// mutationOptions = { ...defaultOptions, ...arguments[1] }; +// } + +// // register callbacks for documentation purposes +// registerCollectionCallbacks(typeName, mutationOptions); + +// const mutations = {}; + +// if (mutationOptions.create) { +// // mutation for inserting a new document + +// const mutationName = getCreateMutationName(typeName); + +// const createMutation = { +// description: `Mutation for creating new ${typeName} documents`, +// name: mutationName, + +// // check function called on a user to see if they can perform the operation +// check(user, document) { + +// // new API +// const permissionsCheck = get(getCollection(collectionName), 'options.permissions.canCreate'); +// if (typeof permissionsCheck === 'function') { +// return permissionsCheck(user, document); +// } else if (Array.isArray(permissionsCheck)) { +// return Users.isMemberOf(user, permissionsCheck, document); +// } + +// // OpenCRUD backwards compatibility +// const check = mutationOptions.createCheck || mutationOptions.newCheck; +// if (check) { +// return check(user, document); +// } + +// // check if they can perform "foo.new" operation (e.g. "movie.new") +// // OpenCRUD backwards compatibility +// return Users.canDo(user, [ +// `${typeName.toLowerCase()}.create`, +// `${collectionName.toLowerCase()}.new`, +// ]); +// }, + +// async mutation(root, { data }, context) { +// const collection = context[collectionName]; + +// // check if current user can pass check function; else throw error +// Utils.performCheck( +// this.check, +// context.currentUser, +// data, +// '', +// `${typeName}.create`, +// collectionName +// ); + +// // pass document to boilerplate newMutator function +// return await createMutator({ +// collection, +// data, +// currentUser: context.currentUser, +// validate: true, +// context, +// }); +// }, +// }; +// mutations.create = createMutation; +// // OpenCRUD backwards compatibility +// mutations.new = createMutation; +// } + +// if (mutationOptions.update) { +// // mutation for editing a specific document + +// const mutationName = getUpdateMutationName(typeName); + +// const updateMutation = { +// description: `Mutation for updating a ${typeName} document`, +// name: mutationName, + +// // check function called on a user and document to see if they can perform the operation +// check(user, document) { + +// // new API +// const permissionsCheck = get(getCollection(collectionName), 'options.permissions.canUpdate'); +// if (typeof permissionsCheck === 'function') { +// return permissionsCheck(user, document); +// } else if (Array.isArray(permissionsCheck)) { +// return Users.isMemberOf(user, permissionsCheck, document); +// } - // OpenCRUD backwards compatibility - const check = mutationOptions.updateCheck || mutationOptions.editCheck; - if (check) { - return check(user, document); - } - - if (!user || !document) return false; - // check if user owns the document being edited. - // if they do, check if they can perform "foo.edit.own" action - // if they don't, check if they can perform "foo.edit.all" action - // OpenCRUD backwards compatibility - return Users.owns(user, document) - ? Users.canDo(user, [ - `${typeName.toLowerCase()}.update.own`, - `${collectionName.toLowerCase()}.edit.own`, - ]) - : Users.canDo(user, [ - `${typeName.toLowerCase()}.update.all`, - `${collectionName.toLowerCase()}.edit.all`, - ]); - }, - - async mutation(root, { where, selector: oldSelector, data }, context) { - const collection = context[collectionName]; - - // handle both `where` and `selector` for backwards-compatibility - let selector; - if (!isEmpty(where)) { - const filterParameters = Connectors.filter(collection, { where }, context); - selector = filterParameters.selector; - } else { - if (!isEmpty(oldSelector)) { - selector = oldSelector; - } else { - throw new Error('Selector cannot be empty'); - } - } - - // get entire unmodified document from database - const document = await Connectors.get(collection, selector); - - if (!document) { - throw new Error( - `Could not find document to update for selector: ${JSON.stringify(selector)}` - ); - } - - // check if user can perform operation; if not throw error - Utils.performCheck( - this.check, - context.currentUser, - document, - document._id, - `${typeName}.update`, - collectionName - ); - - // call editMutator boilerplate function - return await updateMutator({ - collection, - selector, - data, - currentUser: context.currentUser, - validate: true, - context, - document, - }); - }, - }; - - mutations.update = updateMutation; - // OpenCRUD backwards compatibility - mutations.edit = updateMutation; - } - if (mutationOptions.upsert) { - // mutation for upserting a specific document - const mutationName = getUpsertMutationName(typeName); - mutations.upsert = { - description: `Mutation for upserting a ${typeName} document`, - name: mutationName, - - async mutation(root, { where, selector, data }, context) { - const collection = context[collectionName]; - - // check if documeet exists already - const existingDocument = await Connectors.get(collection, selector, { - fields: { _id: 1 }, - }); - - if (existingDocument) { - return await collection.options.mutations.update.mutation( - root, - { where, selector, data }, - context - ); - } else { - return await collection.options.mutations.create.mutation(root, { data }, context); - } - }, - }; - } - - if (mutationOptions.delete) { - // mutation for removing a specific document (same checks as edit mutation) - - const mutationName = getDeleteMutationName(typeName); - - const deleteMutation = { - description: `Mutation for deleting a ${typeName} document`, - name: mutationName, - - check(user, document) { - - // new API - const permissionsCheck = get(getCollection(collectionName), 'options.permissions.canDelete'); - if (typeof permissionsCheck === 'function') { - return permissionsCheck(user, document); - } else if (Array.isArray(permissionsCheck)) { - return Users.isMemberOf(user, permissionsCheck, document); - } - - // OpenCRUD backwards compatibility - const check = mutationOptions.deleteCheck || mutationOptions.removeCheck; - if (check) { - return check(user, document); - } - - if (!user || !document) return false; - // OpenCRUD backwards compatibility - return Users.owns(user, document) - ? Users.canDo(user, [ - `${typeName.toLowerCase()}.delete.own`, - `${collectionName.toLowerCase()}.remove.own`, - ]) - : Users.canDo(user, [ - `${typeName.toLowerCase()}.delete.all`, - `${collectionName.toLowerCase()}.remove.all`, - ]); - }, - - async mutation(root, { where, selector: oldSelector }, context) { - const collection = context[collectionName]; - - // handle both `where` and `selector` for backwards-compatibility - let selector; - if (!isEmpty(where)) { - const filterParameters = Connectors.filter(collection, { where }, context); - selector = filterParameters.selector; - } else { - if (!isEmpty(oldSelector)) { - selector = oldSelector; - } else { - throw new Error('Selector cannot be empty'); - } - } - - const document = await Connectors.get(collection, selector); - - if (!document) { - throw new Error( - `Could not find document to delete for selector: ${JSON.stringify(selector)}` - ); - } - - Utils.performCheck( - this.check, - context.currentUser, - document, - context, - document._id, - `${typeName}.delete`, - collectionName - ); - - return await deleteMutator({ - collection, - selector: { _id: document._id }, - currentUser: context.currentUser, - validate: true, - context, - document, - }); - }, - }; - - mutations.delete = deleteMutation; - // OpenCRUD backwards compatibility - mutations.remove = deleteMutation; - } - - return mutations; -} - -const registerCollectionCallbacks = (typeName, options) => { - typeName = typeName.toLowerCase(); - - if (options.create) { - registerCallback({ - name: `${typeName}.create.validate`, - iterator: { validationErrors: 'An array that can be used to accumulate validation errors' }, - properties: [ - { document: 'The document being inserted' }, - { currentUser: 'The current user' }, - { collection: 'The collection the document belongs to' }, - { context: 'The context of the mutation' }, - ], - runs: 'sync', - returns: 'document', - description: - 'Validate a document before insertion (can be skipped when inserting directly on server).', - }); - registerCallback({ - name: `${typeName}.create.before`, - iterator: { document: 'The document being inserted' }, - properties: [{ currentUser: 'The current user' }], - runs: 'sync', - returns: 'document', - description: "Perform operations on a new document before it's inserted in the database.", - }); - registerCallback({ - name: `${typeName}.create.after`, - iterator: { document: 'The document being inserted' }, - properties: [{ currentUser: 'The current user' }], - runs: 'sync', - returns: 'document', - description: - "Perform operations on a new document after it's inserted in the database but *before* the mutation returns it.", - }); - registerCallback({ - name: `${typeName}.create.async`, - iterator: { document: 'The document being inserted' }, - properties: [ - { currentUser: 'The current user' }, - { collection: 'The collection the document belongs to' }, - ], - runs: 'async', - returns: null, - description: - "Perform operations on a new document after it's inserted in the database asynchronously.", - }); - } - if (options.update) { - registerCallback({ - name: `${typeName}.update.validate`, - iterator: { validationErrors: 'An object that can be used to accumulate validation errors' }, - properties: [ - { document: 'The document being edited' }, - { data: 'The client data' }, - { currentUser: 'The current user' }, - { collection: 'The collection the document belongs to' }, - { context: 'The context of the mutation' }, - ], - runs: 'sync', - returns: 'modifier', - description: - 'Validate a document before update (can be skipped when updating directly on server).', - }); - registerCallback({ - name: `${typeName}.update.before`, - iterator: { data: 'The client data' }, - properties: [{ document: 'The document being edited' }, { currentUser: 'The current user' }], - runs: 'sync', - returns: 'modifier', - description: "Perform operations on a document before it's updated in the database.", - }); - registerCallback({ - name: `${typeName}.update.after`, - iterator: { newDocument: 'The document after the update' }, - properties: [{ document: 'The document being edited' }, { currentUser: 'The current user' }], - runs: 'sync', - returns: 'document', - description: - "Perform operations on a document after it's updated in the database but *before* the mutation returns it.", - }); - registerCallback({ - name: `${typeName}.update.async`, - iterator: { newDocument: 'The document after the edit' }, - properties: [ - { document: 'The document before the edit' }, - { currentUser: 'The current user' }, - { collection: 'The collection the document belongs to' }, - ], - runs: 'async', - returns: null, - description: - "Perform operations on a document after it's updated in the database asynchronously.", - }); - } - if (options.delete) { - registerCallback({ - name: `${typeName}.delete.validate`, - iterator: { validationErrors: 'An object that can be used to accumulate validation errors' }, - properties: [ - { currentUser: 'The current user' }, - { document: 'The document being removed' }, - { collection: 'The collection the document belongs to' }, - { context: 'The context of this mutation' }, - ], - runs: 'sync', - returns: 'document', - description: - 'Validate a document before removal (can be skipped when removing directly on server).', - }); - registerCallback({ - name: `${typeName}.delete.before`, - iterator: { document: 'The document being removed' }, - properties: [{ currentUser: 'The current user' }], - runs: 'sync', - returns: null, - description: "Perform operations on a document before it's removed from the database.", - }); - registerCallback({ - name: `${typeName}.delete.async`, - properties: [ - { document: 'The document being removed' }, - { currentUser: 'The current user' }, - { collection: 'The collection the document belongs to' }, - ], - runs: 'async', - returns: null, - description: - "Perform operations on a document after it's removed from the database asynchronously.", - }); - } -}; +// // OpenCRUD backwards compatibility +// const check = mutationOptions.updateCheck || mutationOptions.editCheck; +// if (check) { +// return check(user, document); +// } + +// if (!user || !document) return false; +// // check if user owns the document being edited. +// // if they do, check if they can perform "foo.edit.own" action +// // if they don't, check if they can perform "foo.edit.all" action +// // OpenCRUD backwards compatibility +// return Users.owns(user, document) +// ? Users.canDo(user, [ +// `${typeName.toLowerCase()}.update.own`, +// `${collectionName.toLowerCase()}.edit.own`, +// ]) +// : Users.canDo(user, [ +// `${typeName.toLowerCase()}.update.all`, +// `${collectionName.toLowerCase()}.edit.all`, +// ]); +// }, + +// async mutation(root, { where, selector: oldSelector, data }, context) { +// const collection = context[collectionName]; + +// // handle both `where` and `selector` for backwards-compatibility +// let selector; +// if (!isEmpty(where)) { +// const filterParameters = Connectors.filter(collection, { where }, context); +// selector = filterParameters.selector; +// } else { +// if (!isEmpty(oldSelector)) { +// selector = oldSelector; +// } else { +// throw new Error('Selector cannot be empty'); +// } +// } + +// // get entire unmodified document from database +// const document = await Connectors.get(collection, selector); + +// if (!document) { +// throw new Error( +// `Could not find document to update for selector: ${JSON.stringify(selector)}` +// ); +// } + +// // check if user can perform operation; if not throw error +// Utils.performCheck( +// this.check, +// context.currentUser, +// document, +// document._id, +// `${typeName}.update`, +// collectionName +// ); + +// // call editMutator boilerplate function +// return await updateMutator({ +// collection, +// selector, +// data, +// currentUser: context.currentUser, +// validate: true, +// context, +// document, +// }); +// }, +// }; + +// mutations.update = updateMutation; +// // OpenCRUD backwards compatibility +// mutations.edit = updateMutation; +// } +// if (mutationOptions.upsert) { +// // mutation for upserting a specific document +// const mutationName = getUpsertMutationName(typeName); +// mutations.upsert = { +// description: `Mutation for upserting a ${typeName} document`, +// name: mutationName, + +// async mutation(root, { where, selector, data }, context) { +// const collection = context[collectionName]; + +// // check if documeet exists already +// const existingDocument = await Connectors.get(collection, selector, { +// fields: { _id: 1 }, +// }); + +// if (existingDocument) { +// return await collection.options.mutations.update.mutation( +// root, +// { where, selector, data }, +// context +// ); +// } else { +// return await collection.options.mutations.create.mutation(root, { data }, context); +// } +// }, +// }; +// } + +// if (mutationOptions.delete) { +// // mutation for removing a specific document (same checks as edit mutation) + +// const mutationName = getDeleteMutationName(typeName); + +// const deleteMutation = { +// description: `Mutation for deleting a ${typeName} document`, +// name: mutationName, + +// check(user, document) { + +// // new API +// const permissionsCheck = get(getCollection(collectionName), 'options.permissions.canDelete'); +// if (typeof permissionsCheck === 'function') { +// return permissionsCheck(user, document); +// } else if (Array.isArray(permissionsCheck)) { +// return Users.isMemberOf(user, permissionsCheck, document); +// } + +// // OpenCRUD backwards compatibility +// const check = mutationOptions.deleteCheck || mutationOptions.removeCheck; +// if (check) { +// return check(user, document); +// } + +// if (!user || !document) return false; +// // OpenCRUD backwards compatibility +// return Users.owns(user, document) +// ? Users.canDo(user, [ +// `${typeName.toLowerCase()}.delete.own`, +// `${collectionName.toLowerCase()}.remove.own`, +// ]) +// : Users.canDo(user, [ +// `${typeName.toLowerCase()}.delete.all`, +// `${collectionName.toLowerCase()}.remove.all`, +// ]); +// }, + +// async mutation(root, { where, selector: oldSelector }, context) { +// const collection = context[collectionName]; + +// // handle both `where` and `selector` for backwards-compatibility +// let selector; +// if (!isEmpty(where)) { +// const filterParameters = Connectors.filter(collection, { where }, context); +// selector = filterParameters.selector; +// } else { +// if (!isEmpty(oldSelector)) { +// selector = oldSelector; +// } else { +// throw new Error('Selector cannot be empty'); +// } +// } + +// const document = await Connectors.get(collection, selector); + +// if (!document) { +// throw new Error( +// `Could not find document to delete for selector: ${JSON.stringify(selector)}` +// ); +// } + +// Utils.performCheck( +// this.check, +// context.currentUser, +// document, +// context, +// document._id, +// `${typeName}.delete`, +// collectionName +// ); + +// return await deleteMutator({ +// collection, +// selector: { _id: document._id }, +// currentUser: context.currentUser, +// validate: true, +// context, +// document, +// }); +// }, +// }; + +// mutations.delete = deleteMutation; +// // OpenCRUD backwards compatibility +// mutations.remove = deleteMutation; +// } + +// return mutations; +// } + +// const registerCollectionCallbacks = (typeName, options) => { +// typeName = typeName.toLowerCase(); + +// if (options.create) { +// registerCallback({ +// name: `${typeName}.create.validate`, +// iterator: { validationErrors: 'An array that can be used to accumulate validation errors' }, +// properties: [ +// { document: 'The document being inserted' }, +// { currentUser: 'The current user' }, +// { collection: 'The collection the document belongs to' }, +// { context: 'The context of the mutation' }, +// ], +// runs: 'sync', +// returns: 'document', +// description: +// 'Validate a document before insertion (can be skipped when inserting directly on server).', +// }); +// registerCallback({ +// name: `${typeName}.create.before`, +// iterator: { document: 'The document being inserted' }, +// properties: [{ currentUser: 'The current user' }], +// runs: 'sync', +// returns: 'document', +// description: "Perform operations on a new document before it's inserted in the database.", +// }); +// registerCallback({ +// name: `${typeName}.create.after`, +// iterator: { document: 'The document being inserted' }, +// properties: [{ currentUser: 'The current user' }], +// runs: 'sync', +// returns: 'document', +// description: +// "Perform operations on a new document after it's inserted in the database but *before* the mutation returns it.", +// }); +// registerCallback({ +// name: `${typeName}.create.async`, +// iterator: { document: 'The document being inserted' }, +// properties: [ +// { currentUser: 'The current user' }, +// { collection: 'The collection the document belongs to' }, +// ], +// runs: 'async', +// returns: null, +// description: +// "Perform operations on a new document after it's inserted in the database asynchronously.", +// }); +// } +// if (options.update) { +// registerCallback({ +// name: `${typeName}.update.validate`, +// iterator: { validationErrors: 'An object that can be used to accumulate validation errors' }, +// properties: [ +// { document: 'The document being edited' }, +// { data: 'The client data' }, +// { currentUser: 'The current user' }, +// { collection: 'The collection the document belongs to' }, +// { context: 'The context of the mutation' }, +// ], +// runs: 'sync', +// returns: 'modifier', +// description: +// 'Validate a document before update (can be skipped when updating directly on server).', +// }); +// registerCallback({ +// name: `${typeName}.update.before`, +// iterator: { data: 'The client data' }, +// properties: [{ document: 'The document being edited' }, { currentUser: 'The current user' }], +// runs: 'sync', +// returns: 'modifier', +// description: "Perform operations on a document before it's updated in the database.", +// }); +// registerCallback({ +// name: `${typeName}.update.after`, +// iterator: { newDocument: 'The document after the update' }, +// properties: [{ document: 'The document being edited' }, { currentUser: 'The current user' }], +// runs: 'sync', +// returns: 'document', +// description: +// "Perform operations on a document after it's updated in the database but *before* the mutation returns it.", +// }); +// registerCallback({ +// name: `${typeName}.update.async`, +// iterator: { newDocument: 'The document after the edit' }, +// properties: [ +// { document: 'The document before the edit' }, +// { currentUser: 'The current user' }, +// { collection: 'The collection the document belongs to' }, +// ], +// runs: 'async', +// returns: null, +// description: +// "Perform operations on a document after it's updated in the database asynchronously.", +// }); +// } +// if (options.delete) { +// registerCallback({ +// name: `${typeName}.delete.validate`, +// iterator: { validationErrors: 'An object that can be used to accumulate validation errors' }, +// properties: [ +// { currentUser: 'The current user' }, +// { document: 'The document being removed' }, +// { collection: 'The collection the document belongs to' }, +// { context: 'The context of this mutation' }, +// ], +// runs: 'sync', +// returns: 'document', +// description: +// 'Validate a document before removal (can be skipped when removing directly on server).', +// }); +// registerCallback({ +// name: `${typeName}.delete.before`, +// iterator: { document: 'The document being removed' }, +// properties: [{ currentUser: 'The current user' }], +// runs: 'sync', +// returns: null, +// description: "Perform operations on a document before it's removed from the database.", +// }); +// registerCallback({ +// name: `${typeName}.delete.async`, +// properties: [ +// { document: 'The document being removed' }, +// { currentUser: 'The current user' }, +// { collection: 'The collection the document belongs to' }, +// ], +// runs: 'async', +// returns: null, +// description: +// "Perform operations on a document after it's removed from the database asynchronously.", +// }); +// } +// }; diff --git a/packages/vulcan-core/lib/modules/default_resolvers.js b/packages/vulcan-core/lib/modules/default_resolvers.js index 2e047b6fbc..81ab6ee1d9 100644 --- a/packages/vulcan-core/lib/modules/default_resolvers.js +++ b/packages/vulcan-core/lib/modules/default_resolvers.js @@ -1,232 +1,232 @@ -/* - -Default list, single, and total resolvers - -*/ - -import { - Utils, - debug, - debugGroup, - debugGroupEnd, - Connectors, - getTypeName, - getCollectionName, - throwError, -} from 'meteor/vulcan:lib'; -import isEmpty from 'lodash/isEmpty'; -import get from 'lodash/get'; - -const defaultOptions = { - cacheMaxAge: 300, -}; - -// note: for some reason changing resolverOptions to "options" throws error -export function getDefaultResolvers(options) { - let typeName, collectionName, resolverOptions; - if (typeof arguments[0] === 'object') { - // new single-argument API - typeName = arguments[0].typeName; - collectionName = arguments[0].collectionName || getCollectionName(typeName); - resolverOptions = { ...defaultOptions, ...arguments[0].options }; - } else { - // OpenCRUD backwards compatibility - collectionName = arguments[0]; - typeName = getTypeName(collectionName); - resolverOptions = { ...defaultOptions, ...arguments[1] }; - } - - return { - // resolver for returning a list of documents based on a set of query terms - - multi: { - description: `A list of ${typeName} documents matching a set of query terms`, - - async resolver(root, { input = {} }, context, { cacheControl }) { - const { terms = {}, enableCache = false, enableTotal = true } = input; - - debug(''); - debugGroup( - `--------------- start \x1b[35m${typeName} Multi Resolver\x1b[0m ---------------` - ); - debug(`Options: ${JSON.stringify(resolverOptions)}`); - debug(`Terms: ${JSON.stringify(terms)}`); - - if (cacheControl && enableCache) { - const maxAge = resolverOptions.cacheMaxAge || defaultOptions.cacheMaxAge; - cacheControl.setCacheHint({ maxAge }); - } - - // get currentUser and Users collection from context - const { currentUser, Users } = context; - - // get collection based on collectionName argument - const collection = context[collectionName]; - - // get selector and options from terms and perform Mongo query - - let { selector, options, filteredFields } = isEmpty(terms) - ? Connectors.filter(collection, input, context) - : await collection.getParameters(terms, {}, context); - - // make sure all filtered fields are allowed - Users.checkFields(currentUser, collection, filteredFields); - - options.skip = terms.offset; - - debug({ selector, options }); - - const docs = await Connectors.find(collection, selector, options); - - let viewableDocs; - - // new API (Oct 2019) - const canRead = get(collection, 'options.permissions.canRead'); - if (canRead) { - if (typeof canRead === 'function') { - // if canRead is a function, use it to filter list of documents - viewableDocs = docs.filter(doc => canRead(currentUser, doc)); - } else if (Array.isArray(canRead)) { - if (canRead.includes('owners')) { - // if canReady array includes the owners group, test each document - // to see if it's owned by the current user - viewableDocs = docs.filter(doc => Users.isMemberOf(currentUser, canRead, doc)); - } else { - // else, we don't need a per-document check and just allow or disallow - // access to all documents at once - viewableDocs = Users.isMemberOf(currentUser, canRead) ? viewableDocs : []; - } - } - } else if (collection.checkAccess) { - // old API - // if collection has a checkAccess function defined, remove any documents that doesn't pass the check - viewableDocs = docs.filter(doc => collection.checkAccess(currentUser, doc)); - } else { - // default to allowing access to all documents - viewableDocs = docs; - } - - // take the remaining documents and remove any fields that shouldn't be accessible - const restrictedDocs = Users.restrictViewableFields(currentUser, collection, viewableDocs); - - // prime the cache - restrictedDocs.forEach(doc => collection.loader.prime(doc._id, doc)); - - debug(`\x1b[33m=> ${restrictedDocs.length} documents returned\x1b[0m`); - debugGroupEnd(); - debug(`--------------- end \x1b[35m${typeName} Multi Resolver\x1b[0m ---------------`); - debug(''); - - const data = { results: restrictedDocs }; - - if (enableTotal) { - // get total count of documents matching the selector - data.totalCount = await Connectors.count(collection, selector); - } else { - data.totalCount = null; - } - - // return results - return data; - }, - }, - - // resolver for returning a single document queried based on id or slug - - single: { - description: `A single ${typeName} document fetched by ID or slug`, - - async resolver(root, { input = {} }, context, { cacheControl }) { - const { selector: oldSelector = {}, enableCache = false, allowNull = false } = input; - - let doc; - - debug(''); - debugGroup( - `--------------- start \x1b[35m${typeName} Single Resolver\x1b[0m ---------------` - ); - debug(`Options: ${JSON.stringify(resolverOptions)}`); - debug(`Selector: ${JSON.stringify(oldSelector)}`); - - if (cacheControl && enableCache) { - const maxAge = resolverOptions.cacheMaxAge || defaultOptions.cacheMaxAge; - cacheControl.setCacheHint({ maxAge }); - } - - const { currentUser, Users } = context; - const collection = context[collectionName]; - - // use Dataloader if doc is selected by documentId/_id - const documentId = oldSelector.documentId || oldSelector._id; - const slug = oldSelector.slug; - - if (documentId) { - doc = await collection.loader.load(documentId); - } else if (slug) { - // make an exception for slug - doc = await Connectors.get(collection, { slug }); - } else { - let { selector, filteredFields } = Connectors.filter(collection, input, context); - - // make sure all filtered fields are allowed - Users.checkFields(currentUser, collection, filteredFields); - - doc = await Connectors.get(collection, selector); - } - - if (!doc) { - if (allowNull) { - return { result: null }; - } else { - throwError({ - id: 'app.missing_document', - data: { documentId, oldSelector }, - }); - } - } - - // if collection has a checkAccess function defined, use it to perform a check on the current document - // (will throw an error if check doesn't pass) - let canReadFunction; - - // new API (Oct 2019) - const canRead = get(collection, 'options.permissions.canRead'); - if (canRead) { - if (typeof canRead === 'function') { - // if canRead is a function, use it to check current document - canReadFunction = canRead; - } else if (Array.isArray(canRead)) { - // else if it's an array of groups, check if current user belongs to them - // for the current document - canReadFunction = (currentUser, doc) => Users.isMemberOf(currentUser, canRead, doc); - } - } else if (collection.checkAccess) { - // old API - canReadFunction = collection.checkAccess; - } else { - // default to allowing access to all documents - canReadFunction = () => true; - } - - Utils.performCheck( - canReadFunction, - currentUser, - doc, - collection, - documentId, - `${typeName}.read.single`, - collectionName - ); - - const restrictedDoc = Users.restrictViewableFields(currentUser, collection, doc); - - debugGroupEnd(); - debug(`--------------- end \x1b[35m${typeName} Single Resolver\x1b[0m ---------------`); - debug(''); - - // filter out disallowed properties and return resulting document - return { result: restrictedDoc }; - }, - }, - }; -} +// /* + +// Default list, single, and total resolvers + +// */ + +// import { +// Utils, +// debug, +// debugGroup, +// debugGroupEnd, +// Connectors, +// getTypeName, +// getCollectionName, +// throwError, +// } from 'meteor/vulcan:lib'; +// import isEmpty from 'lodash/isEmpty'; +// import get from 'lodash/get'; + +// const defaultOptions = { +// cacheMaxAge: 300, +// }; + +// // note: for some reason changing resolverOptions to "options" throws error +// export function getDefaultResolvers(options) { +// let typeName, collectionName, resolverOptions; +// if (typeof arguments[0] === 'object') { +// // new single-argument API +// typeName = arguments[0].typeName; +// collectionName = arguments[0].collectionName || getCollectionName(typeName); +// resolverOptions = { ...defaultOptions, ...arguments[0].options }; +// } else { +// // OpenCRUD backwards compatibility +// collectionName = arguments[0]; +// typeName = getTypeName(collectionName); +// resolverOptions = { ...defaultOptions, ...arguments[1] }; +// } + +// return { +// // resolver for returning a list of documents based on a set of query terms + +// multi: { +// description: `A list of ${typeName} documents matching a set of query terms`, + +// async resolver(root, { input = {} }, context, { cacheControl }) { +// const { terms = {}, enableCache = false, enableTotal = true } = input; + +// debug(''); +// debugGroup( +// `--------------- start \x1b[35m${typeName} Multi Resolver\x1b[0m ---------------` +// ); +// debug(`Options: ${JSON.stringify(resolverOptions)}`); +// debug(`Terms: ${JSON.stringify(terms)}`); + +// if (cacheControl && enableCache) { +// const maxAge = resolverOptions.cacheMaxAge || defaultOptions.cacheMaxAge; +// cacheControl.setCacheHint({ maxAge }); +// } + +// // get currentUser and Users collection from context +// const { currentUser, Users } = context; + +// // get collection based on collectionName argument +// const collection = context[collectionName]; + +// // get selector and options from terms and perform Mongo query + +// let { selector, options, filteredFields } = isEmpty(terms) +// ? Connectors.filter(collection, input, context) +// : await collection.getParameters(terms, {}, context); + +// // make sure all filtered fields are allowed +// Users.checkFields(currentUser, collection, filteredFields); + +// options.skip = terms.offset; + +// debug({ selector, options }); + +// const docs = await Connectors.find(collection, selector, options); + +// let viewableDocs; + +// // new API (Oct 2019) +// const canRead = get(collection, 'options.permissions.canRead'); +// if (canRead) { +// if (typeof canRead === 'function') { +// // if canRead is a function, use it to filter list of documents +// viewableDocs = docs.filter(doc => canRead(currentUser, doc)); +// } else if (Array.isArray(canRead)) { +// if (canRead.includes('owners')) { +// // if canReady array includes the owners group, test each document +// // to see if it's owned by the current user +// viewableDocs = docs.filter(doc => Users.isMemberOf(currentUser, canRead, doc)); +// } else { +// // else, we don't need a per-document check and just allow or disallow +// // access to all documents at once +// viewableDocs = Users.isMemberOf(currentUser, canRead) ? viewableDocs : []; +// } +// } +// } else if (collection.checkAccess) { +// // old API +// // if collection has a checkAccess function defined, remove any documents that doesn't pass the check +// viewableDocs = docs.filter(doc => collection.checkAccess(currentUser, doc)); +// } else { +// // default to allowing access to all documents +// viewableDocs = docs; +// } + +// // take the remaining documents and remove any fields that shouldn't be accessible +// const restrictedDocs = Users.restrictViewableFields(currentUser, collection, viewableDocs); + +// // prime the cache +// restrictedDocs.forEach(doc => collection.loader.prime(doc._id, doc)); + +// debug(`\x1b[33m=> ${restrictedDocs.length} documents returned\x1b[0m`); +// debugGroupEnd(); +// debug(`--------------- end \x1b[35m${typeName} Multi Resolver\x1b[0m ---------------`); +// debug(''); + +// const data = { results: restrictedDocs }; + +// if (enableTotal) { +// // get total count of documents matching the selector +// data.totalCount = await Connectors.count(collection, selector); +// } else { +// data.totalCount = null; +// } + +// // return results +// return data; +// }, +// }, + +// // resolver for returning a single document queried based on id or slug + +// single: { +// description: `A single ${typeName} document fetched by ID or slug`, + +// async resolver(root, { input = {} }, context, { cacheControl }) { +// const { selector: oldSelector = {}, enableCache = false, allowNull = false } = input; + +// let doc; + +// debug(''); +// debugGroup( +// `--------------- start \x1b[35m${typeName} Single Resolver\x1b[0m ---------------` +// ); +// debug(`Options: ${JSON.stringify(resolverOptions)}`); +// debug(`Selector: ${JSON.stringify(oldSelector)}`); + +// if (cacheControl && enableCache) { +// const maxAge = resolverOptions.cacheMaxAge || defaultOptions.cacheMaxAge; +// cacheControl.setCacheHint({ maxAge }); +// } + +// const { currentUser, Users } = context; +// const collection = context[collectionName]; + +// // use Dataloader if doc is selected by documentId/_id +// const documentId = oldSelector.documentId || oldSelector._id; +// const slug = oldSelector.slug; + +// if (documentId) { +// doc = await collection.loader.load(documentId); +// } else if (slug) { +// // make an exception for slug +// doc = await Connectors.get(collection, { slug }); +// } else { +// let { selector, filteredFields } = Connectors.filter(collection, input, context); + +// // make sure all filtered fields are allowed +// Users.checkFields(currentUser, collection, filteredFields); + +// doc = await Connectors.get(collection, selector); +// } + +// if (!doc) { +// if (allowNull) { +// return { result: null }; +// } else { +// throwError({ +// id: 'app.missing_document', +// data: { documentId, oldSelector }, +// }); +// } +// } + +// // if collection has a checkAccess function defined, use it to perform a check on the current document +// // (will throw an error if check doesn't pass) +// let canReadFunction; + +// // new API (Oct 2019) +// const canRead = get(collection, 'options.permissions.canRead'); +// if (canRead) { +// if (typeof canRead === 'function') { +// // if canRead is a function, use it to check current document +// canReadFunction = canRead; +// } else if (Array.isArray(canRead)) { +// // else if it's an array of groups, check if current user belongs to them +// // for the current document +// canReadFunction = (currentUser, doc) => Users.isMemberOf(currentUser, canRead, doc); +// } +// } else if (collection.checkAccess) { +// // old API +// canReadFunction = collection.checkAccess; +// } else { +// // default to allowing access to all documents +// canReadFunction = () => true; +// } + +// Utils.performCheck( +// canReadFunction, +// currentUser, +// doc, +// collection, +// documentId, +// `${typeName}.read.single`, +// collectionName +// ); + +// const restrictedDoc = Users.restrictViewableFields(currentUser, collection, doc); + +// debugGroupEnd(); +// debug(`--------------- end \x1b[35m${typeName} Single Resolver\x1b[0m ---------------`); +// debug(''); + +// // filter out disallowed properties and return resulting document +// return { result: restrictedDoc }; +// }, +// }, +// }; +// } diff --git a/packages/vulcan-core/lib/modules/index.js b/packages/vulcan-core/lib/modules/index.js index 0484426451..1d9aa123dd 100644 --- a/packages/vulcan-core/lib/modules/index.js +++ b/packages/vulcan-core/lib/modules/index.js @@ -2,13 +2,10 @@ // import and re-export export * from 'meteor/vulcan:lib'; -export * from './default_mutations.js'; -export * from './default_resolvers.js'; - export * from './components.js'; export { default as App } from './components/App.jsx'; -export { default as Datatable } from './components/Datatable/index.js'; +export { default as Datatable } from './components/datatable/index.js'; export { default as Dummy } from './components/Dummy.jsx'; export { default as DynamicLoading } from './components/DynamicLoading.jsx'; export { default as Error404 } from './components/Error404.jsx'; diff --git a/packages/vulcan-lib/lib/client/main.js b/packages/vulcan-lib/lib/client/main.js index 2d9ea56393..8f3d79ff17 100644 --- a/packages/vulcan-lib/lib/client/main.js +++ b/packages/vulcan-lib/lib/client/main.js @@ -8,5 +8,5 @@ export * from './apollo-client'; // createCollection, resolvers and mutations mocks // avoid warnings when building with webpack export * from './connectors'; -export * from './mutators'; +export * from './mock'; export * from './errors'; \ No newline at end of file diff --git a/packages/vulcan-lib/lib/client/mock.js b/packages/vulcan-lib/lib/client/mock.js new file mode 100644 index 0000000000..deb035978f --- /dev/null +++ b/packages/vulcan-lib/lib/client/mock.js @@ -0,0 +1,8 @@ +// mock mutators +export const createMutator = null; +export const updateMutator = null; +export const deleteMutator = null; + +// mock default mutations and resolvers +export const getDefaultResolvers = () => ({}); +export const getDefaultMutations = () => ({}); \ No newline at end of file diff --git a/packages/vulcan-lib/lib/client/mutators.js b/packages/vulcan-lib/lib/client/mutators.js deleted file mode 100644 index 08434e66a0..0000000000 --- a/packages/vulcan-lib/lib/client/mutators.js +++ /dev/null @@ -1,4 +0,0 @@ -// mock mutators -export const createMutator = null; -export const updateMutator = null; -export const deleteMutator = null; \ No newline at end of file diff --git a/packages/vulcan-lib/lib/server/default_mutations.js b/packages/vulcan-lib/lib/server/default_mutations.js new file mode 100644 index 0000000000..fbff99d0ad --- /dev/null +++ b/packages/vulcan-lib/lib/server/default_mutations.js @@ -0,0 +1,455 @@ +/* + +Default mutations + +*/ + +import { registerCallback } from '../modules/callbacks.js'; +import { createMutator, updateMutator, deleteMutator } from './mutators.js'; +import { Utils } from '../modules/utils.js'; +import { Connectors } from './connectors.js'; +import { getTypeName, getCollection, getCollectionName } from '../modules/collections.js'; +import isEmpty from 'lodash/isEmpty'; +import get from 'lodash/get'; + +const defaultOptions = { create: true, update: true, upsert: true, delete: true }; + +const getCreateMutationName = typeName => `create${typeName}`; +const getUpdateMutationName = typeName => `update${typeName}`; +const getDeleteMutationName = typeName => `delete${typeName}`; +const getUpsertMutationName = typeName => `upsert${typeName}`; +//const getMultiQueryName = (typeName) => `multi${typeName}Query`; + +export function getDefaultMutations(options) { + let typeName, collectionName, mutationOptions; + + if (typeof arguments[0] === 'object') { + // new single-argument API + typeName = arguments[0].typeName; + collectionName = arguments[0].collectionName || getCollectionName(typeName); + mutationOptions = { ...defaultOptions, ...arguments[0].options }; + } else { + // OpenCRUD backwards compatibility + collectionName = arguments[0]; + typeName = getTypeName(collectionName); + mutationOptions = { ...defaultOptions, ...arguments[1] }; + } + + // register callbacks for documentation purposes + registerCollectionCallbacks(typeName, mutationOptions); + + const mutations = {}; + + if (mutationOptions.create) { + // mutation for inserting a new document + + const mutationName = getCreateMutationName(typeName); + + const createMutation = { + description: `Mutation for creating new ${typeName} documents`, + name: mutationName, + + // check function called on a user to see if they can perform the operation + check(user, document, context) { + + const { Users } = context; + + // new API + const permissionsCheck = get(getCollection(collectionName), 'options.permissions.canCreate'); + if (typeof permissionsCheck === 'function') { + return permissionsCheck(user, document); + } else if (Array.isArray(permissionsCheck)) { + return Users.isMemberOf(user, permissionsCheck, document); + } + + // OpenCRUD backwards compatibility + const check = mutationOptions.createCheck || mutationOptions.newCheck; + if (check) { + return check(user, document); + } + + // check if they can perform "foo.new" operation (e.g. "movie.new") + // OpenCRUD backwards compatibility + return Users.canDo(user, [ + `${typeName.toLowerCase()}.create`, + `${collectionName.toLowerCase()}.new`, + ]); + }, + + async mutation(root, { data }, context) { + const collection = context[collectionName]; + + // check if current user can pass check function; else throw error + Utils.performCheck( + this.check, + context.currentUser, + data, + '', + `${typeName}.create`, + collectionName + ); + + // pass document to boilerplate newMutator function + return await createMutator({ + collection, + data, + currentUser: context.currentUser, + validate: true, + context, + }); + }, + }; + mutations.create = createMutation; + // OpenCRUD backwards compatibility + mutations.new = createMutation; + } + + if (mutationOptions.update) { + // mutation for editing a specific document + + const mutationName = getUpdateMutationName(typeName); + + const updateMutation = { + description: `Mutation for updating a ${typeName} document`, + name: mutationName, + + // check function called on a user and document to see if they can perform the operation + check(user, document, context) { + + const { Users } = context; + + // new API + const permissionsCheck = get(getCollection(collectionName), 'options.permissions.canUpdate'); + if (typeof permissionsCheck === 'function') { + return permissionsCheck(user, document); + } else if (Array.isArray(permissionsCheck)) { + return Users.isMemberOf(user, permissionsCheck, document); + } + + // OpenCRUD backwards compatibility + const check = mutationOptions.updateCheck || mutationOptions.editCheck; + if (check) { + return check(user, document); + } + + if (!user || !document) return false; + // check if user owns the document being edited. + // if they do, check if they can perform "foo.edit.own" action + // if they don't, check if they can perform "foo.edit.all" action + // OpenCRUD backwards compatibility + return Users.owns(user, document) + ? Users.canDo(user, [ + `${typeName.toLowerCase()}.update.own`, + `${collectionName.toLowerCase()}.edit.own`, + ]) + : Users.canDo(user, [ + `${typeName.toLowerCase()}.update.all`, + `${collectionName.toLowerCase()}.edit.all`, + ]); + }, + + async mutation(root, { where, selector: oldSelector, data }, context) { + const collection = context[collectionName]; + + // handle both `where` and `selector` for backwards-compatibility + let selector; + if (!isEmpty(where)) { + const filterParameters = Connectors.filter(collection, { where }, context); + selector = filterParameters.selector; + } else { + if (!isEmpty(oldSelector)) { + selector = oldSelector; + } else { + throw new Error('Selector cannot be empty'); + } + } + + // get entire unmodified document from database + const document = await Connectors.get(collection, selector); + + if (!document) { + throw new Error( + `Could not find document to update for selector: ${JSON.stringify(selector)}` + ); + } + + // check if user can perform operation; if not throw error + Utils.performCheck( + this.check, + context.currentUser, + document, + context, + `${typeName}.update`, + collectionName + ); + + // call editMutator boilerplate function + return await updateMutator({ + collection, + selector, + data, + currentUser: context.currentUser, + validate: true, + context, + document, + }); + }, + }; + + mutations.update = updateMutation; + // OpenCRUD backwards compatibility + mutations.edit = updateMutation; + } + if (mutationOptions.upsert) { + // mutation for upserting a specific document + const mutationName = getUpsertMutationName(typeName); + mutations.upsert = { + description: `Mutation for upserting a ${typeName} document`, + name: mutationName, + + async mutation(root, { where, selector, data }, context) { + const collection = context[collectionName]; + + // check if documeet exists already + const existingDocument = await Connectors.get(collection, selector, { + fields: { _id: 1 }, + }); + + if (existingDocument) { + return await collection.options.mutations.update.mutation( + root, + { where, selector, data }, + context + ); + } else { + return await collection.options.mutations.create.mutation(root, { data }, context); + } + }, + }; + } + + if (mutationOptions.delete) { + // mutation for removing a specific document (same checks as edit mutation) + + const mutationName = getDeleteMutationName(typeName); + + const deleteMutation = { + description: `Mutation for deleting a ${typeName} document`, + name: mutationName, + + check(user, document, context) { + + const { Users } = context; + + // new API + const permissionsCheck = get(getCollection(collectionName), 'options.permissions.canDelete'); + if (typeof permissionsCheck === 'function') { + return permissionsCheck(user, document); + } else if (Array.isArray(permissionsCheck)) { + return Users.isMemberOf(user, permissionsCheck, document); + } + + // OpenCRUD backwards compatibility + const check = mutationOptions.deleteCheck || mutationOptions.removeCheck; + if (check) { + return check(user, document); + } + + if (!user || !document) return false; + // OpenCRUD backwards compatibility + return Users.owns(user, document) + ? Users.canDo(user, [ + `${typeName.toLowerCase()}.delete.own`, + `${collectionName.toLowerCase()}.remove.own`, + ]) + : Users.canDo(user, [ + `${typeName.toLowerCase()}.delete.all`, + `${collectionName.toLowerCase()}.remove.all`, + ]); + }, + + async mutation(root, { where, selector: oldSelector }, context) { + const collection = context[collectionName]; + + // handle both `where` and `selector` for backwards-compatibility + let selector; + if (!isEmpty(where)) { + const filterParameters = Connectors.filter(collection, { where }, context); + selector = filterParameters.selector; + } else { + if (!isEmpty(oldSelector)) { + selector = oldSelector; + } else { + throw new Error('Selector cannot be empty'); + } + } + + const document = await Connectors.get(collection, selector); + + if (!document) { + throw new Error( + `Could not find document to delete for selector: ${JSON.stringify(selector)}` + ); + } + + Utils.performCheck( + this.check, + context.currentUser, + document, + context, + document._id, + `${typeName}.delete`, + collectionName + ); + + return await deleteMutator({ + collection, + selector: { _id: document._id }, + currentUser: context.currentUser, + validate: true, + context, + document, + }); + }, + }; + + mutations.delete = deleteMutation; + // OpenCRUD backwards compatibility + mutations.remove = deleteMutation; + } + + return mutations; +} + +const registerCollectionCallbacks = (typeName, options) => { + typeName = typeName.toLowerCase(); + + if (options.create) { + registerCallback({ + name: `${typeName}.create.validate`, + iterator: { validationErrors: 'An array that can be used to accumulate validation errors' }, + properties: [ + { document: 'The document being inserted' }, + { currentUser: 'The current user' }, + { collection: 'The collection the document belongs to' }, + { context: 'The context of the mutation' }, + ], + runs: 'sync', + returns: 'document', + description: + 'Validate a document before insertion (can be skipped when inserting directly on server).', + }); + registerCallback({ + name: `${typeName}.create.before`, + iterator: { document: 'The document being inserted' }, + properties: [{ currentUser: 'The current user' }], + runs: 'sync', + returns: 'document', + description: "Perform operations on a new document before it's inserted in the database.", + }); + registerCallback({ + name: `${typeName}.create.after`, + iterator: { document: 'The document being inserted' }, + properties: [{ currentUser: 'The current user' }], + runs: 'sync', + returns: 'document', + description: + "Perform operations on a new document after it's inserted in the database but *before* the mutation returns it.", + }); + registerCallback({ + name: `${typeName}.create.async`, + iterator: { document: 'The document being inserted' }, + properties: [ + { currentUser: 'The current user' }, + { collection: 'The collection the document belongs to' }, + ], + runs: 'async', + returns: null, + description: + "Perform operations on a new document after it's inserted in the database asynchronously.", + }); + } + if (options.update) { + registerCallback({ + name: `${typeName}.update.validate`, + iterator: { validationErrors: 'An object that can be used to accumulate validation errors' }, + properties: [ + { document: 'The document being edited' }, + { data: 'The client data' }, + { currentUser: 'The current user' }, + { collection: 'The collection the document belongs to' }, + { context: 'The context of the mutation' }, + ], + runs: 'sync', + returns: 'modifier', + description: + 'Validate a document before update (can be skipped when updating directly on server).', + }); + registerCallback({ + name: `${typeName}.update.before`, + iterator: { data: 'The client data' }, + properties: [{ document: 'The document being edited' }, { currentUser: 'The current user' }], + runs: 'sync', + returns: 'modifier', + description: "Perform operations on a document before it's updated in the database.", + }); + registerCallback({ + name: `${typeName}.update.after`, + iterator: { newDocument: 'The document after the update' }, + properties: [{ document: 'The document being edited' }, { currentUser: 'The current user' }], + runs: 'sync', + returns: 'document', + description: + "Perform operations on a document after it's updated in the database but *before* the mutation returns it.", + }); + registerCallback({ + name: `${typeName}.update.async`, + iterator: { newDocument: 'The document after the edit' }, + properties: [ + { document: 'The document before the edit' }, + { currentUser: 'The current user' }, + { collection: 'The collection the document belongs to' }, + ], + runs: 'async', + returns: null, + description: + "Perform operations on a document after it's updated in the database asynchronously.", + }); + } + if (options.delete) { + registerCallback({ + name: `${typeName}.delete.validate`, + iterator: { validationErrors: 'An object that can be used to accumulate validation errors' }, + properties: [ + { currentUser: 'The current user' }, + { document: 'The document being removed' }, + { collection: 'The collection the document belongs to' }, + { context: 'The context of this mutation' }, + ], + runs: 'sync', + returns: 'document', + description: + 'Validate a document before removal (can be skipped when removing directly on server).', + }); + registerCallback({ + name: `${typeName}.delete.before`, + iterator: { document: 'The document being removed' }, + properties: [{ currentUser: 'The current user' }], + runs: 'sync', + returns: null, + description: "Perform operations on a document before it's removed from the database.", + }); + registerCallback({ + name: `${typeName}.delete.async`, + properties: [ + { document: 'The document being removed' }, + { currentUser: 'The current user' }, + { collection: 'The collection the document belongs to' }, + ], + runs: 'async', + returns: null, + description: + "Perform operations on a document after it's removed from the database asynchronously.", + }); + } +}; diff --git a/packages/vulcan-lib/lib/server/default_resolvers.js b/packages/vulcan-lib/lib/server/default_resolvers.js new file mode 100644 index 0000000000..62ab53145f --- /dev/null +++ b/packages/vulcan-lib/lib/server/default_resolvers.js @@ -0,0 +1,227 @@ +/* + +Default list, single, and total resolvers + +*/ + +import { Utils } from '../modules/utils.js'; +import { debug, debugGroup, debugGroupEnd } from '../modules/debug.js'; +import { Connectors }from './connectors.js'; +import { getTypeName, getCollectionName } from '../modules/collections.js'; +import { throwError } from './errors.js'; +import isEmpty from 'lodash/isEmpty'; +import get from 'lodash/get'; + +const defaultOptions = { + cacheMaxAge: 300, +}; + +// note: for some reason changing resolverOptions to "options" throws error +export function getDefaultResolvers(options) { + let typeName, collectionName, resolverOptions; + if (typeof arguments[0] === 'object') { + // new single-argument API + typeName = arguments[0].typeName; + collectionName = arguments[0].collectionName || getCollectionName(typeName); + resolverOptions = { ...defaultOptions, ...arguments[0].options }; + } else { + // OpenCRUD backwards compatibility + collectionName = arguments[0]; + typeName = getTypeName(collectionName); + resolverOptions = { ...defaultOptions, ...arguments[1] }; + } + + return { + // resolver for returning a list of documents based on a set of query terms + + multi: { + description: `A list of ${typeName} documents matching a set of query terms`, + + async resolver(root, { input = {} }, context, { cacheControl }) { + const { terms = {}, enableCache = false, enableTotal = true } = input; + + debug(''); + debugGroup( + `--------------- start \x1b[35m${typeName} Multi Resolver\x1b[0m ---------------` + ); + debug(`Options: ${JSON.stringify(resolverOptions)}`); + debug(`Terms: ${JSON.stringify(terms)}`); + + if (cacheControl && enableCache) { + const maxAge = resolverOptions.cacheMaxAge || defaultOptions.cacheMaxAge; + cacheControl.setCacheHint({ maxAge }); + } + + // get currentUser and Users collection from context + const { currentUser, Users } = context; + + // get collection based on collectionName argument + const collection = context[collectionName]; + + // get selector and options from terms and perform Mongo query + + let { selector, options, filteredFields } = isEmpty(terms) + ? Connectors.filter(collection, input, context) + : await collection.getParameters(terms, {}, context); + + // make sure all filtered fields are allowed + Users.checkFields(currentUser, collection, filteredFields); + + options.skip = terms.offset; + + debug({ selector, options }); + + const docs = await Connectors.find(collection, selector, options); + + let viewableDocs; + + // new API (Oct 2019) + const canRead = get(collection, 'options.permissions.canRead'); + if (canRead) { + if (typeof canRead === 'function') { + // if canRead is a function, use it to filter list of documents + viewableDocs = docs.filter(doc => canRead(currentUser, doc)); + } else if (Array.isArray(canRead)) { + if (canRead.includes('owners')) { + // if canReady array includes the owners group, test each document + // to see if it's owned by the current user + viewableDocs = docs.filter(doc => Users.isMemberOf(currentUser, canRead, doc)); + } else { + // else, we don't need a per-document check and just allow or disallow + // access to all documents at once + viewableDocs = Users.isMemberOf(currentUser, canRead) ? viewableDocs : []; + } + } + } else if (collection.checkAccess) { + // old API + // if collection has a checkAccess function defined, remove any documents that doesn't pass the check + viewableDocs = docs.filter(doc => collection.checkAccess(currentUser, doc)); + } else { + // default to allowing access to all documents + viewableDocs = docs; + } + + // take the remaining documents and remove any fields that shouldn't be accessible + const restrictedDocs = Users.restrictViewableFields(currentUser, collection, viewableDocs); + + // prime the cache + restrictedDocs.forEach(doc => collection.loader.prime(doc._id, doc)); + + debug(`\x1b[33m=> ${restrictedDocs.length} documents returned\x1b[0m`); + debugGroupEnd(); + debug(`--------------- end \x1b[35m${typeName} Multi Resolver\x1b[0m ---------------`); + debug(''); + + const data = { results: restrictedDocs }; + + if (enableTotal) { + // get total count of documents matching the selector + data.totalCount = await Connectors.count(collection, selector); + } else { + data.totalCount = null; + } + + // return results + return data; + }, + }, + + // resolver for returning a single document queried based on id or slug + + single: { + description: `A single ${typeName} document fetched by ID or slug`, + + async resolver(root, { input = {} }, context, { cacheControl }) { + const { selector: oldSelector = {}, enableCache = false, allowNull = false } = input; + + let doc; + + debug(''); + debugGroup( + `--------------- start \x1b[35m${typeName} Single Resolver\x1b[0m ---------------` + ); + debug(`Options: ${JSON.stringify(resolverOptions)}`); + debug(`Selector: ${JSON.stringify(oldSelector)}`); + + if (cacheControl && enableCache) { + const maxAge = resolverOptions.cacheMaxAge || defaultOptions.cacheMaxAge; + cacheControl.setCacheHint({ maxAge }); + } + + const { currentUser, Users } = context; + const collection = context[collectionName]; + + // use Dataloader if doc is selected by documentId/_id + const documentId = oldSelector.documentId || oldSelector._id; + const slug = oldSelector.slug; + + if (documentId) { + doc = await collection.loader.load(documentId); + } else if (slug) { + // make an exception for slug + doc = await Connectors.get(collection, { slug }); + } else { + let { selector, filteredFields } = Connectors.filter(collection, input, context); + + // make sure all filtered fields are allowed + Users.checkFields(currentUser, collection, filteredFields); + + doc = await Connectors.get(collection, selector); + } + + if (!doc) { + if (allowNull) { + return { result: null }; + } else { + throwError({ + id: 'app.missing_document', + data: { documentId, oldSelector }, + }); + } + } + + // if collection has a checkAccess function defined, use it to perform a check on the current document + // (will throw an error if check doesn't pass) + let canReadFunction; + + // new API (Oct 2019) + const canRead = get(collection, 'options.permissions.canRead'); + if (canRead) { + if (typeof canRead === 'function') { + // if canRead is a function, use it to check current document + canReadFunction = canRead; + } else if (Array.isArray(canRead)) { + // else if it's an array of groups, check if current user belongs to them + // for the current document + canReadFunction = (currentUser, doc) => Users.isMemberOf(currentUser, canRead, doc); + } + } else if (collection.checkAccess) { + // old API + canReadFunction = collection.checkAccess; + } else { + // default to allowing access to all documents + canReadFunction = () => true; + } + + Utils.performCheck( + canReadFunction, + currentUser, + doc, + collection, + documentId, + `${typeName}.read.single`, + collectionName + ); + + const restrictedDoc = Users.restrictViewableFields(currentUser, collection, doc); + + debugGroupEnd(); + debug(`--------------- end \x1b[35m${typeName} Single Resolver\x1b[0m ---------------`); + debug(''); + + // filter out disallowed properties and return resulting document + return { result: restrictedDoc }; + }, + }, + }; +} diff --git a/packages/vulcan-lib/lib/server/main.js b/packages/vulcan-lib/lib/server/main.js index 0d88a8b137..53bfabaf59 100644 --- a/packages/vulcan-lib/lib/server/main.js +++ b/packages/vulcan-lib/lib/server/main.js @@ -10,6 +10,8 @@ export * from './query.js'; export * from '../modules/index.js'; export * from './mutators.js'; export * from './errors.js'; +export * from './default_resolvers.js'; +export * from './default_mutations.js'; // TODO: what to do with this? export * from './meteor_patch.js'; //export * from './render_context.js'; diff --git a/packages/vulcan-lib/lib/server/new_default_mutations.js b/packages/vulcan-lib/lib/server/new_default_mutations.js new file mode 100644 index 0000000000..4e85725cd4 --- /dev/null +++ b/packages/vulcan-lib/lib/server/new_default_mutations.js @@ -0,0 +1,185 @@ +/* + +Default mutations + +*/ + +import { createMutator, updateMutator, deleteMutator } from './mutators.js'; +import { Connectors } from './connectors.js'; +import { getCollectionName } from '../modules/collections.js'; +import get from 'lodash/get'; +import { throwError } from './errors.js'; + +const defaultOptions = { create: true, update: true, upsert: true, delete: true }; + +const getCreateMutationName = typeName => `create${typeName}`; +const getUpdateMutationName = typeName => `update${typeName}`; +const getDeleteMutationName = typeName => `delete${typeName}`; +const getUpsertMutationName = typeName => `upsert${typeName}`; + +/* + +Perform security check before calling mutators + +*/ +export const performMutationCheck = options => { + const { user, document, collection, context, operationName } = options; + const { Users } = context; + const documentId = document._id; + const permissionsCheck = get(collection, 'options.permissions.canCreate'); + let allowOperation = false; + + // 1. if no permission has been defined, throw error + if (!permissionsCheck) { + throwError({ id: 'app.no_permissions_defined', data: { documentId, operationName } }); + } + // 2. if no document is passed, throw error + if (!document) { + throwError({ id: 'app.document_not_found', data: { documentId, operationName } }); + } + + if (typeof permissionsCheck === 'function') { + allowOperation = permissionsCheck(options); + } else if (Array.isArray(permissionsCheck)) { + allowOperation = Users.isMemberOf(user, permissionsCheck, document); + } + + // 3. if permission check is defined but fails, disallow operation + if (!allowOperation) { + throwError({ id: 'app.operation_not_allowed', data: { documentId, operationName } }); + } +}; + +/* + +Default Mutations + +*/ +export function getNewDefaultMutations({ typeName, collectionName, options }) { + collectionName = collectionName || getCollectionName(typeName); + const mutationOptions = { ...defaultOptions, ...options }; + + const mutations = {}; + + if (mutationOptions.create) { + mutations.create = { + description: `Mutation for creating new ${typeName} documents`, + name: getCreateMutationName(typeName), + async mutation(root, { data }, context) { + const collection = context[collectionName]; + const { currentUser } = context; + + performMutationCheck({ + user: currentUser, + document: data, + collection, + context, + operationName: `${typeName}.create`, + }); + + return await createMutator({ + collection, + data, + currentUser: context.currentUser, + validate: true, + context, + }); + }, + }; + } + + if (mutationOptions.update) { + mutations.update = { + description: `Mutation for updating a ${typeName} document`, + name: getUpdateMutationName(typeName), + async mutation(root, { where, selector: oldSelector, data }, context) { + const { currentUser } = context; + const collection = context[collectionName]; + + // handle both `where` and `selector` for backwards-compatibility + const filterParameters = Connectors.filter(collection, { where }, context); + const selector = filterParameters.selector; + // get entire unmodified document from database + const document = await Connectors.get(collection, selector); + + performMutationCheck({ + user: currentUser, + document, + collection, + context, + operationName: `${typeName}.update`, + }); + + // call editMutator boilerplate function + return await updateMutator({ + collection, + selector, + data, + currentUser: context.currentUser, + validate: true, + context, + document, + }); + }, + }; + } + + if (mutationOptions.upsert) { + mutations.upsert = { + description: `Mutation for upserting a ${typeName} document`, + name: getUpsertMutationName(typeName), + async mutation(root, { where, selector, data }, context) { + const collection = context[collectionName]; + + // check if document exists already + const existingDocument = await Connectors.get(collection, selector, { + fields: { _id: 1 }, + }); + + if (existingDocument) { + return await collection.options.mutations.update.mutation( + root, + { where, selector, data }, + context + ); + } else { + return await collection.options.mutations.create.mutation(root, { data }, context); + } + }, + }; + } + + if (mutationOptions.delete) { + mutations.delete = { + description: `Mutation for deleting a ${typeName} document`, + name: getDeleteMutationName(typeName), + async mutation(root, { where, selector: oldSelector }, context) { + const { currentUser } = context; + const collection = context[collectionName]; + + const filterParameters = Connectors.filter(collection, { where }, context); + const selector = filterParameters.selector; + const document = await Connectors.get(collection, selector); + + performMutationCheck({ + user: currentUser, + document, + collection, + context, + operationName: `${typeName}.delete`, + }); + + return await deleteMutator({ + collection, + selector: { _id: document._id }, + currentUser: context.currentUser, + validate: true, + context, + document, + }); + }, + }; + } + + return mutations; +} diff --git a/packages/vulcan-lib/lib/server/new_default_resolvers.js b/packages/vulcan-lib/lib/server/new_default_resolvers.js new file mode 100644 index 0000000000..fcf5f548d7 --- /dev/null +++ b/packages/vulcan-lib/lib/server/new_default_resolvers.js @@ -0,0 +1,193 @@ +/* + +Default list, single, and total resolvers + +*/ + +import { debug, debugGroup, debugGroupEnd } from '../modules/debug.js'; +import { Connectors } from './connectors.js'; +import { getCollectionName } from '../modules/collections.js'; +import { throwError } from './errors.js'; +import get from 'lodash/get'; + +const defaultOptions = { + cacheMaxAge: 300, +}; + +// note: for some reason changing resolverOptions to "options" throws error +export function getNewDefaultResolvers({ typeName, collectionName, options }) { + collectionName = collectionName || getCollectionName(typeName); + const resolverOptions = { ...defaultOptions, ...options }; + + return { + // resolver for returning a list of documents based on a set of query terms + + multi: { + description: `A list of ${typeName} documents matching a set of query terms`, + + async resolver(root, { input = {} }, context, { cacheControl }) { + const { terms = {}, enableCache = false, enableTotal = true } = input; + const operationName = `${typeName}.read.multi`; + + debug(''); + debugGroup( + `--------------- start \x1b[35m${typeName} Multi Resolver\x1b[0m ---------------` + ); + debug(`Options: ${JSON.stringify(resolverOptions)}`); + debug(`Terms: ${JSON.stringify(terms)}`); + + if (cacheControl && enableCache) { + const maxAge = resolverOptions.cacheMaxAge || defaultOptions.cacheMaxAge; + cacheControl.setCacheHint({ maxAge }); + } + + // get currentUser and Users collection from context + const { currentUser, Users } = context; + + // get collection based on collectionName argument + const collection = context[collectionName]; + + // get selector and options from terms and perform Mongo query + + let { selector, options, filteredFields } = Connectors.filter(collection, input, context); + + // make sure all filtered fields are allowed + Users.checkFields(currentUser, collection, filteredFields); + + options.skip = terms.offset; + + debug({ selector, options }); + + const docs = await Connectors.find(collection, selector, options); + + let viewableDocs; + + // new API (Oct 2019) + const canRead = get(collection, 'options.permissions.canRead'); + if (canRead) { + if (typeof canRead === 'function') { + // if canRead is a function, use it to filter list of documents + viewableDocs = docs.filter(doc => + canRead({ user: currentUser, document: doc, collection, context, operationName }) + ); + } else if (Array.isArray(canRead)) { + if (canRead.includes('owners')) { + // if canReady array includes the owners group, test each document + // to see if it's owned by the current user + viewableDocs = docs.filter(doc => Users.isMemberOf(currentUser, canRead, doc)); + } else { + // else, we don't need a per-document check and just allow or disallow + // access to all documents at once + viewableDocs = Users.isMemberOf(currentUser, canRead) ? viewableDocs : []; + } + } + } + + // take the remaining documents and remove any fields that shouldn't be accessible + const restrictedDocs = Users.restrictViewableFields(currentUser, collection, viewableDocs); + + // prime the cache + restrictedDocs.forEach(doc => collection.loader.prime(doc._id, doc)); + + debug(`\x1b[33m=> ${restrictedDocs.length} documents returned\x1b[0m`); + debugGroupEnd(); + debug(`--------------- end \x1b[35m${typeName} Multi Resolver\x1b[0m ---------------`); + debug(''); + + const data = { results: restrictedDocs }; + + if (enableTotal) { + // get total count of documents matching the selector + data.totalCount = await Connectors.count(collection, selector); + } else { + data.totalCount = null; + } + + // return results + return data; + }, + }, + + // resolver for returning a single document queried based on id or slug + + single: { + description: `A single ${typeName} document fetched by ID or slug`, + + async resolver(root, { input = {} }, context, { cacheControl }) { + const { selector: oldSelector = {}, enableCache = false, allowNull = false } = input; + const operationName = `${typeName}.read.single`; + const { _id } = input; + let doc; + + debug(''); + debugGroup( + `--------------- start \x1b[35m${typeName} Single Resolver\x1b[0m ---------------` + ); + debug(`Options: ${JSON.stringify(resolverOptions)}`); + debug(`Selector: ${JSON.stringify(oldSelector)}`); + + if (cacheControl && enableCache) { + const maxAge = resolverOptions.cacheMaxAge || defaultOptions.cacheMaxAge; + cacheControl.setCacheHint({ maxAge }); + } + + const { currentUser, Users } = context; + const collection = context[collectionName]; + + // use Dataloader if doc is selected by _id + if (_id) { + doc = await collection.loader.load(_id); + } else { + let { selector, options, filteredFields } = Connectors.filter(collection, input, context); + // make sure all filtered fields are allowed + Users.checkFields(currentUser, collection, filteredFields); + doc = await Connectors.get(collection, selector, options); + } + + if (!doc) { + if (allowNull) { + return { result: null }; + } else { + throwError({ + id: 'app.missing_document', + data: { documentId: doc._id, input }, + }); + } + } + + // new API (Oct 2019) + let canReadFunction; + const canRead = get(collection, 'options.permissions.canRead'); + if (canRead) { + if (typeof canRead === 'function') { + // if canRead is a function, use it to check current document + canReadFunction = canRead; + } else if (Array.isArray(canRead)) { + // else if it's an array of groups, check if current user belongs to them + // for the current document + canReadFunction = ({ user, document }) => Users.isMemberOf(user, canRead, document); + } + } else { + // default to allowing access to all documents + canReadFunction = () => true; + } + + if (!canReadFunction({ user: currentUser, document, collection, context, operationName })) { + throwError({ + id: 'app.operation_not_allowed', + data: { documentId: document._id, operationName }, + }); + } + + const restrictedDoc = Users.restrictViewableFields(currentUser, collection, doc); + + debugGroupEnd(); + debug(`--------------- end \x1b[35m${typeName} Single Resolver\x1b[0m ---------------`); + debug(''); + + // filter out disallowed properties and return resulting document + return { result: restrictedDoc }; + }, + }, + }; +}