UNPKG

modules-pack

Version:

JavaScript Modules for Modern Frontend & Backend Projects

398 lines (381 loc) 14.7 kB
import { CONFIG, CURRENCY, PERMISSION, PHONE, UPLOAD, VALIDATE } from 'modules-pack/variables' import { fileName, folderFrom, resolvePath } from 'modules-pack/variables/files' import mongoose from 'mongoose' import { phone } from 'react-ui-pack/inputs/normalizers' import { assertBackend, by, enumFrom, get, hasObjectValue, LANGUAGE, truncate } from 'utils-pack' import { toRgbaColor } from 'utils-pack/color' import { isContinuousNumberRanges, isNumeric, startEndFromNumberRanges, } from 'utils-pack/number' import { interpolateString, isPhoneNumber, toLowerCase, trimSpaces, } from 'utils-pack/string' import { Id as _Id, isId } from 'utils-pack/utility' import isEmailValidator from 'validator/es/lib/isEmail' import isURL from 'validator/es/lib/isURL' import * as d from './database' // initialize database automatically on import of this file import { _ } from './translations' const sideEffects = {d} assertBackend() /** * DATABASE HELPERS ============================================================ * ============================================================================= */ /** * TYPE OPTIONS ---------------------------------------------------------------- * @reference: https://mongoosejs.com/docs/schematypes.html */ export const Schema = mongoose.Schema export const Mixed = Schema.Types.Mixed export const ObjectId = Schema.Types.ObjectId export const index = true export const immutable = true export const lowercase = true export const uppercase = true export const required = true export const select = true export const trim = true export const unique = true export const isEmail = [isEmailValidator, 'Please enter a valid email address'] export const isColor = [toRgbaColor, 'Please enter a valid RGBA(A) Color'] export const Json = {type: Mixed, default: undefined} /** * TYPES ----------------------------------------------------------------------- */ // @Note: add `default: undefined` to easily declare fallback when destructuring instances, // else, Mongoose will default to empty of given type (a.k.a. default for objects will be empty `{}`) // @Note: For array of sub-documents, sub-documents cannot be declared with nested `type` object, only primitives. // This is because Mongoose only supports array of SchemaTypes or nested Schemas. // However, it's possible to declare array of objects without nested `type` object. // @see: https://mongoosejs.com/docs/schematypes.html#arrays // @Note: nested Schema is needed when: // - having getters/setters, // - `type` as nested object within array // @see: https://mongoosejs.com/docs/subdocs.html export const Id = {type: String, validate: isId, default: () => _Id()} export const Ids = {type: [Id], default: undefined} export const Timestamp = {type: Number, validate: isNumeric, default: undefined} export const URL = {type: String, validate: isURL, default: undefined} export const About = {type: String, maxLength: VALIDATE.ABOUT_MAX_LENGTH, default: undefined} export const Address = {type: String, maxLength: VALIDATE.ADDRESS_MAX_LENGTH, default: undefined} export const Action = {type: {type: String}, payload: Json, meta: Json, error: Boolean, _id: false} export const Actions = {type: [Action], default: undefined} export const ActionHistory = {type: [Action], set: val => val.sort(by('-meta.time')), default: undefined} export const Color = {type: [Number], validate: isColor, default: undefined} export const CurrencySymbol = {type: String, enum: enumFrom(CURRENCY), default: undefined} export const Email = {type: String, validate: isEmail, set: toLowerCase} export const LanguageCode = {type: String, enum: enumFrom(LANGUAGE), default: undefined} // @note: when iterating array of Mongoose sub-documents (nested Schemas), need to explicitly destruct all props // example: files.filter(({kind, i, id}) => fileId({kind, i, id}) !== fileID) const file = { kind: Mixed, i: Mixed, id: {...Id, default: undefined}, name: {type: String, set: v => truncate(v, VALIDATE.FILE_NAME_MAX_LENGTH, 7)}, // filename gets sanitized during upload creatorId: {...Id, default: undefined}, // cannot use foreign key here because we don't know model type created: Timestamp, updated: Timestamp, _id: false, // File src for frontend can be stored in db, or computed dynamically src: { type: String, get (v) { if (v != null) return v const folder = folderFrom(this.parent()) return resolvePath({folder, filename: fileName(this), workDir: UPLOAD.DIR}).path } }, } export const FileType = new Schema({...file}, {timestamps: false, default: undefined}) export const Files = {type: [FileType], default: undefined} export const Img = new Schema({ ...file, // to reduce network payload, compute different image sizes in frontend width: Number, height: Number, sizes: { type: [{ key: Mixed, // file name suffix (resolution key) val: Number, // file size in bytes _id: false, }], default: undefined, }, }, {timestamps: false, default: undefined}) export const Imgs = {type: [Img], default: undefined} export const Location = new Schema({ lat: {type: Number, required}, lng: {type: Number, required}, accuracy: Number, timestamp: Timestamp, _id: false, }, {timestamps: false, default: undefined}) export const Name = {type: String, maxLength: VALIDATE.NAME_MAX_LENGTH, set: trimSpaces, default: undefined} export const KeyVal = { key: String, val: Mixed, _id: false, } export const Pay = new Schema({ currency: CurrencySymbol, interval: Timestamp, min: Number, max: Number, total: Number, _id: false, // @IMPORTANT! // default: undefined - cannot be undefined to prevent 500 errors when required // However, `default: {}` does not work either because Mongo does not save empty {}. // Solution is to prevent model.save() with this field being required and empty. }, {timestamps: false, default: undefined}) export const Permissions = { type: enumFrom(PERMISSION).reduce((o, k) => ({...o, [k]: Boolean}), {_id: false}), default: undefined } export const Point = {type: {lat: Number, lng: Number, _id: false}, default: undefined} export const Phones = new Schema({ ...enumFrom(PHONE).reduce((o, k) => ({ ...o, [k]: {type: String, validate: isPhoneNumber, set: (v) => phone(v).replace(/[^\d]+$/g, '')} }), {}), _id: false, }, {timestamps: false, default: undefined}) export const TimeRange = {from: Timestamp, to: Timestamp, _id: false} export const TimeRanges = { type: [TimeRange], validate: { validator: (input) => isContinuousNumberRanges(input.toObject()), message: props => interpolateString(_.timeRanges_MUST_BE_A_LIST_OF_CONTINUOUS_TIMESTAMP_RANGES, {timeRanges: props.value}) }, set (times) { const {start, end} = startEndFromNumberRanges(times) this.start = start this.end = end return times }, default: undefined, } /** * VALUES ---------------------------------------------------------------------- */ export const allPermissions = enumFrom(PERMISSION).reduce((o, k) => ({...o, [k]: true}), {_id: false}) /** * HELPERS --------------------------------------------------------------------- */ /** * Localised String Fields Definition for MongoDB * - Requires createModel() setup and @localised decorator for GQL Resolver. * - Each defined field then becomes virtual getter/setter for currently active language. * - Virtual fields fallback to default language, or to the first value found among translated values. * - You can also set Localised String object directly for multiple languages. * * @example: * const schema = { * _id: Id, * ...Localised({ * about: String, * name: {maxLength: VALIDATE.NAME_MAX_LENGTH, required}, * }) * } * export const Model = createModel(MODEL, schema) * => Results in below schema if LocalString has `en` and `ru` languages activated * >>> schema: { * _id: Id, * _: { * type: { * _id: false, * about: { * _id: false, * en: String, * ru: String, * }, * name: { * type: { * _id: false, * en: {type: String, maxLength: VALIDATE.NAME_MAX_LENGTH}, * ru: {type: String, maxLength: VALIDATE.NAME_MAX_LENGTH}, * }, * required, * } * }, * required, * }, * // about - becomes virtual getter/setter * // name - becomes virtual getter/setter * } * * @param {Object} fields - to be localised, each can be either a String, or Object type definition used in Mongoose * @param {Object} [LocalString] - nested key/value pairs of activated languages by their ISO code * @returns {{_: Object}} fields - defined under `_` property of the schema, and virtuals are to be set by createModel() */ export function Localised (fields, LocalString) { if (!LocalString) { LocalString = {} enumFrom(CONFIG.LANGUAGE_OPTIONS).forEach(key => LocalString[key] = String) } const result = {_id: false} // `_id: false` because Mongoose 6 converts nested path to SubDoc let isRequired for (const key in fields) { const field = fields[key] const isObj = hasObjectValue(field) if (!isObj && field !== String) throw new Error(`${Localised.name}.${key} can only be String or plain Object with value, not ${field}`) if (isObj) { // Pass declared field options to activated language codes const {required, ...options} = field const localString = hasObjectValue(options) ? {type: String, ...options} : String if (required) { isRequired = true result[key] = {type: {_id: false}, required} for (const lang in LocalString) { result[key].type[lang] = localString } } else { result[key] = {_id: false} for (const lang in LocalString) { result[key][lang] = localString } } } else { result[key] = {_id: false, ...LocalString} } } // Then set declared fields as virtuals inside createModel()... return { _: isRequired ? {type: result, required} : result } } Localised.path = function (field, lang) { return `_.${field}.${lang}` // must use dot notation to work as MongoDB filter } /** * Foreign Id Type Creator for Mongoose Model Field (automatically verifies integrity) * * @note: unlike ForeignKey() function, this does not include the field in foreignKeys() list for population. * @example: * eventSchema = new Schema({ * hostId: ForeignId('User') * }) * * @param {String} modelName - that the foreign key belongs to * @param {Object} [options] - extra field attributes * @returns {Object} type - Id String field that validates itself for the given model name */ export function ForeignId (modelName, options) { return { type: String, validate: { validator: input => mongoose.model(modelName).findById(input), message: props => interpolateString(_.INVALID_modelName_ID_id, {modelName, id: props.value}) }, ...options, } } /** * Foreign Key Type Creator for Mongoose Model Field (automatically verifies integrity) * * @note: Mongoose does not validate that the foreign key points to existing document by default * @example: * eventSchema = new Schema({ * creator: ForeignKey('User') * }) * * @param {String} modelName - that the foreign key belongs to * @param {Object} [options] - extra field attributes * @returns {Object} type - foreign key as ObjectId referencing the given model name */ export function ForeignKey (modelName, options) { // noinspection JSUnresolvedFunction return { type: String, // @note: type can only be primitive, ObjectId or Mixed, see @Note at the top ref: modelName, validate: { validator: input => mongoose.model(modelName).findById(input), message: props => interpolateString(_.INVALID_modelName_ID_id, {modelName, id: props.value}) }, ...options, } } /** * Dynamic Foreign Key Type Creator for Mongoose Model Field (automatically verifies integrity) * * @example: * const eventSchema = { * host: ForeignDynamicKey('hostType', {required}), // entity that owns the Event (i.e. Company or User) * hostType: {type: String, enum: [USER, COMPANY], required, default: USER}, * } * * @param {String} refField - to get model name reference for the foreign key * @param {Object} [options] - extra field attributes * @returns {Object} type - foreign key as ObjectId referencing the dynamic model name */ export function ForeignDynamicKey (refField, options) { return { type: String, refPath: refField, validate: { validator (input) { // noinspection JSUnresolvedFunction return mongoose.model(get(this, refField)).findById(input) }, message: props => interpolateString(_.INVALID_modelName_ID_id, {modelName: get(this, refField), id: props.value}) }, ...options, } } /** * Convert String ID to ObjectId * * @param {String} id - to convert * @return {Object} ID */ export function toObjectId (id) { return mongoose.Types.ObjectId(id) } /** * Check if given value is a MongoDb Object ID * * @param {*} value - to check * @returns {Boolean} true - if it is */ export function isObjectId (value) { return value instanceof mongoose.Types.ObjectId } /** * Check if given String is of valid MongoDb Object ID format * * @param {String} value - to check * @returns {Boolean} true - if it is */ export function isObjectID (value) { return /^[0-9a-fA-F]{24}$/.test(value) } /** * Foreign Key Type Creator with User and Permission levels * * @param {String} modelName - that the foreign key belongs to * @param {Object} [fields] - extra field attributes * @returns {Object} type - object referencing the given model name */ export function UserPermission (modelName, fields) { return { user: ForeignKey(modelName, {required}), permissions: {...Permissions, required}, _id: false, ...fields, } } /** * Map Javascript Constructor Type to Mongoose { field: { $type: <BSON type> } } * @see: https://docs.mongodb.com/manual/reference/operator/query/type/ * * @param {*} type - js constructor property * @returns {String} - Mongoose data type used for filtering */ export function toDatabaseType (type) { switch (type) { case Array: return 'array' case Object: return 'object' case Number: return 'double' case Boolean: return 'bool' case String: default: return 'string' } }