adaptorex
Version:
Connect all your live interactive storytelling devices and software
1,230 lines (1,103 loc) • 39.5 kB
JavaScript
/**
*
* 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