diff --git a/.eslintrc.js b/.eslintrc.js index e172b006..d39ef237 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -30,16 +30,6 @@ module.exports = { // we use named export in utils 'import/prefer-default-export': 'off', - // disable comma-dangle in functions - 'comma-dangle': [ - 'error', - { - arrays: 'always-multiline', - objects: 'always-multiline', - imports: 'always-multiline', - functions: 'never', - }, - ], // allow `console.error` & `console.warning` 'no-console': ['error', { allow: ['warn', 'error'] }], // `_id` comes from Mongo diff --git a/package-lock.json b/package-lock.json index 66972bf2..c367a721 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1201,6 +1201,47 @@ "find-up": "^2.1.0" } }, + "@hapi/address": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@hapi/address/-/address-2.0.0.tgz", + "integrity": "sha512-mV6T0IYqb0xL1UALPFplXYQmR0twnXG0M6jUswpquqT2sD12BOiCiLy3EvMp/Fy7s3DZElC4/aPjEjo2jeZpvw==" + }, + "@hapi/hoek": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-6.2.4.tgz", + "integrity": "sha512-HOJ20Kc93DkDVvjwHyHawPwPkX44sIrbXazAUDiUXaY2R9JwQGo2PhFfnQtdrsIe4igjG2fPgMra7NYw7qhy0A==" + }, + "@hapi/joi": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/@hapi/joi/-/joi-15.1.0.tgz", + "integrity": "sha512-n6kaRQO8S+kepUTbXL9O/UOL788Odqs38/VOfoCrATDtTvyfiO3fgjlSRaNkHabpTLgM7qru9ifqXlXbXk8SeQ==", + "requires": { + "@hapi/address": "2.x.x", + "@hapi/hoek": "6.x.x", + "@hapi/marker": "1.x.x", + "@hapi/topo": "3.x.x" + } + }, + "@hapi/marker": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@hapi/marker/-/marker-1.0.0.tgz", + "integrity": "sha512-JOfdekTXnJexfE8PyhZFyHvHjt81rBFSAbTIRAhF2vv/2Y1JzoKsGqxH/GpZJoF7aEfYok8JVcAHmSz1gkBieA==" + }, + "@hapi/topo": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-3.1.2.tgz", + "integrity": "sha512-r+aumOqJ5QbD6aLPJWqVjMAPsx5pZKz+F5yPqXZ/WWG9JTtHbQqlzrJoknJ0iJxLj9vlXtmpSdjlkszseeG8OA==", + "requires": { + "@hapi/hoek": "8.x.x" + }, + "dependencies": { + "@hapi/hoek": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-8.1.0.tgz", + "integrity": "sha512-b1J4jxYnW+n6lC91V6Pqg9imP9BZq0HNCeM+3sbXg05rQsE9cGYrKFpZjyztVesGmNRE6R+QaEoWGATeIiUVjA==" + } + } + }, "@marionebl/sander": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/@marionebl/sander/-/sander-0.6.1.tgz", @@ -7992,6 +8033,15 @@ "resolved": "https://registry.npmjs.org/joi-objectid/-/joi-objectid-2.0.0.tgz", "integrity": "sha1-VlSVc6Zrp5Xc9rniJt5fOy027Do=" }, + "joigoose": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/joigoose/-/joigoose-4.0.8.tgz", + "integrity": "sha512-FVid0+nWGVLKottxrjq8HlVzW4AAnu+9bq/E34LTQjzGRUcyaw63xSVf1UXeRqdlYoMG8WOipkBLQAvr42LPsA==", + "requires": { + "@hapi/hoek": "^6.2.1", + "@hapi/joi": "^15.0.0" + } + }, "js-levenshtein": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", diff --git a/package.json b/package.json index 91219bb6..1d05f1d4 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "http-status-codes": "^1.3.0", "joi": "^14.3.1", "joi-objectid": "^2.0.0", + "joigoose": "^4.0.8", "lodash": "^4.17.15", "mailchimp-api-v3": "^1.12.1", "md5": "^2.2.1", diff --git a/src/api/article/controller.js b/src/api/article/controller.js index 461a0b36..b3a3f574 100644 --- a/src/api/article/controller.js +++ b/src/api/article/controller.js @@ -149,7 +149,7 @@ export const update = async ({ params: { slugOrId }, body, user }, res, next) => locale, articleId, }, - mergeWithUpdateMetadata(localeData, user._id), + mergeWithUpdateMetadata(localeData, user), { new: true } ) // unless found, create a new one. @@ -174,7 +174,7 @@ export const update = async ({ params: { slugOrId }, body, user }, res, next) => localesToUpdate.map(_id => LocalizedArticle.findOneAndUpdate( { _id }, - mergeWithUpdateMetadata({ active: false }, user._id) + mergeWithUpdateMetadata({ active: false }, user) ).catch(next) ) ); @@ -207,7 +207,7 @@ export const update = async ({ params: { slugOrId }, body, user }, res, next) => export const remove = ({ params: { slugOrId }, user }, res, next) => retrieveArticleId(slugOrId, { active: true }) .then(articleId => - Article.updateOne({ _id: articleId }, mergeWithUpdateMetadata({ active: false }, user._id)) + Article.updateOne({ _id: articleId }, mergeWithUpdateMetadata({ active: false }, user)) ) .then(() => res.sendStatus(HttpStatus.OK)) .catch(next); diff --git a/src/api/article/localized/controller.js b/src/api/article/localized/controller.js index 4ddc4161..2424ef9c 100644 --- a/src/api/article/localized/controller.js +++ b/src/api/article/localized/controller.js @@ -35,7 +35,7 @@ export const create = async ({ params: { articleId }, user, body }, res, next) = }; export const update = ({ params: { slug }, user, body }, res, next) => - LocalizedArticle.findOneAndUpdate({ slug }, mergeWithUpdateMetadata(body, user._id), { + LocalizedArticle.findOneAndUpdate({ slug }, mergeWithUpdateMetadata(body, user), { new: true, }) .then(checkIsFound) diff --git a/src/api/helpers/metadata/index.js b/src/api/helpers/metadata/index.js index 4d6641ff..48034d66 100644 --- a/src/api/helpers/metadata/index.js +++ b/src/api/helpers/metadata/index.js @@ -3,6 +3,13 @@ import { getInitObjectMetadata, updateObjectMetadata, mergeWithUpdateMetadata, + joiMetadataSchema, } from './model'; -export { ObjectMetadata, getInitObjectMetadata, updateObjectMetadata, mergeWithUpdateMetadata }; +export { + ObjectMetadata, + getInitObjectMetadata, + updateObjectMetadata, + mergeWithUpdateMetadata, + joiMetadataSchema, +}; diff --git a/src/api/helpers/metadata/model.js b/src/api/helpers/metadata/model.js index e67997d0..46c83c6b 100644 --- a/src/api/helpers/metadata/model.js +++ b/src/api/helpers/metadata/model.js @@ -1,48 +1,36 @@ import mongoose from 'mongoose'; -const { Schema } = mongoose; +import { Joi, joiToMongoose, joiSchemas } from 'validation'; -// ObjectMetadataSchema model is a general-purpose object to be used across the project. -const ObjectMetadataSchema = new Schema({ - createdAt: { - type: Date, - required: true, - }, - createdBy: { - type: Schema.Types.ObjectId, - ref: 'User', - required: true, - }, - updatedAt: { - type: Date, - required: true, - }, - updatedBy: { - type: Schema.Types.ObjectId, - ref: 'User', - required: true, - }, +export const joiMetadataSchema = Joi.object({ + createdAt: Joi.date().required(), + updatedAt: Joi.date().required(), + createdBy: joiSchemas.userRef.required(), + updatedBy: joiSchemas.userRef.required(), }); +// ObjectMetadataSchema model is a general-purpose object to be used across the project. +const ObjectMetadataSchema = joiToMongoose(joiMetadataSchema); + export const ObjectMetadata = mongoose.model('ObjectMetadata', ObjectMetadataSchema); -export const getInitObjectMetadata = userId => ({ +export const getInitObjectMetadata = ({ _id }) => ({ createdAt: Date.now(), - createdBy: userId, + createdBy: _id, updatedAt: Date.now(), - updatedBy: userId, + updatedBy: _id, }); -export const updateObjectMetadata = (oldMetadata, userId) => ({ +export const updateObjectMetadata = (oldMetadata, { _id }) => ({ ...oldMetadata, updatedAt: Date.now(), - updatedBy: userId, + updatedBy: _id, }); -export const mergeWithUpdateMetadata = (data, userId) => ({ +export const mergeWithUpdateMetadata = (data, { _id }) => ({ $set: { ...data, - 'metadata.updatedBy': userId, + 'metadata.updatedBy': _id, 'metadata.updatedAt': Date.now(), }, }); diff --git a/src/api/storage/model.js b/src/api/storage/model.js index abb6f8bd..be7d18b3 100644 --- a/src/api/storage/model.js +++ b/src/api/storage/model.js @@ -1,41 +1,31 @@ import mongoose from 'mongoose'; import { - ObjectMetadata, + joiMetadataSchema, getInitObjectMetadata, mergeWithUpdateMetadata, } from 'api/helpers/metadata'; +import { Joi, joiToMongoose } from 'validation'; -const { Schema } = mongoose; - -const StorageEntitySchema = new Schema({ - key: { - type: String, - required: true, - unique: true, - }, - document: { - type: Schema.Types.Mixed, - required: true, - }, - accessPolicy: { - type: String, - enum: ['public'], - default: 'public', - required: true, - }, - metadata: { - type: ObjectMetadata.schema, - required: true, - }, +const joiStorageEntitySchema = Joi.object({ + key: Joi.string() + .required() + .meta({ unique: true }), + document: Joi.object().required(), + accessPolicy: Joi.string() + .valid('public') + .default('public'), + metadata: joiMetadataSchema.required(), }); +const StorageEntitySchema = joiToMongoose(joiStorageEntitySchema); + StorageEntitySchema.statics.getValue = function(key) { return this.findOne({ key }); }; -StorageEntitySchema.statics.setValue = function(key, value, userId) { - return this.findOneAndUpdate({ key }, mergeWithUpdateMetadata({ document: value }, userId), { +StorageEntitySchema.statics.setValue = function(key, value, _id) { + return this.findOneAndUpdate({ key }, mergeWithUpdateMetadata({ document: value }, { _id }), { new: true, }).then( entity => @@ -43,7 +33,7 @@ StorageEntitySchema.statics.setValue = function(key, value, userId) { this({ key, document: value, - metadata: getInitObjectMetadata(userId), + metadata: getInitObjectMetadata({ _id }), }).save() ); }; diff --git a/src/api/tag/model.js b/src/api/tag/model.js index deb9c944..92ad4f6d 100644 --- a/src/api/tag/model.js +++ b/src/api/tag/model.js @@ -1,37 +1,26 @@ import mongoose from 'mongoose'; import set from 'lodash/set'; -import Joi from 'joi'; - -import { ObjectMetadata } from 'api/helpers/metadata'; +import { joiMetadataSchema } from 'api/helpers/metadata'; import { Topic } from 'api/topic'; import { TAG_CONTENT_SCHEMA } from 'constants/topic'; import { ValidationError } from 'utils/validation'; - -const { Schema } = mongoose; - -const TagSchema = new Schema({ - topic: { - type: Schema.Types.ObjectId, - ref: 'Topic', - required: true, - }, - slug: { - type: String, - required: true, - unique: true, - }, - content: { - // Content depends on which Topic this Tag belongs to. - type: Schema.Types.Mixed, - required: true, - }, - metadata: { - type: ObjectMetadata.schema, - required: true, - }, +import { Joi, joiToMongoose } from 'validation'; + +const joiTagSchema = Joi.object({ + topic: Joi.string() + .meta({ type: 'ObjectId', ref: 'Topic' }) + .required(), + slug: Joi.string() + .required() + .meta({ unique: true }), + // content depends on which `Topic` this `Tag` belongs to. + content: Joi.object().required(), + metadata: joiMetadataSchema.required(), }); +const TagSchema = joiToMongoose(joiTagSchema); + TagSchema.pre('validate', async function(next) { const topic = await Topic.findOne({ _id: this.topic }); diff --git a/src/api/topic/model.js b/src/api/topic/model.js index bf7be01d..6be223c1 100644 --- a/src/api/topic/model.js +++ b/src/api/topic/model.js @@ -1,23 +1,19 @@ import mongoose from 'mongoose'; -import { ObjectMetadata } from 'api/helpers/metadata'; +import { joiMetadataSchema } from 'api/helpers/metadata'; +import { Joi, joiToMongoose } from 'validation'; import { TOPIC_SLUGS } from 'constants/topic'; -const { Schema } = mongoose; - -const TopicSchema = new Schema({ - slug: { - type: String, - required: true, - unique: true, - enum: TOPIC_SLUGS, - }, - metadata: { - type: ObjectMetadata.schema, - required: true, - }, +const joiTopicSchema = Joi.object({ + slug: Joi.string() + .valid(TOPIC_SLUGS) + .required() + .meta({ unique: true }), + metadata: joiMetadataSchema.required(), }); +const TopicSchema = joiToMongoose(joiTopicSchema); + TopicSchema.statics.getAll = function() { return this.find().select('slug'); }; diff --git a/src/constants/storage.js b/src/constants/storage.js index 6dfdbb28..f91dbea4 100644 --- a/src/constants/storage.js +++ b/src/constants/storage.js @@ -1,22 +1,16 @@ -import Joi from 'joi'; -import joiObjectId from 'joi-objectid'; - -Joi.objectId = joiObjectId(Joi); - -const DATA_SCHEMA = entities => - Joi.object().pattern(Joi.string().valid(entities), Joi.array().items(Joi.objectId())); +import { Joi } from 'validation'; export const MAIN_PAGE_KEY = 'main-page-key'; +export const SIDEBAR_KEY = 'sidebar-key'; // This is a list of Main Page Entities to be accepted by setMainPage() method. // Note: 'topics' are not accepted; get method always returns all topics available. // TODO: to add 'banners', 'diary'. const MAIN_PAGE_ENTITIES = ['articles', 'tags']; - -export const MAIN_PAGE_DATA_SCHEMA = DATA_SCHEMA(MAIN_PAGE_ENTITIES); - -export const SIDEBAR_KEY = 'sidebar-key'; - const SIDEBAR_ENTITIES = ['tags']; -export const SIDEBAR_DATA_SCHEMA = DATA_SCHEMA(SIDEBAR_ENTITIES); +const getDataSchema = entities => + Joi.object().pattern(Joi.string().valid(entities), Joi.array().items(Joi.objectId())); + +export const joiMainPageDataSchema = getDataSchema(MAIN_PAGE_ENTITIES); +export const joiSidebarDataSchema = getDataSchema(SIDEBAR_ENTITIES); diff --git a/src/constants/topic.js b/src/constants/topic.js index 9babbcd6..08aa70b4 100644 --- a/src/constants/topic.js +++ b/src/constants/topic.js @@ -1,43 +1,36 @@ -import Joi from 'joi'; +import { Joi, joiSchemas } from 'validation'; -const LOCALIZED_TEXT_SCHEMA_BE_REQUIRED = Joi.object().keys({ - be: Joi.string().required(), - en: Joi.string(), - ru: Joi.string(), -}); - -// Tag Content Schemas must be consistent with the data in our Google Spreadsheet. export const TAG_CONTENT_SCHEMA = { - locations: Joi.object().keys({ - title: LOCALIZED_TEXT_SCHEMA_BE_REQUIRED.required(), + locations: Joi.object({ + title: joiSchemas.localizedText.required(), image: Joi.string().required(), }), - themes: Joi.object().keys({ - title: LOCALIZED_TEXT_SCHEMA_BE_REQUIRED.required(), + themes: Joi.object({ + title: joiSchemas.localizedText.required(), }), - personalities: Joi.object().keys({ - name: LOCALIZED_TEXT_SCHEMA_BE_REQUIRED.required(), - dates: LOCALIZED_TEXT_SCHEMA_BE_REQUIRED.required(), + personalities: Joi.object({ + name: joiSchemas.localizedText.required(), + dates: joiSchemas.localizedText.required(), image: Joi.string().required(), color: Joi.string() .regex(/^[0-9a-fA-F]{6}$/) .required(), - description: LOCALIZED_TEXT_SCHEMA_BE_REQUIRED.required(), + description: joiSchemas.localizedText.required(), }), - times: Joi.object().keys({ - title: LOCALIZED_TEXT_SCHEMA_BE_REQUIRED, + times: Joi.object({ + title: joiSchemas.localizedText, }), - brands: Joi.object().keys({ - title: LOCALIZED_TEXT_SCHEMA_BE_REQUIRED.required(), - image: Joi.string().required(), + brands: Joi.object({ + title: joiSchemas.localizedText.required(), // TODO: to improve images validation. - }), - authors: Joi.object().keys({ - firstName: LOCALIZED_TEXT_SCHEMA_BE_REQUIRED.required(), - lastName: LOCALIZED_TEXT_SCHEMA_BE_REQUIRED.required(), - bio: LOCALIZED_TEXT_SCHEMA_BE_REQUIRED.required(), image: Joi.string().required(), + }), + authors: Joi.object({ + firstName: joiSchemas.localizedText.required(), + lastName: joiSchemas.localizedText.required(), + bio: joiSchemas.localizedText.required(), // TODO: to improve images validation. + image: Joi.string().required(), }), }; diff --git a/src/utils/validation.js b/src/utils/validation.js index bfd3e91c..d1305bf2 100644 --- a/src/utils/validation.js +++ b/src/utils/validation.js @@ -6,7 +6,7 @@ import set from 'lodash/set'; import mongoose from 'mongoose'; import Joi from 'joi'; -import { MAIN_PAGE_DATA_SCHEMA, SIDEBAR_DATA_SCHEMA } from 'constants/storage'; +import { joiMainPageDataSchema, joiSidebarDataSchema } from 'constants/storage'; export function ValidationError(message) { return HttpError(HttpStatus.BAD_REQUEST, message); @@ -79,14 +79,14 @@ const updateArticleValidator = ({ body }, res, next) => { }; export const checkMainPageEntitiesFormat = data => - Joi.validate(data, MAIN_PAGE_DATA_SCHEMA).error === null; + Joi.validate(data, joiMainPageDataSchema).error === null; const setMainPageValidator = ({ body }, res, next) => { const valid = checkMainPageEntitiesFormat(body.data); return next(!valid && new ValidationError({ mainPageEntities: 'not valid' })); }; -const checkSidebarEntitiesFormat = data => Joi.validate(data, SIDEBAR_DATA_SCHEMA).error === null; +const checkSidebarEntitiesFormat = data => Joi.validate(data, joiSidebarDataSchema).error === null; const setSidebarValidator = ({ body }, res, next) => { const valid = checkSidebarEntitiesFormat(body.data); @@ -173,6 +173,7 @@ export const permissionsObjectValidator = { message: 'errors.badPermissions', }; +// TODO: replace with `joiSchemas.color` export const colorValidator = { validator: v => /^[0-9a-fA-F]{6}$/.test(v), message: 'errors.failedMatchRegex', diff --git a/src/validation/index.js b/src/validation/index.js new file mode 100644 index 00000000..0b634afc --- /dev/null +++ b/src/validation/index.js @@ -0,0 +1,13 @@ +import mongoose from 'mongoose'; +import Joi from 'joi'; +import joiObjectId from 'joi-objectid'; +import getJoigoose from 'joigoose'; + +import joiSchemas from './schemas'; + +Joi.objectId = joiObjectId(Joi); + +const Joigoose = getJoigoose(mongoose); +const joiToMongoose = (model, options) => new mongoose.Schema(Joigoose.convert(model), options); + +export { Joi, joiToMongoose, joiSchemas }; diff --git a/src/validation/schemas.js b/src/validation/schemas.js new file mode 100644 index 00000000..74cafbc6 --- /dev/null +++ b/src/validation/schemas.js @@ -0,0 +1,15 @@ +import { Joi } from './index'; + +const schemas = {}; + +schemas.userRef = Joi.string().meta({ type: 'ObjectId', ref: 'User' }); + +schemas.localizedText = Joi.object({ + be: Joi.string().required(), + en: Joi.string(), + ru: Joi.string(), +}); + +schemas.color = Joi.string().regex(/^[0-9a-fA-F]{6}$/); + +export default schemas;