UNPKG

adaptorex

Version:

Connect all your live interactive storytelling devices and software

1,230 lines (1,103 loc) 39.5 kB
/** * * topics are the main features of each game. * * Each topic creates a connection between API and Database and allows to create, remove and modify elements. * * @requires express * @requires jsonschema * @requires query-types * * @module topic * @copyright Lasse Marburg 2021 * @license MIT */ const { EventEmitter } = require("events") const express = require("express") const jsonschema = require("jsonschema") const { MongoError } = require("mongodb") const queryTypes = require("query-types") /** @typedef {import('./mongo').Collection} DatabaseCollection */ /** * @typedef {Object} Issue - An expected error that did not break the original request but should nevertheless be reported * @property {string} name - The error types name * @property {string} message - The error message including (appended) causing error messages */ /** * Install routes for POST, GET, PUT, PATCH and DELETE requests. * * extended methods: * * - GET * Will create a GET endpoint both at this topics root / and /{entity} * * - EDIT * Allow updating an entity using update operators like set, remove, push, pull... * Creates a POST endpoint at /{entity}/{operator} * * - SET * define a property for each topic entity that can be updated via PUT using en extended path * */ class Topic { /** * @param {Object} setup - topic setup options * @param {string} setup.name - topic name * @param {Object} [setup.coll_name] - database collection name of database collection that will be associated with topic API * @param {DatabaseCollection} [setup.collection] - database collection object that will be associated with topic API * @param {Object} setup.schema - path, query and body schema to validate request data against * @param {Object} setup.default - a default entity object that is created on POST * @param {Object} setup.main_properties - list of properties that should be included in preview * @param {string[]} setup.methods - what methods to enable for this topic. EDIT enables update operators for POST requests * @param {boolean} setup.uniqueNames - restrict names to be unique in db collection * @param {boolean} setup.cast_query_types_default - If set to 'true' GET query strings are cast to number or bool if they can be converted. Can be prevented for each request with `_cast_types="off"` query parameter * @param {"data"|"plugin"} setup.data_type - Wether the items in the associated collection are of type "data" or "plugin" * @param {boolean} setup.has_archive - Wether to create an archive collection for the topic. Deleted documents will be moved to the archive collection. * @param {import('./game').Game} game - Game Interface of game that inherits the Topic */ constructor(setup, game) { Object.assign(this, setup) /** @type {import('./game').Game} */ this.game = game /** event emitter for add/remove/change updates * @type {events.EventEmitter} */ this.event = new EventEmitter() this.log = log.getContextLog(`${this.game.name} ${this.name} api`) this.schema.query = { type: "object", properties: { anyOf: this.schema.query } } this.default = setup.default this.router = express.Router({ mergeParams: true }) /** potential JS Object instances of collection documents */ this.items = [] this.writeProtectedProperties = [ "created_at", "created_by", "created_with", "sessions" ] this.validator = new jsonschema.Validator() /** stores functions that are called on specific operators for entities */ this.custom_operator = {} if (this.has_archive) { this.router.post("/:entity/_archive", (req, res) => { if (!this.valid(req.params.entity, this.schema.path, res, 400)) return this.archive(req.params.entity) .then((result) => { this.log.info(`${req.params.entity} has been moved to archive`) res.send(result) }) .catch((err) => { this.errorHandling(err, res) }) }) this.router.post("/:entity/_restore", (req, res) => { if (!this.valid(req.params.entity, this.schema.path, res, 400)) return this.restore(req.params.entity, req) .then((result) => { this.log.info(`Document ${result.created_id} has been restored`) res.send(result) }) .catch((err) => { this.errorHandling(err, res) }) }) } if (this.methods.includes("POST")) { this.router.post("/", (req, res) => { if (!this.valid(req.body, this.schema.body, res, 400)) return this.create(req.body, req) .then((result) => { res.status(201).send(result) }) .catch((err) => { this.errorHandling(err, res) }) }) } if (this.methods.includes("GET")) { this.router.get("/", (req, res) => { if (!this.valid(req.query, this.schema.query, res, 400)) return let options = this.getOptions(req) this.getMany(req.query, options) .then((result) => { if (result) { res.send(result) } else { res.status(404).send({ name: "NotFoundError", message: `no ${this.name} items found` }) } }) .catch((err) => { this.errorHandling(err, res) }) }) this.router.get("/:entity", (req, res) => { if (!this.valid(req.params.entity, this.schema.path, res, 400)) return let options = this.getOptions(req) this.get(req.params.entity, options) .then((result) => { if (result) { res.send(result) } else { res.status(404).send({ name: "NotFoundError", message: `${this.name} ${req.params.entity} was not found` }) } }) .catch((err) => { this.errorHandling(err, res) }) }) } if (this.methods.includes("PUT")) { this.router.put("/:entity", (req, res) => { if (!this.valid(req.params.entity, this.schema.path, res, 400)) return if (!this.valid(req.body, this.schema.body, res, 400)) return this.update(req.params.entity, req.body, req) .then((result) => { if (result) { res.send(result) } else { res.status(404).send({ name: "NotFoundError", message: `${this.name} ${req.params.entity} was not found` }) } }) .catch((err) => { this.errorHandling(err, res) }) }) } if (this.methods.includes("EDIT")) { this.router.post("/:entity/_:operator", (req, res) => { if (!this.valid(req.params.entity, this.schema.path, res, 400)) return if (!this.valid(req.body, this.schema.body, res, 400)) return this.edit(req.params.entity, req.params.operator, req.body, req) .then((result) => { if (result) { res.send(result) } else { res.status(404).send({ name: "NotFoundError", message: `${this.name} ${req.params.entity} was not found` }) } }) .catch((err) => { this.errorHandling(err, res) }) }) } if (this.methods.includes("SET")) { this.router.put("/:entity/:field", (req, res) => { this.log(req.body) if (!this.valid(req.params.entity, this.schema.path, res, 400)) return if ( !this.valid( req.body, this.schema.body["properties"][req.params.field], res, 400 ) ) return let query = {} query[req.params.field] = req.body this.edit(req.params.entity, "set", query, req) .then((result) => { if (result) { res.send(result) } else { res.status(404).send({ name: "NotFoundError", message: `${this.name} ${req.params.entity} was not found` }) } }) .catch((err) => { this.errorHandling(err, res) }) }) } if (this.methods.includes("PATCH")) { this.router.patch("/:entity", (req, res) => { if (!this.valid(req.params.entity, this.schema.path, res, 400)) return if (!this.valid(req.body, this.schema.body, res, 400)) return this.edit(req.params.entity, "set", req.body) .then((result) => { if (result) { res.send(result) } else { res.status(404).send({ name: "NotFoundError", message: `${this.name} ${req.params.entity} was not found` }) } }) .catch((err) => { this.errorHandling(err, res) }) }) } if (this.methods.includes("DELETE")) { this.router.delete("/:entity", (req, res) => { if (!this.valid(req.params.entity, this.schema.path, res, 400)) return this.delete(req.params.entity, req) .then((result) => { if (result) { this.log.info(`${req.params.entity} has been permanently deleted`) res.status(204).send() } else { res.status(404).send({ name: "NotFoundError", message: `${this.name} ${req.params.entity} was not found` }) } }) .catch((err) => { this.errorHandling(err, res) }) }) } } /** * Create database if it does not exist and add index fields and change listener * @returns {Promise} */ async setup() { if (!this.coll_name) { return } if (!this.game.db.hasOwnProperty(this.coll_name)) { await this.game.db.addCollection(this.coll_name) this.db_coll_created = true } /** @type {DatabaseCollection} */ this.collection = this.game.db[this.coll_name] if (this.uniqueNames) { this.collection.createIndex("name", true) } if (this.changes) { this.game.changes[this.name] = this.collection.changes({ fullDocument: "updateLookup" }) this.game.changes[this.name].on("change", this.documentChanges.bind(this)) } if (this.has_archive) { const archive_coll_name = this.coll_name + "_archive" if (!this.game.db.hasOwnProperty(archive_coll_name)) { await this.game.db.addCollection(archive_coll_name) } /** @type {DatabaseCollection} */ this.archive_collection = this.game.db[archive_coll_name] } } /** * Processes the given query object and returns an object with sort, limit, and skip options. * * If query has _cast_types set to true, it will typecast the query parameters in this request. * * @param {Object} req - The request object with query to process. * @return {Object} An object with sort, limit, and skip options. */ getOptions(req) { let options = {} if (typeof req.query._archived !== "undefined") { options["archived"] = req.query._archived === "true" delete req.query._archived } if (req.query._cast_types) { if (req.query._cast_types == "true") { req.query = queryTypes.parseObject(req.query) } delete req.query._cast_types } else if (this.cast_query_types_default) { req.query = queryTypes.parseObject(req.query) } if (req.query._sort_by) { options.sort = {} if (req.query._sort_direction) { options.sort[req.query._sort_by] = req.query._sort_direction } else { options.sort[req.query._sort_by] = 1 } delete req.query._sort_by } if (typeof req.query._sort_direction !== "undefined") { delete req.query._sort_direction } if (typeof req.query._limit !== "undefined") { options["limit"] = req.query._limit delete req.query._limit } if (typeof req.query._skip !== "undefined") { options["skip"] = req.query._skip delete req.query._skip } return options } addCustomOperator(operator, callback) { this.custom_operator[operator] = callback } /** * Validate incoming data against json schema. Respond to request if validation did fail. * * @param {*} val - value to validate against schema * @param {*} schema - validation schema * @param {*} res - express response object * @param {*} code - response code if validation fails * @returns {boolean} false if validation failed. true if validation succeeded */ valid(val, schema, res, code) { let validation = this.validator.validate(val, schema) if (!validation.valid) { this.log.error(validation.errors) res .status(code) .send({ name: validation.errors.name, message: validation.toString() }) return false } return true } /** * Make error specific response for AdaptorErrors. Otherwise, respond with 500 internal server error. * * @param {Error} err - error object that was caught * @param {Object} res - request response object * @returns undefined */ errorHandling(err, res) { this.log.error(err) if (err.cause) { err.message = err.message + "\n" + err.cause.message this.log.error(err.cause.message) } if (err instanceof adaptor.NotFoundError) { return res.status(404).send({ name: err.name, message: err.message }) } else if (err instanceof adaptor.AdaptorError) { return res.status(400).send({ name: err.name, message: err.message }) } else if ( (err instanceof MongoError && err.code == 11000) || err.errorType == "uniqueViolated" ) { return res .status(400) .send({ name: "DuplicateError", message: err.message }) } res .status(500) .send({ name: err.name, message: err.message, stack: err.stack }) } /** * Add "issue" {@link Issue} property to a request response based on an error that was caught. * The Error will be visible to the client but not indicate that the original operation failed * and it will not skip the rest of the operation. * * Only Errors that are known and do not cause unhandled follow up problems should be appended and * only instances of the AdaptorError Error will be appended as issue. * * @param {Error} err - the error that occurred during the request * @param {Object} response - response object the issue shall be appended to */ appendIssue(err, response) { if (!(err instanceof adaptor.AdaptorError)) { throw err } this.log.error(`Issue ${err.name}: ${err.message}`) if (!response.issues) { response.issues = [] } let issue = { name: err.name, message: err.message } if (err.cause) { issue.message = err.message + "\n" + err.cause.message this.log.error(err.cause.message) } response.issues.push(issue) } /** * Publish the current topics documents (preview) to connected clients if topic has a socket namespace. */ async publish() { if (this.client) { let preview = await this.preview() this.client.emit(this.name, preview) } } /** * Send a message to all connected clients but the ones the request originated from. * * Excluded clients and their socket ids should be provided in the request headers. * * @param {SocketIO.Namespace} client - the SocketIO namespace to send the message to * @param {string} topic - the topic to send the message on * @param {Object} data - the data to send * @param {Request} req - the express request that contains the excluded socket ids in its headers */ broadcast(client, topic, data, req) { if ( req.headers && typeof req.headers["x-socket-ids"] === "string" && req.headers["x-socket-ids"] ) { let excludedSockets = req.headers["x-socket-ids"] .split(",") .filter((id) => id) return client.except(excludedSockets).emit(topic, data) } client.emit(topic, data) } /** * Publish Changes in collection data. * * Skip changes of metadata like modified_at * * @param {Object} change - mongo (style) db change stream data */ documentChanges(change) { switch (change.operationType) { case "insert": if (change.fullDocument) { this.documentsChanged([change.fullDocument]) } case "replace": case "update": if (change.hasOwnProperty("updateDescription")) { if (change.updateDescription.hasOwnProperty("updatedFields")) { if ( change.updateDescription.updatedFields.modified_at || change.updateDescription.updatedFields.sessions ) { if (change.fullDocument) { this.documentsChanged([change.fullDocument]) } else { this.log.error( "Can not publish update event. Change update did not provide updated document. This may be due to a race condition for an update event on a just about deleted document" ) } } } } break case "delete": this.documentsRemoved([change.documentKey._id]) break } } documentsChanged(documents) { documents = documents.map((document) => { if (this.data_type) { document["type"] = this.data_type } if (this.plugin) { document["plugin"] = this.plugin } document["collection"] = this.name return document }) this.client.emit("changed", documents) } documentsRemoved(ids) { this.client.emit("removed", ids) } /** * add a document to topic collection. * Add default properties if they are not specified. * Append creation date and user. * * @param {Object} data - data object that will be inserted into collection * @param {Object} origin - caller metadata (e.g. express request object) * @param {string} origin.user.login - caller user login name * @param {string} origin.originalUrl - request URL * * @returns {Promise<object>} mongo id string of created document, created_at, created_by and created_with (version) information */ async create(data, origin) { if (data.hasOwnProperty("_id")) { delete data._id } for (let def in this.default) { if (!data.hasOwnProperty(def)) { data[def] = this.default[def] } } if (!data.created_at) { data["created_at"] = adaptor.now() } if (!data.modified_at) { data["modified_at"] = adaptor.now() } if (!data.created_by) { data["created_by"] = origin.user.login } if (!data.modified_by) { data["modified_by"] = origin.user.login } if (!data.created_with) { data["created_with"] = adaptor.info.version } if (!data.modified_with) { data["modified_with"] = adaptor.info.version } let insert = await this.collection.insert(data) this.publish() let return_val = { created_id: insert.insertedId, created_at: data.created_at, created_by: data.created_by, created_with: data.created_with } if (origin.originalUrl) { return_val["created_path"] = origin.originalUrl + "/" + insert.insertedId } return return_val } /** * find and return documents by name or id * * @param {string} id - unique identifier for requested document * @returns {Object} requested document. * * @throws {@link adaptor.NotFoundError} */ async get(id, options = {}) { if (this.has_archive && options.archived) { delete options.archived let docs = await this.archive_collection.find(this.getIndexQuery(id)) if (!docs.length) { throw new adaptor.NotFoundError( `${this.name} ${id} was not found in archive` ) } return docs[0] } let docs = await this.collection.find(this.getIndexQuery(id)) if (!docs.length) { throw new adaptor.NotFoundError(`${this.name} ${id} was not found`) } return docs[0] } /** * find and return documents by query * * @param {Object} query - find query that matches the requested documents * @param {Object} [options] - Additional query options * @param {Object<string,integer>} [options.sort] - order definition. Order by <key>: <direction> * @param {integer} [options.limit] - maximum number of documents * @param {integer} [options.skip] - return only from document n * @param {boolean} [options.archived] - return archived documents * @returns {Promise<array>} list of requested documents. Empty list if none where found */ async getMany(query, options = {}) { let docs = [] if (this.has_archive && options.archived) { delete options.archived docs = await this.archive_collection.find(query, options) } else { docs = await this.collection.find(query, options) } return docs } /** * Get a js item from this collection based on the item documents id. * The topic stores all items that have been requested at some point. * Items are a js representation of the collection document. * * @param {string} id - unique identifier for requested item * @returns {Promise<object>} - The requested item */ async getItem(id) { let document = await this.get(id) if (this.items[document._id]) { return this.items[document._id] } else { this.items[document._id] = document return this.items[document._id] } } /** * Find a js item from in this topics collection based on a mongo find query. * The topic stores all items that have been requested at some point. * Items are a js representation of the collection document that is updated on each request. * * @param {Object} query - mongodb find query * @returns {Promise<object>} - The requested item */ async queryItem(query) { let documents = await this.collection.find(query) if (!documents.length) { throw new adaptor.NotFoundError( `Could not find item. No document with ${JSON.stringify(query)} in ${this.name}` ) } if (this.items[documents[0]._id]) { return this.items[documents[0]._id] } else { this.items[documents[0]._id] = documents[0] return this.items[documents[0]._id] } } /** * Get list of a preview of all documents. * * @returns {Promise<array>} preview list of documents containing only most important fields */ async preview() { let projection = { created_by: 1, created_at: 1, created_with: 1, modified_by: 1, modified_at: 1, modified_with: 1 } for (let prop of this.main_properties) { projection[prop] = 1 } return this.collection.find({}, { project: projection }) } /** * @typedef {Object} Changes * @property {number} changed - amount of changed documents (0-n) * @property {Date} [modified_at] - date and time of modification * @property {string} [modified_by] - username or name of operation that initiated the update * @property {string} [modified_with] - version number of adaptor:ex server the update was made with */ /** * replace (overwrite) document with new values. * * @param {string} doc - document identifier, a query or a name or _id property * @param {Object} data - new document values * @param {Object} origin - caller metadata (e.g. express request object) * @param {string} origin.user.login - caller user login name * * @returns {Promise<Changes>} "changed:1" and modified_at, modified_by and modified_with if document has changed */ async update(doc, data, origin) { if (data.hasOwnProperty("_id")) { delete data._id } let query = this.getIndexQuery(doc) let original_docs = await this.collection.find(query) if (original_docs.length) { let original_doc = original_docs[0] if (data.hasOwnProperty("modified_at")) { data.modified_at = new Date(data.modified_at) } else { data.modified_at = 0 } if ( original_doc.hasOwnProperty("modified_at") && original_doc.modified_at > data.modified_at && !data._forceUpdate ) { throw new adaptor.OutdatedError( `Can not replace newer document ${original_doc._id} in ${this.collection.name}, last modified ${original_doc.modified_at}, with older version, last modified ${data.modified_at}.` ) } if (data._forceUpdate) { delete data._forceUpdate } for (let property of this.writeProtectedProperties) { if (original_doc.hasOwnProperty(property)) { data[property] = original_doc[property] } } } let response = await this.collection.replace(query, data) return this.reportChanges(response, query, origin.user) } /** * Change document * * Use operators to make specific updates. Maps to any mongo update operator: https://docs.mongodb.com/manual/reference/operator/update/ * remove = $unset * add = $addToSet * * @param {string} doc - document identifier, a query or a name or _id property * @param {string} operator - update operator set, remove, push or add * @param {Object} data - update values * @param {Object} origin - caller metadata (e.g. express request object) * @param {string} origin.user.login - caller user login name * * @returns {Promise<Changes>} contains modified information and changed is 1-n if one or more element have changed */ async edit(doc, operator, data, origin) { if (this.custom_operator.hasOwnProperty(operator)) { return await this.custom_operator[operator](doc, data, origin) } if (data.hasOwnProperty("_id")) { delete data._id } let updt = {} if (operator == "remove") { updt["$unset"] = data } else if (operator == "add") { updt["$addToSet"] = data } else { updt["$" + operator] = data } let query = this.getIndexQuery(doc) let response = await this.collection.update(query, updt) return this.reportChanges(response, query, origin.user) } /** * Change one document. The first document found based on the `doc` query will be edited. * * Use operators to make specific updates. Maps to any mongo update operator: https://docs.mongodb.com/manual/reference/operator/update/ * remove = $unset * add = $addToSet * * @param {string} doc - document identifier, a query or a name or _id property * @param {string} operator - update operator set, remove, push or add * @param {Object} data - update values * @param {Object} origin - caller metadata (e.g. express request object) * @param {string} origin.user.login - caller user login name * * @returns {Promise<Changes>} contains modified information and changed is 1-n if more than one element has changed */ async editOne(doc, operator, data, origin) { let query = this.getIndexQuery(doc) let document = await this.collection.find(query) if (document.length) { return this.edit({ _id: document[0]._id }, operator, data, origin) } } /** * Get query based on unique property like _id or name. * * @param {string|object} doc - document identifier, a query, or a name or _id property * @returns {Object} find query */ getIndexQuery(doc) { if (typeof doc === "string") { if (this.collection.validId(doc)) { return { $or: [{ _id: doc }, { name: doc }] } } else { return { name: doc } } } return doc } /** * create response object depending on update outcome * * @param {Object} updateResponse - information about update process * @param {number} updateResponse.matchedCount - amount of elements that where found and updated * @param {number} updateResponse.modifiedCount - amount of elements that changed during updated * @param {string} query - find query with identifier for element to apply update information * @param {Object} user - request user data * @param {Object} [changes={}] - meta change information that is appended to the document and included in the request return value * * @returns {Promise<Changes|undefined>} modification information and changed = <number of changed documents> (0 or more). Resolves undefined if document was not found */ async reportChanges(updateResponse, query, user, changes = {}) { if (updateResponse.matchedCount == 0) { return undefined } else if (updateResponse.modifiedCount == 0) { return { changed: 0 } } changes["modified_at"] = adaptor.now() changes["modified_by"] = user.login changes["modified_with"] = adaptor.info.version await this.collection.set(query, changes) changes["changed"] = updateResponse.modifiedCount changes.query = query return changes } /** * Permanently delete a document. Either from archive or from collection. * * @param {string} doc - document identifier, a query, or a name or _id property * @returns {Promise<string|undefined>} returns info about count of deleted documents * @throws {NotFoundError} If no document was found */ async delete(doc) { const query = this.getIndexQuery(doc) if (this.has_archive) { let response = await this.archive_collection.delete(query) if (response > 0) { return response + " document(s) permanently deleted from archive" } } let response = await this.collection.delete(query) this.publish() if (response == 0) { throw new adaptor.NotFoundError(`${this.name} ${doc} was not found`) } return response + " document(s) permanently deleted" } /** * Delete multiple documents from the collection or archive. * * @param {Object} query - find query that matches the requested documents * @param {Object} [options] - Additional query options * @param {boolean} [options.archived] - delete from archive * * @returns {Promise<string>} returns info about count of deleted documents */ async deleteMany(query, options) { let del_documents = await this.getMany(query, options) if (!del_documents.length) { return "0 document(s) permanently deleted" } let count if (del_documents[0].archived) { count = await this.archive_collection.delete({ _id: { $in: del_documents.map((d) => d._id) } }) } else { count = await this.collection.delete({ _id: { $in: del_documents.map((d) => d._id) } }) } return count + " document(s) permanently deleted" } /** * Move a document from the collection to the archive collection. * * @param {string} id - document identifier, a query or a name or _id property * * @returns {Promise<string>} returns count of archived documents or undefined if none was archived * @throws {NotFoundError} */ async archive(id) { let archive_doc = await this.get(id) archive_doc.archived = true archive_doc.archived_at = adaptor.now() await this.archive_collection.insert(archive_doc) let response = await this.collection.delete({ _id: archive_doc._id }) this.publish() if (response == 0) { throw new adaptor.NotFoundError(`${this.name} ${id} was not found`) } return response + " document(s) archived" } /** * Restore a document from archive. * * @param {string} doc - document identifier, a query or a name or _id property * @param {Object} origin - caller metadata (e.g. express request object) * @param {string} origin.user.login - caller user login name * @param {string} origin.originalUrl - request URL * * @returns {Promise<object>} mongo id string of created document, created_at, created_by and created_with (version) information */ async restore(doc, origin = {}) { let archive_doc = await this.get(doc, { archived: true }) delete archive_doc.archived delete archive_doc.archived_at let insert = await this.collection.insert(archive_doc) this.publish() let return_val = { created_id: insert.insertedId, created_at: archive_doc.created_at, created_by: archive_doc.created_by, created_with: archive_doc.created_with } if (origin.originalUrl) { return_val["created_path"] = origin.originalUrl.replace(/\/_restore$/, "") } await this.archive_collection.delete({ _id: archive_doc._id }) return return_val } logDocuments(options, append) { this.getMany({}, options).then((result) => { result.forEach((doc, i) => { log.info( `${i}`, `${doc.name} (${doc._id}) created ${Input.since(doc.created_at)}, modified ${Input.since(doc.modified_at)}${append ? ", " + append(doc) : ""}` ) }) }) } /** * Translate index to document _id based on the order inside the collection as it is returned by getMany. * * Returns input val if it is not a number * * @param {number|string} val - index or id as string or number * @returns {string|undefined} id string or undefined if index is out of range */ async getCLID(val, options = {}) { if (isNaN(val)) return val let docs = await this.getMany({}, options) if (Number(val) < docs.length) { return docs[Number(val)]._id } else { throw new adaptor.NotFoundError(`index ${val} not in range`) } } /** * remove all routes */ close() { let i = this.router.stack.length while (i--) { let routing = this.router.stack.splice(i, 1) if (routing[0].route) { this.log.trace( `Express route removed: ${Object.keys(routing[0].route.methods)[0]} ${routing[0].route.path}` ) } } this.log.debug("Topic closed") } /** * Processes command line inputs for this topic. * * Commands that start with the topic name are forwarded from game command function (`index.js -> game.js -> topic.js`) * * @param {string[]} input - command as array of strings */ command(input) { if (!input || input.length == 0) { this.logDocuments() return } switch (input[0]) { case "list": case "show": this.logDocuments() break case "get": case "doc": case "document": if (!input[1]) return this.log.info("name or _id required to get document") this.getCLID(input[1]) .then((res) => { this.get(res) .then((doc) => { this.log.info( `${doc.name} (${doc._id}) created ${Input.since(doc.created_at)}, modified ${Input.since(doc.modified_at)}` ) for (const [key, value] of Object.entries(doc)) { log.info("-", `${key}: ${value}`) } }) .catch((error) => this.log.error(error.message)) }) .catch((error) => this.log.error(error.message)) break case "count": this.getMany({}).then((result) => { this.log.info(`collection contains ${result.length} Documents`) }) break case "archive": if (this.has_archive) { if (input.length == 1) { this.logDocuments( { archived: true }, (doc) => `archived ${Input.since(doc.archived_at)}` ) } else if (input.length >= 2) { if (input[1] == "list") { this.logDocuments( { archived: true }, (doc) => `archived ${Input.since(doc.archived_at)}` ) } else if (input[1] == "count") { this.getMany({}, { archived: true }).then((result) => { this.log.info( `${this.name} has ${result.length} archived document(s)` ) }) } else if (input[1] == "delete") { if (!input[2]) { this.log.info("Please provide a document name or id to delete") return } this.getCLID(input[2], { archived: true }) .then((id) => { Input.confirmation("delete", `${id} from archive`, () => this.delete(input[2]) ).then((res) => this.log.info(res)) }) .catch((error) => this.log.error(error.message)) } else if (input[1] == "clear") { if (input[2]) { if (isNaN(input[2])) { this.log.info( "2nd argument must be a number (number of documents to keep)" ) return } return Input.confirmation( "delete", `all except the ${input[2]} latest documents from archive`, () => this.deleteMany( {}, { archived: true, sort: { created_at: -1 }, skip: input[2] } ) ).then((res) => this.log.info(res)) } Input.confirmation("delete", `all documents from archive`, () => this.deleteMany({}, { archived: true }) ).then((res) => this.log.info(res)) } else if (input[1] == "restore") { if (!input[2]) return this.log.info( "Please provide a document name or id to restore from archive" ) this.getCLID(input[2], { archived: true }) .then((id) => { return this.restore(id) }) .catch((error) => this.log.error(error.message)) } else { this.getCLID(input[1]) .then((id) => { if (id) { Input.confirmation("archive", id, () => this.archive(id)) } }) .catch((error) => this.log.error(error.message)) } } break } case "delete": this.getCLID(input[1]) .then((res) => { if (res) { Input.confirmation("delete", `${res} permanently`, () => this.delete(res) ).then((res) => this.log.info(res)) } }) .catch((error) => this.log.error(error.message)) break case "restore": if (!input[1]) return this.log.info( "Please provide a document name or id to restore from archive" ) this.getCLID(input[1], { archived: true }) .then((res) => { return this.restore(res) }) .catch((error) => this.log.error(error.message)) break default: this.log.info("No such command or document '" + input[0] + "'") break } } } module.exports.Topic = Topic