Move stuff into intro/ and advanced/
This commit is contained in:
171
advanced/server/services/api.service.js
Normal file
171
advanced/server/services/api.service.js
Normal file
@@ -0,0 +1,171 @@
|
||||
"use strict";
|
||||
|
||||
const _ = require("lodash");
|
||||
const ApiGateway = require("moleculer-web");
|
||||
const { UnAuthorizedError } = ApiGateway.Errors;
|
||||
|
||||
module.exports = {
|
||||
name: "api",
|
||||
mixins: [ApiGateway],
|
||||
|
||||
settings: {
|
||||
port: process.env.PORT || 3000,
|
||||
|
||||
routes: [{
|
||||
path: "/api",
|
||||
|
||||
authorization: true,
|
||||
|
||||
aliases: {
|
||||
// Login
|
||||
"POST /users/login": "users.login",
|
||||
|
||||
// Users
|
||||
"REST /users": "users",
|
||||
|
||||
// Current user
|
||||
"GET /user": "users.me",
|
||||
"PUT /user": "users.updateMyself",
|
||||
|
||||
// Articles
|
||||
"GET /articles/feed": "articles.feed",
|
||||
"REST /articles": "articles",
|
||||
"GET /tags": "articles.tags",
|
||||
|
||||
// Comments
|
||||
"GET /articles/:slug/comments": "articles.comments",
|
||||
"POST /articles/:slug/comments": "articles.addComment",
|
||||
"PUT /articles/:slug/comments/:commentID": "articles.updateComment",
|
||||
"DELETE /articles/:slug/comments/:commentID": "articles.removeComment",
|
||||
|
||||
// Favorites
|
||||
"POST /articles/:slug/favorite": "articles.favorite",
|
||||
"DELETE /articles/:slug/favorite": "articles.unfavorite",
|
||||
|
||||
// Profile
|
||||
"GET /profiles/:username": "users.profile",
|
||||
"POST /profiles/:username/follow": "users.follow",
|
||||
"DELETE /profiles/:username/follow": "users.unfollow",
|
||||
},
|
||||
|
||||
// Disable to call not-mapped actions
|
||||
mappingPolicy: "restrict",
|
||||
|
||||
// Set CORS headers
|
||||
cors: true,
|
||||
|
||||
// Parse body content
|
||||
bodyParsers: {
|
||||
json: {
|
||||
strict: false
|
||||
},
|
||||
urlencoded: {
|
||||
extended: false
|
||||
}
|
||||
}
|
||||
}],
|
||||
|
||||
assets: {
|
||||
folder: "./public"
|
||||
},
|
||||
|
||||
// logRequestParams: "info",
|
||||
// logResponseData: "info",
|
||||
|
||||
onError(req, res, err) {
|
||||
// Return with the error as JSON object
|
||||
res.setHeader("Content-type", "application/json; charset=utf-8");
|
||||
res.writeHead(err.code || 500);
|
||||
|
||||
if (err.code == 422) {
|
||||
let o = {};
|
||||
err.data.forEach(e => {
|
||||
let field = e.field.split(".").pop();
|
||||
o[field] = e.message;
|
||||
});
|
||||
|
||||
res.end(JSON.stringify({ errors: o }, null, 2));
|
||||
} else {
|
||||
const errObj = _.pick(err, ["name", "message", "code", "type", "data"]);
|
||||
res.end(JSON.stringify(errObj, null, 2));
|
||||
}
|
||||
this.logResponse(req, res, err? err.ctx : null);
|
||||
}
|
||||
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Authorize the request
|
||||
*
|
||||
* @param {Context} ctx
|
||||
* @param {Object} route
|
||||
* @param {IncomingRequest} req
|
||||
* @returns {Promise}
|
||||
*/
|
||||
authorize(ctx, route, req) {
|
||||
let token;
|
||||
if (req.headers.authorization) {
|
||||
let type = req.headers.authorization.split(" ")[0];
|
||||
if (type === "Token" || type === "Bearer")
|
||||
token = req.headers.authorization.split(" ")[1];
|
||||
}
|
||||
|
||||
return this.Promise.resolve(token)
|
||||
.then(token => {
|
||||
if (token) {
|
||||
// Verify JWT token
|
||||
return ctx.call("users.resolveToken", { token })
|
||||
.then(user => {
|
||||
if (user) {
|
||||
this.logger.info("Authenticated via JWT: ", user.username);
|
||||
// Reduce user fields (it will be transferred to other nodes)
|
||||
ctx.meta.user = _.pick(user, ["_id", "username", "email", "image"]);
|
||||
ctx.meta.token = token;
|
||||
}
|
||||
return user;
|
||||
})
|
||||
.catch(err => {
|
||||
// Ignored because we continue processing if user is not exist
|
||||
return null;
|
||||
});
|
||||
}
|
||||
})
|
||||
.then(user => {
|
||||
if (req.$endpoint.action.auth == "required" && !user)
|
||||
return this.Promise.reject(new UnAuthorizedError());
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Convert ValidationError to RealWorld.io result
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
* @param {*} err
|
||||
*/
|
||||
/*sendError(req, res, err) {
|
||||
if (err.code == 422) {
|
||||
res.setHeader("Content-type", "application/json; charset=utf-8");
|
||||
res.writeHead(422);
|
||||
let o = {};
|
||||
err.data.forEach(e => {
|
||||
let field = e.field.split(".").pop();
|
||||
o[field] = e.message;
|
||||
});
|
||||
return res.end(JSON.stringify({
|
||||
errors: o
|
||||
}, null, 2));
|
||||
|
||||
}
|
||||
|
||||
return this._sendError(req, res, err);
|
||||
}*/
|
||||
},
|
||||
|
||||
created() {
|
||||
// Pointer to the original function
|
||||
//this._sendError = ApiGateway.methods.sendError.bind(this);
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
614
advanced/server/services/articles.service.js
Normal file
614
advanced/server/services/articles.service.js
Normal file
@@ -0,0 +1,614 @@
|
||||
"use strict";
|
||||
|
||||
const { MoleculerClientError } = require("moleculer").Errors;
|
||||
const { ForbiddenError } = require("moleculer-web").Errors;
|
||||
|
||||
const _ = require("lodash");
|
||||
const slug = require("slug");
|
||||
const DbService = require("../mixins/db.mixin");
|
||||
|
||||
module.exports = {
|
||||
name: "articles",
|
||||
mixins: [DbService("articles")],
|
||||
|
||||
/**
|
||||
* Default settings
|
||||
*/
|
||||
settings: {
|
||||
fields: ["_id", "title", "slug", "description", "body", "tagList", "createdAt", "updatedAt", "favorited", "favoritesCount", "author", "comments"],
|
||||
|
||||
// Populates
|
||||
populates: {
|
||||
author: {
|
||||
action: "users.get",
|
||||
params: {
|
||||
fields: ["username", "bio", "image"]
|
||||
}
|
||||
},
|
||||
comments: {
|
||||
action: "comments.get",
|
||||
params: {
|
||||
fields: ["_id", "body", "author"],
|
||||
populates: ["author"]
|
||||
}
|
||||
},
|
||||
favorited(ids, articles, rule, ctx) {
|
||||
if (ctx.meta.user)
|
||||
return this.Promise.all(articles.map(article => ctx.call("favorites.has", { article: article._id.toString(), user: ctx.meta.user._id.toString() }).then(res => article.favorited = res)));
|
||||
else {
|
||||
articles.forEach(article => article.favorited = false);
|
||||
return this.Promise.resolve();
|
||||
}
|
||||
},
|
||||
favoritesCount(ids, articles, rule, ctx) {
|
||||
return this.Promise.all(articles.map(article => ctx.call("favorites.count", { article: article._id.toString() }).then(count => article.favoritesCount = count)));
|
||||
}
|
||||
},
|
||||
|
||||
// Validation schema for new entities
|
||||
entityValidator: {
|
||||
title: { type: "string", min: 1 },
|
||||
description: { type: "string", min: 1 },
|
||||
body: { type: "string", min: 1 },
|
||||
tagList: { type: "array", items: "string", optional: true },
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Actions
|
||||
*/
|
||||
actions: {
|
||||
|
||||
/**
|
||||
* Create a new article.
|
||||
* Auth is required!
|
||||
*
|
||||
* @actions
|
||||
* @param {Object} article - Article entity
|
||||
*
|
||||
* @returns {Object} Created entity
|
||||
*/
|
||||
create: {
|
||||
auth: "required",
|
||||
params: {
|
||||
article: { type: "object" }
|
||||
},
|
||||
handler(ctx) {
|
||||
let entity = ctx.params.article;
|
||||
return this.validateEntity(entity)
|
||||
.then(() => {
|
||||
|
||||
entity.slug = slug(entity.title, { lower: true }) + "-" + (Math.random() * Math.pow(36, 6) | 0).toString(36);
|
||||
entity.author = ctx.meta.user._id.toString();
|
||||
entity.createdAt = new Date();
|
||||
entity.updatedAt = new Date();
|
||||
|
||||
return this.adapter.insert(entity)
|
||||
.then(doc => this.transformDocuments(ctx, { populate: ["author", "favorited", "favoritesCount"]}, doc))
|
||||
.then(entity => this.transformResult(ctx, entity, ctx.meta.user))
|
||||
.then(json => this.entityChanged("created", json, ctx).then(() => json));
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update an article.
|
||||
* Auth is required!
|
||||
*
|
||||
* @actions
|
||||
* @param {String} id - Article ID
|
||||
* @param {Object} article - Article modified fields
|
||||
*
|
||||
* @returns {Object} Updated entity
|
||||
*/
|
||||
update: {
|
||||
auth: "required",
|
||||
params: {
|
||||
id: { type: "string" },
|
||||
article: { type: "object", props: {
|
||||
title: { type: "string", min: 1, optional: true },
|
||||
description: { type: "string", min: 1, optional: true },
|
||||
body: { type: "string", min: 1, optional: true },
|
||||
tagList: { type: "array", items: "string", optional: true },
|
||||
} }
|
||||
},
|
||||
handler(ctx) {
|
||||
let newData = ctx.params.article;
|
||||
newData.updatedAt = new Date();
|
||||
// the 'id' is the slug
|
||||
return this.Promise.resolve(ctx.params.id)
|
||||
.then(slug => this.findBySlug(slug))
|
||||
.then(article => {
|
||||
if (!article)
|
||||
return this.Promise.reject(new MoleculerClientError("Article not found", 404));
|
||||
|
||||
if (article.author !== ctx.meta.user._id.toString())
|
||||
return this.Promise.reject(new ForbiddenError());
|
||||
|
||||
const update = {
|
||||
"$set": newData
|
||||
};
|
||||
|
||||
return this.adapter.updateById(article._id, update);
|
||||
})
|
||||
.then(doc => this.transformDocuments(ctx, { populate: ["author", "favorited", "favoritesCount"]}, doc))
|
||||
.then(entity => this.transformResult(ctx, entity, ctx.meta.user))
|
||||
.then(json => this.entityChanged("updated", json, ctx).then(() => json));
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* List articles with pagination.
|
||||
*
|
||||
* @actions
|
||||
* @param {String} tag - Filter for 'tag'
|
||||
* @param {String} author - Filter for author ID
|
||||
* @param {String} favorited - Filter for favorited author
|
||||
* @param {Number} limit - Pagination limit
|
||||
* @param {Number} offset - Pagination offset
|
||||
*
|
||||
* @returns {Object} List of articles
|
||||
*/
|
||||
list: {
|
||||
cache: {
|
||||
keys: ["#token", "tag", "author", "favorited", "limit", "offset"]
|
||||
},
|
||||
params: {
|
||||
tag: { type: "string", optional: true },
|
||||
author: { type: "string", optional: true },
|
||||
favorited: { type: "string", optional: true },
|
||||
limit: { type: "number", optional: true, convert: true },
|
||||
offset: { type: "number", optional: true, convert: true },
|
||||
},
|
||||
handler(ctx) {
|
||||
const limit = ctx.params.limit ? Number(ctx.params.limit) : 20;
|
||||
const offset = ctx.params.offset ? Number(ctx.params.offset) : 0;
|
||||
|
||||
let params = {
|
||||
limit,
|
||||
offset,
|
||||
sort: ["-createdAt"],
|
||||
populate: ["author", "favorited", "favoritesCount"],
|
||||
query: {}
|
||||
};
|
||||
let countParams;
|
||||
|
||||
if (ctx.params.tag)
|
||||
params.query.tagList = {"$in" : [ctx.params.tag]};
|
||||
|
||||
return this.Promise.resolve()
|
||||
.then(() => {
|
||||
if (ctx.params.author) {
|
||||
return ctx.call("users.find", { query: { username: ctx.params.author } })
|
||||
.then(users => {
|
||||
if (users.length == 0)
|
||||
return this.Promise.reject(new MoleculerClientError("Author not found"));
|
||||
|
||||
params.query.author = users[0]._id;
|
||||
});
|
||||
}
|
||||
if (ctx.params.favorited) {
|
||||
return ctx.call("users.find", { query: { username: ctx.params.favorited } })
|
||||
.then(users => {
|
||||
if (users.length == 0)
|
||||
return this.Promise.reject(new MoleculerClientError("Author not found"));
|
||||
|
||||
return users[0]._id;
|
||||
})
|
||||
.then(user => {
|
||||
return ctx.call("favorites.find", { fields: ["article"], query: { user }})
|
||||
.then(list => {
|
||||
params.query._id = { $in: list.map(o => o.article) };
|
||||
});
|
||||
});
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
countParams = Object.assign({}, params);
|
||||
// Remove pagination params
|
||||
if (countParams && countParams.limit)
|
||||
countParams.limit = null;
|
||||
if (countParams && countParams.offset)
|
||||
countParams.offset = null;
|
||||
})
|
||||
.then(() => this.Promise.all([
|
||||
// Get rows
|
||||
this.adapter.find(params),
|
||||
|
||||
// Get count of all rows
|
||||
this.adapter.count(countParams)
|
||||
|
||||
])).then(res => {
|
||||
return this.transformDocuments(ctx, params, res[0])
|
||||
.then(docs => this.transformResult(ctx, docs, ctx.meta.user))
|
||||
.then(r => {
|
||||
r.articlesCount = res[1];
|
||||
return r;
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* List articles from followed authors.
|
||||
* Auth is required!
|
||||
*
|
||||
* @actions
|
||||
* @param {Number} limit - Pagination limit
|
||||
* @param {Number} offset - Pagination offset
|
||||
*
|
||||
* @returns {Object} List of articles
|
||||
*/
|
||||
feed: {
|
||||
auth: "required",
|
||||
cache: {
|
||||
keys: ["#token", "limit", "offset"]
|
||||
},
|
||||
params: {
|
||||
limit: { type: "number", optional: true, convert: true },
|
||||
offset: { type: "number", optional: true, convert: true },
|
||||
},
|
||||
handler(ctx) {
|
||||
const limit = ctx.params.limit ? Number(ctx.params.limit) : 20;
|
||||
const offset = ctx.params.offset ? Number(ctx.params.offset) : 0;
|
||||
|
||||
let params = {
|
||||
limit,
|
||||
offset,
|
||||
sort: ["-createdAt"],
|
||||
populate: ["author", "favorited", "favoritesCount"],
|
||||
query: {}
|
||||
};
|
||||
let countParams;
|
||||
|
||||
return this.Promise.resolve()
|
||||
.then(() => {
|
||||
return ctx.call("follows.find", { fields: ["follow"], query: { user: ctx.meta.user._id.toString() } })
|
||||
.then(list => {
|
||||
const authors = _.uniq(_.compact(_.flattenDeep(list.map(o => o.follow))));
|
||||
params.query.author = {"$in" : authors};
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
countParams = Object.assign({}, params);
|
||||
// Remove pagination params
|
||||
if (countParams && countParams.limit)
|
||||
countParams.limit = null;
|
||||
if (countParams && countParams.offset)
|
||||
countParams.offset = null;
|
||||
})
|
||||
.then(() => this.Promise.all([
|
||||
// Get rows
|
||||
this.adapter.find(params),
|
||||
|
||||
// Get count of all rows
|
||||
this.adapter.count(countParams)
|
||||
|
||||
])).then(res => {
|
||||
return this.transformDocuments(ctx, params, res[0])
|
||||
.then(docs => this.transformResult(ctx, docs, ctx.meta.user))
|
||||
.then(r => {
|
||||
r.articlesCount = res[1];
|
||||
return r;
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get an article by slug
|
||||
*
|
||||
* @actions
|
||||
* @param {String} id - Article slug
|
||||
*
|
||||
* @returns {Object} Article entity
|
||||
*/
|
||||
get: {
|
||||
cache: {
|
||||
keys: ["#token", "id"]
|
||||
},
|
||||
params: {
|
||||
id: { type: "string" }
|
||||
},
|
||||
handler(ctx) {
|
||||
return this.findBySlug(ctx.params.id)
|
||||
.then(entity => {
|
||||
if (!entity)
|
||||
return this.Promise.reject(new MoleculerClientError("Article not found!", 404));
|
||||
|
||||
return entity;
|
||||
})
|
||||
.then(doc => this.transformDocuments(ctx, { populate: ["author", "favorited", "favoritesCount"] }, doc))
|
||||
.then(entity => this.transformResult(ctx, entity, ctx.meta.user));
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove an article by slug
|
||||
* Auth is required!
|
||||
*
|
||||
* @actions
|
||||
* @param {String} id - Article slug
|
||||
*
|
||||
* @returns {Number} Count of removed articles
|
||||
*/
|
||||
remove: {
|
||||
auth: "required",
|
||||
params: {
|
||||
id: { type: "any" }
|
||||
},
|
||||
handler(ctx) {
|
||||
return this.findBySlug(ctx.params.id)
|
||||
.then(entity => {
|
||||
if (!entity)
|
||||
return this.Promise.reject(new MoleculerClientError("Article not found!", 404));
|
||||
|
||||
if (entity.author !== ctx.meta.user._id.toString())
|
||||
return this.Promise.reject(new ForbiddenError());
|
||||
|
||||
return this.adapter.removeById(entity._id)
|
||||
.then(() => ctx.call("favorites.removeByArticle", { article: entity._id }))
|
||||
.then(json => this.entityChanged("removed", json, ctx).then(() => json));
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Favorite an article
|
||||
* Auth is required!
|
||||
*
|
||||
* @actions
|
||||
* @param {String} id - Article slug
|
||||
*
|
||||
* @returns {Object} Updated article
|
||||
*/
|
||||
favorite: {
|
||||
auth: "required",
|
||||
params: {
|
||||
slug: { type: "string" }
|
||||
},
|
||||
handler(ctx) {
|
||||
return this.Promise.resolve(ctx.params.slug)
|
||||
.then(slug => this.findBySlug(slug))
|
||||
.then(article => {
|
||||
if (!article)
|
||||
return this.Promise.reject(new MoleculerClientError("Article not found", 404));
|
||||
|
||||
return ctx.call("favorites.add", { article: article._id.toString(), user: ctx.meta.user._id.toString() }).then(() => article);
|
||||
})
|
||||
.then(doc => this.transformDocuments(ctx, { populate: ["author", "favorited", "favoritesCount"] }, doc))
|
||||
.then(entity => this.transformResult(ctx, entity, ctx.meta.user));
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Unfavorite an article
|
||||
* Auth is required!
|
||||
*
|
||||
* @actions
|
||||
* @param {String} id - Article slug
|
||||
*
|
||||
* @returns {Object} Updated article
|
||||
*/
|
||||
unfavorite: {
|
||||
auth: "required",
|
||||
params: {
|
||||
slug: { type: "string" }
|
||||
},
|
||||
handler(ctx) {
|
||||
return this.Promise.resolve(ctx.params.slug)
|
||||
.then(slug => this.findBySlug(slug))
|
||||
.then(article => {
|
||||
if (!article)
|
||||
return this.Promise.reject(new MoleculerClientError("Article not found", 404));
|
||||
|
||||
return ctx.call("favorites.delete", { article: article._id.toString(), user: ctx.meta.user._id.toString() }).then(() => article);
|
||||
})
|
||||
.then(doc => this.transformDocuments(ctx, { populate: ["author", "favorited", "favoritesCount"] }, doc))
|
||||
.then(entity => this.transformResult(ctx, entity, ctx.meta.user));
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get list of available tags
|
||||
*
|
||||
* @returns {Object} Tag list
|
||||
*/
|
||||
tags: {
|
||||
cache: {
|
||||
keys: []
|
||||
},
|
||||
handler(ctx) {
|
||||
return this.Promise.resolve()
|
||||
.then(() => this.adapter.find({ fields: ["tagList"], sort: ["createdAt"] }))
|
||||
.then(list => {
|
||||
return _.uniq(_.compact(_.flattenDeep(list.map(o => o.tagList))));
|
||||
})
|
||||
.then(tags => ({ tags }));
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all comments of an article.
|
||||
*
|
||||
* @actions
|
||||
* @param {String} slug - Article slug
|
||||
*
|
||||
* @returns {Object} Comment list
|
||||
*
|
||||
*/
|
||||
comments: {
|
||||
cache: {
|
||||
keys: ["#token", "slug"]
|
||||
},
|
||||
params: {
|
||||
slug: { type: "string" }
|
||||
},
|
||||
handler(ctx) {
|
||||
return this.Promise.resolve(ctx.params.slug)
|
||||
.then(slug => this.findBySlug(slug))
|
||||
.then(article => {
|
||||
if (!article)
|
||||
return this.Promise.reject(new MoleculerClientError("Article not found", 404));
|
||||
|
||||
return ctx.call("comments.list", { article: article._id.toString() });
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Add a new comment to an article.
|
||||
* Auth is required!
|
||||
*
|
||||
* @actions
|
||||
* @param {String} slug - Article slug
|
||||
* @param {Object} comment - Comment fields
|
||||
*
|
||||
* @returns {Object} Comment entity
|
||||
*/
|
||||
addComment: {
|
||||
auth: "required",
|
||||
params: {
|
||||
slug: { type: "string" },
|
||||
comment: { type: "object" }
|
||||
},
|
||||
handler(ctx) {
|
||||
return this.Promise.resolve(ctx.params.slug)
|
||||
.then(slug => this.findBySlug(slug))
|
||||
.then(article => {
|
||||
if (!article)
|
||||
return this.Promise.reject(new MoleculerClientError("Article not found", 404));
|
||||
|
||||
return ctx.call("comments.create", { article: article._id.toString(), comment: ctx.params.comment });
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a comment.
|
||||
* Auth is required!
|
||||
*
|
||||
* @actions
|
||||
* @param {String} slug - Article slug
|
||||
* @param {String} commentID - Comment ID
|
||||
* @param {Object} comment - Comment fields
|
||||
*
|
||||
* @returns {Object} Comment entity
|
||||
*/
|
||||
updateComment: {
|
||||
auth: "required",
|
||||
params: {
|
||||
slug: { type: "string" },
|
||||
commentID: { type: "string" },
|
||||
comment: { type: "object" }
|
||||
},
|
||||
handler(ctx) {
|
||||
return this.Promise.resolve(ctx.params.slug)
|
||||
.then(slug => this.findBySlug(slug))
|
||||
.then(article => {
|
||||
if (!article)
|
||||
return this.Promise.reject(new MoleculerClientError("Article not found", 404));
|
||||
|
||||
return ctx.call("comments.update", { id: ctx.params.commentID, comment: ctx.params.comment });
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove a comment.
|
||||
* Auth is required!
|
||||
*
|
||||
* @actions
|
||||
* @param {String} slug - Article slug
|
||||
* @param {String} commentID - Comment ID
|
||||
*
|
||||
* @returns {Number} Count of removed comment
|
||||
*/
|
||||
removeComment: {
|
||||
auth: "required",
|
||||
params: {
|
||||
slug: { type: "string" },
|
||||
commentID: { type: "string" }
|
||||
},
|
||||
handler(ctx) {
|
||||
return this.Promise.resolve(ctx.params.slug)
|
||||
.then(slug => this.findBySlug(slug))
|
||||
.then(article => {
|
||||
if (!article)
|
||||
return this.Promise.reject(new MoleculerClientError("Article not found"));
|
||||
|
||||
return ctx.call("comments.remove", { id: ctx.params.commentID });
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Methods
|
||||
*/
|
||||
methods: {
|
||||
/**
|
||||
* Find an article by slug
|
||||
*
|
||||
* @param {String} slug - Article slug
|
||||
*
|
||||
* @results {Object} Promise<Article
|
||||
*/
|
||||
findBySlug(slug) {
|
||||
return this.adapter.findOne({ slug });
|
||||
},
|
||||
|
||||
/**
|
||||
* Transform the result entities to follow the RealWorld API spec
|
||||
*
|
||||
* @param {Context} ctx
|
||||
* @param {Array} entities
|
||||
* @param {Object} user - Logged in user
|
||||
*/
|
||||
transformResult(ctx, entities, user) {
|
||||
if (Array.isArray(entities)) {
|
||||
return this.Promise.map(entities, item => this.transformEntity(ctx, item, user))
|
||||
.then(articles => ({ articles }));
|
||||
} else {
|
||||
return this.transformEntity(ctx, entities, user)
|
||||
.then(article => ({ article }));
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Transform a result entity to follow the RealWorld API spec
|
||||
*
|
||||
* @param {Context} ctx
|
||||
* @param {Object} entity
|
||||
* @param {Object} user - Logged in user
|
||||
*/
|
||||
transformEntity(ctx, entity, user) {
|
||||
if (!entity) return this.Promise.resolve();
|
||||
|
||||
return this.Promise.resolve(entity);
|
||||
}
|
||||
},
|
||||
|
||||
events: {
|
||||
"cache.clean.articles"() {
|
||||
if (this.broker.cacher)
|
||||
this.broker.cacher.clean(`${this.name}.*`);
|
||||
},
|
||||
"cache.clean.users"() {
|
||||
if (this.broker.cacher)
|
||||
this.broker.cacher.clean(`${this.name}.*`);
|
||||
},
|
||||
"cache.clean.comments"() {
|
||||
if (this.broker.cacher)
|
||||
this.broker.cacher.clean(`${this.name}.*`);
|
||||
},
|
||||
"cache.clean.follows"() {
|
||||
if (this.broker.cacher)
|
||||
this.broker.cacher.clean(`${this.name}.*`);
|
||||
},
|
||||
"cache.clean.favorites"() {
|
||||
if (this.broker.cacher)
|
||||
this.broker.cacher.clean(`${this.name}.*`);
|
||||
}
|
||||
}
|
||||
};
|
||||
266
advanced/server/services/comments.service.js
Normal file
266
advanced/server/services/comments.service.js
Normal file
@@ -0,0 +1,266 @@
|
||||
"use strict";
|
||||
|
||||
const { ForbiddenError } = require("moleculer-web").Errors;
|
||||
const DbService = require("../mixins/db.mixin");
|
||||
|
||||
module.exports = {
|
||||
name: "comments",
|
||||
mixins: [DbService("comments")],
|
||||
|
||||
/**
|
||||
* Default settings
|
||||
*/
|
||||
settings: {
|
||||
fields: ["_id", "author", "article", "body", "createdAt", "updatedAt"],
|
||||
populates: {
|
||||
"author": {
|
||||
action: "users.get",
|
||||
params: {
|
||||
fields: ["_id", "username", "bio", "image"]
|
||||
}
|
||||
}
|
||||
},
|
||||
entityValidator: {
|
||||
article: { type: "string" },
|
||||
body: { type: "string", min: 1 },
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Actions
|
||||
*/
|
||||
actions: {
|
||||
/**
|
||||
* Create a comment.
|
||||
* Auth is required!
|
||||
*
|
||||
* @actions
|
||||
* @param {String} article - Article ID
|
||||
* @param {Object} comment - Comment entity
|
||||
*
|
||||
* @returns {Object} Created comment entity
|
||||
*/
|
||||
create: {
|
||||
auth: "required",
|
||||
params: {
|
||||
article: { type: "string" },
|
||||
comment: { type: "object" }
|
||||
},
|
||||
handler(ctx) {
|
||||
let entity = ctx.params.comment;
|
||||
entity.article = ctx.params.article;
|
||||
entity.author = ctx.meta.user._id.toString();
|
||||
|
||||
return this.validateEntity(entity)
|
||||
.then(() => {
|
||||
|
||||
entity.createdAt = new Date();
|
||||
entity.updatedAt = new Date();
|
||||
|
||||
return this.adapter.insert(entity)
|
||||
.then(doc => this.transformDocuments(ctx, { populate: ["author"]}, doc))
|
||||
.then(entity => this.transformResult(ctx, entity, ctx.meta.user))
|
||||
.then(json => this.entityChanged("created", json, ctx).then(() => json));
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a comment.
|
||||
* Auth is required!
|
||||
*
|
||||
* @actions
|
||||
* @param {String} id - Comment ID
|
||||
* @param {Object} comment - Comment modified fields
|
||||
*
|
||||
* @returns {Object} Updated comment entity
|
||||
*/
|
||||
update: {
|
||||
auth: "required",
|
||||
params: {
|
||||
id: { type: "string" },
|
||||
comment: { type: "object", props: {
|
||||
body: { type: "string", min: 1 },
|
||||
} }
|
||||
},
|
||||
handler(ctx) {
|
||||
let newData = ctx.params.comment;
|
||||
newData.updatedAt = new Date();
|
||||
|
||||
return this.getById(ctx.params.id)
|
||||
.then(comment => {
|
||||
if (comment.author !== ctx.meta.user._id.toString())
|
||||
return this.Promise.reject(new ForbiddenError());
|
||||
|
||||
const update = {
|
||||
"$set": newData
|
||||
};
|
||||
|
||||
return this.adapter.updateById(ctx.params.id, update);
|
||||
})
|
||||
.then(doc => this.transformDocuments(ctx, { populate: ["author"]}, doc))
|
||||
.then(entity => this.transformResult(ctx, entity, ctx.meta.user))
|
||||
.then(json => this.entityChanged("updated", json, ctx).then(() => json));
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* List of comments by article.
|
||||
*
|
||||
* @actions
|
||||
* @param {String} article - Article ID
|
||||
* @param {Number} limit - Pagination limit
|
||||
* @param {Number} offset - Pagination offset
|
||||
*
|
||||
* @returns {Object} List of comments
|
||||
*/
|
||||
list: {
|
||||
cache: {
|
||||
keys: ["#token", "article", "limit", "offset"]
|
||||
},
|
||||
params: {
|
||||
article: { type: "string" },
|
||||
limit: { type: "number", optional: true, convert: true },
|
||||
offset: { type: "number", optional: true, convert: true },
|
||||
},
|
||||
handler(ctx) {
|
||||
const limit = ctx.params.limit ? Number(ctx.params.limit) : 20;
|
||||
const offset = ctx.params.offset ? Number(ctx.params.offset) : 0;
|
||||
|
||||
let params = {
|
||||
limit,
|
||||
offset,
|
||||
sort: ["-createdAt"],
|
||||
populate: ["author"],
|
||||
query: {
|
||||
article: ctx.params.article
|
||||
}
|
||||
};
|
||||
let countParams;
|
||||
|
||||
return this.Promise.resolve()
|
||||
.then(() => {
|
||||
countParams = Object.assign({}, params);
|
||||
// Remove pagination params
|
||||
if (countParams && countParams.limit)
|
||||
countParams.limit = null;
|
||||
if (countParams && countParams.offset)
|
||||
countParams.offset = null;
|
||||
})
|
||||
.then(() => this.Promise.all([
|
||||
// Get rows
|
||||
this.adapter.find(params),
|
||||
|
||||
// Get count of all rows
|
||||
this.adapter.count(countParams)
|
||||
|
||||
])).then(res => {
|
||||
return this.transformDocuments(ctx, params, res[0])
|
||||
.then(docs => this.transformResult(ctx, docs, ctx.meta.user))
|
||||
.then(r => {
|
||||
r.commentsCount = res[1];
|
||||
return r;
|
||||
});
|
||||
});
|
||||
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove a comment
|
||||
* Auth is required!
|
||||
*
|
||||
* @actions
|
||||
* @param {String} id - Comment ID
|
||||
*
|
||||
* @returns {Number} Count of removed comments
|
||||
*/
|
||||
remove: {
|
||||
auth: "required",
|
||||
params: {
|
||||
id: { type: "any" }
|
||||
},
|
||||
handler(ctx) {
|
||||
return this.getById(ctx.params.id)
|
||||
.then(comment => {
|
||||
if (comment.author !== ctx.meta.user._id.toString())
|
||||
return this.Promise.reject(new ForbiddenError());
|
||||
|
||||
return this.adapter.removeById(ctx.params.id)
|
||||
.then(json => this.entityChanged("updated", json, ctx).then(() => json));
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Methods
|
||||
*/
|
||||
methods: {
|
||||
|
||||
/**
|
||||
* Transform the result entities to follow the RealWorld API spec
|
||||
*
|
||||
* @param {Context} ctx
|
||||
* @param {Array} entities
|
||||
* @param {Object} user - Logged in user
|
||||
*/
|
||||
transformResult(ctx, entities, user) {
|
||||
if (Array.isArray(entities)) {
|
||||
return this.Promise.map(entities, item => this.transformEntity(ctx, item, user))
|
||||
.then(comments => ({ comments }));
|
||||
} else {
|
||||
return this.transformEntity(ctx, entities, user)
|
||||
.then(comment => ({ comment }));
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Transform a result entity to follow the RealWorld API spec
|
||||
*
|
||||
* @param {Context} ctx
|
||||
* @param {Object} entity
|
||||
* @param {Object} user - Logged in user
|
||||
*/
|
||||
transformEntity(ctx, entity, loggedInUser) {
|
||||
if (!entity) return this.Promise.resolve();
|
||||
|
||||
return this.Promise.resolve(entity)
|
||||
.then(entity => {
|
||||
entity.id = entity._id;
|
||||
|
||||
if (loggedInUser) {
|
||||
return ctx.call("follows.has", { user: loggedInUser._id.toString(), follow: entity.author._id })
|
||||
.then(res => {
|
||||
entity.author.following = res;
|
||||
return entity;
|
||||
});
|
||||
}
|
||||
|
||||
entity.author.following = false;
|
||||
|
||||
return entity;
|
||||
});
|
||||
|
||||
}
|
||||
},
|
||||
|
||||
events: {
|
||||
"cache.clean.comments"() {
|
||||
if (this.broker.cacher)
|
||||
this.broker.cacher.clean(`${this.name}.*`);
|
||||
},
|
||||
"cache.clean.users"() {
|
||||
if (this.broker.cacher)
|
||||
this.broker.cacher.clean(`${this.name}.*`);
|
||||
},
|
||||
"cache.clean.follows"() {
|
||||
if (this.broker.cacher)
|
||||
this.broker.cacher.clean(`${this.name}.*`);
|
||||
},
|
||||
"cache.clean.articles"() {
|
||||
if (this.broker.cacher)
|
||||
this.broker.cacher.clean(`${this.name}.*`);
|
||||
}
|
||||
}
|
||||
};
|
||||
177
advanced/server/services/favorites.service.js
Normal file
177
advanced/server/services/favorites.service.js
Normal file
@@ -0,0 +1,177 @@
|
||||
"use strict";
|
||||
|
||||
const { MoleculerClientError } = require("moleculer").Errors;
|
||||
const DbService = require("../mixins/db.mixin");
|
||||
|
||||
|
||||
module.exports = {
|
||||
name: "favorites",
|
||||
mixins: [DbService("favorites")],
|
||||
|
||||
/**
|
||||
* Default settings
|
||||
*/
|
||||
settings: {
|
||||
|
||||
},
|
||||
|
||||
/**
|
||||
* Actions
|
||||
*/
|
||||
actions: {
|
||||
|
||||
/**
|
||||
* Create a new favorite record
|
||||
*
|
||||
* @actions
|
||||
*
|
||||
* @param {String} article - Article ID
|
||||
* @param {String} user - User ID
|
||||
* @returns {Object} Created favorite record
|
||||
*/
|
||||
add: {
|
||||
params: {
|
||||
article: { type: "string" },
|
||||
user: { type: "string" },
|
||||
},
|
||||
handler(ctx) {
|
||||
const { article, user } = ctx.params;
|
||||
return this.findByArticleAndUser(article, user)
|
||||
.then(item => {
|
||||
if (item)
|
||||
return this.Promise.reject(new MoleculerClientError("Articles has already favorited"));
|
||||
|
||||
return this.adapter.insert({ article, user, createdAt: new Date() })
|
||||
.then(json => this.entityChanged("created", json, ctx).then(() => json));
|
||||
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check the given 'article' is followed by 'user'.
|
||||
*
|
||||
* @actions
|
||||
*
|
||||
* @param {String} article - Article ID
|
||||
* @param {String} user - User ID
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
has: {
|
||||
cache: {
|
||||
keys: ["article", "user"]
|
||||
},
|
||||
params: {
|
||||
article: { type: "string" },
|
||||
user: { type: "string" },
|
||||
},
|
||||
handler(ctx) {
|
||||
const { article, user } = ctx.params;
|
||||
return this.findByArticleAndUser(article, user)
|
||||
.then(item => !!item);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Count of favorites.
|
||||
*
|
||||
* @actions
|
||||
*
|
||||
* @param {String?} article - Article ID
|
||||
* @param {String?} user - User ID
|
||||
* @returns {Number}
|
||||
*/
|
||||
count: {
|
||||
cache: {
|
||||
keys: ["article", "user"]
|
||||
},
|
||||
params: {
|
||||
article: { type: "string", optional: true },
|
||||
user: { type: "string", optional: true },
|
||||
},
|
||||
handler(ctx) {
|
||||
let query = {};
|
||||
if (ctx.params.article)
|
||||
query = { article: ctx.params.article };
|
||||
|
||||
if (ctx.params.user)
|
||||
query = { user: ctx.params.user };
|
||||
|
||||
return this.adapter.count({ query });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a favorite record
|
||||
*
|
||||
* @actions
|
||||
*
|
||||
* @param {String} article - Article ID
|
||||
* @param {String} user - User ID
|
||||
* @returns {Number} Count of removed records
|
||||
*/
|
||||
delete: {
|
||||
params: {
|
||||
article: { type: "string" },
|
||||
user: { type: "string" },
|
||||
},
|
||||
handler(ctx) {
|
||||
const { article, user } = ctx.params;
|
||||
return this.findByArticleAndUser(article, user)
|
||||
.then(item => {
|
||||
if (!item)
|
||||
return this.Promise.reject(new MoleculerClientError("Articles has not favorited yet"));
|
||||
|
||||
return this.adapter.removeById(item._id)
|
||||
.then(json => this.entityChanged("removed", json, ctx).then(() => json));
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove all favorites by article
|
||||
*
|
||||
* @actions
|
||||
*
|
||||
* @param {String} article - Article ID
|
||||
* @returns {Number} Count of removed records
|
||||
*/
|
||||
removeByArticle: {
|
||||
params: {
|
||||
article: { type: "string" }
|
||||
},
|
||||
handler(ctx) {
|
||||
return this.adapter.removeMany(ctx.params);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Methods
|
||||
*/
|
||||
methods: {
|
||||
/**
|
||||
* Find the first favorite record by 'article' or 'user'
|
||||
* @param {String} article - Article ID
|
||||
* @param {String} user - User ID
|
||||
*/
|
||||
findByArticleAndUser(article, user) {
|
||||
return this.adapter.findOne({ article, user });
|
||||
},
|
||||
},
|
||||
|
||||
events: {
|
||||
"cache.clean.favorites"() {
|
||||
if (this.broker.cacher)
|
||||
this.broker.cacher.clean(`${this.name}.*`);
|
||||
},
|
||||
"cache.clean.users"() {
|
||||
if (this.broker.cacher)
|
||||
this.broker.cacher.clean(`${this.name}.*`);
|
||||
},
|
||||
"cache.clean.articles"() {
|
||||
if (this.broker.cacher)
|
||||
this.broker.cacher.clean(`${this.name}.*`);
|
||||
}
|
||||
}
|
||||
};
|
||||
153
advanced/server/services/follows.service.js
Normal file
153
advanced/server/services/follows.service.js
Normal file
@@ -0,0 +1,153 @@
|
||||
"use strict";
|
||||
|
||||
const { MoleculerClientError } = require("moleculer").Errors;
|
||||
const DbService = require("../mixins/db.mixin");
|
||||
|
||||
module.exports = {
|
||||
name: "follows",
|
||||
mixins: [DbService("follows")],
|
||||
|
||||
/**
|
||||
* Default settings
|
||||
*/
|
||||
settings: {
|
||||
|
||||
},
|
||||
|
||||
/**
|
||||
* Actions
|
||||
*/
|
||||
actions: {
|
||||
|
||||
/**
|
||||
* Create a new following record
|
||||
*
|
||||
* @actions
|
||||
*
|
||||
* @param {String} user - Follower username
|
||||
* @param {String} follow - Followee username
|
||||
* @returns {Object} Created following record
|
||||
*/
|
||||
add: {
|
||||
params: {
|
||||
user: { type: "string" },
|
||||
follow: { type: "string" },
|
||||
},
|
||||
handler(ctx) {
|
||||
const { follow, user } = ctx.params;
|
||||
return this.findByFollowAndUser(follow, user)
|
||||
.then(item => {
|
||||
if (item)
|
||||
return this.Promise.reject(new MoleculerClientError("User has already followed"));
|
||||
|
||||
return this.adapter.insert({ follow, user, createdAt: new Date() })
|
||||
.then(json => this.entityChanged("created", json, ctx).then(() => json));
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check the given 'follow' user is followed by 'user' user.
|
||||
*
|
||||
* @actions
|
||||
*
|
||||
* @param {String} user - Follower username
|
||||
* @param {String} follow - Followee username
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
has: {
|
||||
cache: {
|
||||
keys: ["article", "user"]
|
||||
},
|
||||
params: {
|
||||
user: { type: "string" },
|
||||
follow: { type: "string" },
|
||||
},
|
||||
handler(ctx) {
|
||||
return this.findByFollowAndUser(ctx.params.follow, ctx.params.user)
|
||||
.then(item => !!item);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Count of following.
|
||||
*
|
||||
* @actions
|
||||
*
|
||||
* @param {String?} user - Follower username
|
||||
* @param {String?} follow - Followee username
|
||||
* @returns {Number}
|
||||
*/
|
||||
count: {
|
||||
cache: {
|
||||
keys: ["article", "user"]
|
||||
},
|
||||
params: {
|
||||
follow: { type: "string", optional: true },
|
||||
user: { type: "string", optional: true },
|
||||
},
|
||||
handler(ctx) {
|
||||
let query = {};
|
||||
if (ctx.params.follow)
|
||||
query = { follow: ctx.params.follow };
|
||||
|
||||
if (ctx.params.user)
|
||||
query = { user: ctx.params.user };
|
||||
|
||||
return this.adapter.count({ query });
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a following record
|
||||
*
|
||||
* @actions
|
||||
*
|
||||
* @param {String} user - Follower username
|
||||
* @param {String} follow - Followee username
|
||||
* @returns {Number} Count of removed records
|
||||
*/
|
||||
delete: {
|
||||
params: {
|
||||
user: { type: "string" },
|
||||
follow: { type: "string" },
|
||||
},
|
||||
handler(ctx) {
|
||||
const { follow, user } = ctx.params;
|
||||
return this.findByFollowAndUser(follow, user)
|
||||
.then(item => {
|
||||
if (!item)
|
||||
return this.Promise.reject(new MoleculerClientError("User has not followed yet"));
|
||||
|
||||
return this.adapter.removeById(item._id)
|
||||
.then(json => this.entityChanged("removed", json, ctx).then(() => json));
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Methods
|
||||
*/
|
||||
methods: {
|
||||
/**
|
||||
* Find the first following record by 'follow' or 'user'
|
||||
* @param {String} follow - Followee username
|
||||
* @param {String} user - Follower username
|
||||
*/
|
||||
findByFollowAndUser(follow, user) {
|
||||
return this.adapter.findOne({ follow, user });
|
||||
},
|
||||
},
|
||||
|
||||
events: {
|
||||
"cache.clean.follows"() {
|
||||
if (this.broker.cacher)
|
||||
this.broker.cacher.clean(`${this.name}.*`);
|
||||
},
|
||||
"cache.clean.users"() {
|
||||
if (this.broker.cacher)
|
||||
this.broker.cacher.clean(`${this.name}.*`);
|
||||
}
|
||||
}
|
||||
};
|
||||
96
advanced/server/services/metrics.service.js
Normal file
96
advanced/server/services/metrics.service.js
Normal file
@@ -0,0 +1,96 @@
|
||||
let _ = require("lodash");
|
||||
|
||||
module.exports = {
|
||||
name: "metrics",
|
||||
|
||||
events: {
|
||||
"metrics.trace.span.start"(payload) {
|
||||
this.requests[payload.id] = payload;
|
||||
payload.spans = [];
|
||||
|
||||
if (payload.parent) {
|
||||
let parent = this.requests[payload.parent];
|
||||
if (parent)
|
||||
parent.spans.push(payload.id);
|
||||
}
|
||||
},
|
||||
|
||||
"metrics.trace.span.finish"(payload) {
|
||||
let item = this.requests[payload.id];
|
||||
_.assign(item, payload);
|
||||
|
||||
if (!payload.parent)
|
||||
this.printRequest(payload.id);
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
printRequest(id) {
|
||||
let main = this.requests[id];
|
||||
|
||||
let w = 73;
|
||||
let r = _.repeat;
|
||||
let gw = 35;
|
||||
let maxTitle = w - 2 - 2 - gw - 2 - 1;
|
||||
|
||||
this.logger.info(["┌", r("─", w-2), "┐"].join(""));
|
||||
|
||||
let printSpanTime = (span) => {
|
||||
let time = span.duration.toFixed(2);
|
||||
|
||||
let maxActionName = maxTitle - (span.level-1) * 2 - time.length - 3 - (span.fromCache ? 2 : 0) - (span.remoteCall ? 2 : 0) - (span.error ? 2 : 0);
|
||||
let actionName = span.action ? span.action.name : "";
|
||||
if (actionName.length > maxActionName)
|
||||
actionName = _.truncate(span.action.name, { length: maxActionName });
|
||||
|
||||
let strAction = [
|
||||
r(" ", span.level - 1),
|
||||
actionName,
|
||||
r(" ", maxActionName - actionName.length + 1),
|
||||
span.fromCache ? "* " : "",
|
||||
span.remoteCall ? "» " : "",
|
||||
span.error ? "× " : "",
|
||||
time,
|
||||
"ms "
|
||||
].join("");
|
||||
|
||||
if (span.startTime == null || span.endTime == null) {
|
||||
this.logger.info(strAction + "! Missing invoke !");
|
||||
return;
|
||||
}
|
||||
|
||||
let gstart = (span.startTime - main.startTime) / (main.endTime - main.startTime) * 100;
|
||||
let gstop = (span.endTime - main.startTime) / (main.endTime - main.startTime) * 100;
|
||||
|
||||
if (_.isNaN(gstart) && _.isNaN(gstop)) {
|
||||
gstart = 0;
|
||||
gstop = 100;
|
||||
}
|
||||
|
||||
let p1 = Math.round(gw * gstart / 100);
|
||||
let p2 = Math.round(gw * gstop / 100) - p1;
|
||||
let p3 = Math.max(gw - (p1 + p2), 0);
|
||||
|
||||
let gauge = [
|
||||
"[",
|
||||
r(".", p1),
|
||||
r("■", p2),
|
||||
r(".", p3),
|
||||
"]"
|
||||
].join("");
|
||||
|
||||
this.logger.info("│ " + strAction + gauge + " │");
|
||||
|
||||
if (span.spans.length > 0)
|
||||
span.spans.forEach(spanID => printSpanTime(this.requests[spanID]));
|
||||
};
|
||||
|
||||
printSpanTime(main);
|
||||
this.logger.info(["└", r("─", w-2), "┘"].join(""));
|
||||
}
|
||||
},
|
||||
|
||||
created() {
|
||||
this.requests = {};
|
||||
}
|
||||
};
|
||||
390
advanced/server/services/users.service.js
Normal file
390
advanced/server/services/users.service.js
Normal file
@@ -0,0 +1,390 @@
|
||||
"use strict";
|
||||
|
||||
const { MoleculerClientError } = require("moleculer").Errors;
|
||||
|
||||
//const crypto = require("crypto");
|
||||
const bcrypt = require("bcrypt-nodejs");
|
||||
const jwt = require("jsonwebtoken");
|
||||
|
||||
const DbService = require("../mixins/db.mixin");
|
||||
|
||||
module.exports = {
|
||||
name: "users",
|
||||
mixins: [DbService("users")],
|
||||
|
||||
/**
|
||||
* Default settings
|
||||
*/
|
||||
settings: {
|
||||
/** Secret for JWT */
|
||||
JWT_SECRET: process.env.JWT_SECRET || "jwt-conduit-secret",
|
||||
|
||||
/** Public fields */
|
||||
fields: ["_id", "username", "email", "bio", "image"],
|
||||
|
||||
/** Validator schema for entity */
|
||||
entityValidator: {
|
||||
username: { type: "string", min: 2, pattern: /^[a-zA-Z0-9]+$/ },
|
||||
password: { type: "string", min: 6 },
|
||||
email: { type: "email" },
|
||||
bio: { type: "string", optional: true },
|
||||
image: { type: "string", optional: true },
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Actions
|
||||
*/
|
||||
actions: {
|
||||
/**
|
||||
* Register a new user
|
||||
*
|
||||
* @actions
|
||||
* @param {Object} user - User entity
|
||||
*
|
||||
* @returns {Object} Created entity & token
|
||||
*/
|
||||
create: {
|
||||
params: {
|
||||
user: { type: "object" }
|
||||
},
|
||||
handler(ctx) {
|
||||
let entity = ctx.params.user;
|
||||
return this.validateEntity(entity)
|
||||
.then(() => {
|
||||
if (entity.username)
|
||||
return this.adapter.findOne({ username: entity.username })
|
||||
.then(found => {
|
||||
if (found)
|
||||
return Promise.reject(new MoleculerClientError("Username is exist!", 422, "", [{ field: "username", message: "is exist"}]));
|
||||
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
if (entity.email)
|
||||
return this.adapter.findOne({ email: entity.email })
|
||||
.then(found => {
|
||||
if (found)
|
||||
return Promise.reject(new MoleculerClientError("Email is exist!", 422, "", [{ field: "email", message: "is exist"}]));
|
||||
});
|
||||
|
||||
})
|
||||
.then(() => {
|
||||
entity.password = bcrypt.hashSync(entity.password, bcrypt.genSaltSync(10));
|
||||
entity.bio = entity.bio || "";
|
||||
entity.image = entity.image || null;
|
||||
entity.createdAt = new Date();
|
||||
|
||||
return this.adapter.insert(entity)
|
||||
.then(doc => this.transformDocuments(ctx, {}, doc))
|
||||
.then(user => this.transformEntity(user, true, ctx.meta.token))
|
||||
.then(json => this.entityChanged("created", json, ctx).then(() => json));
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Login with username & password
|
||||
*
|
||||
* @actions
|
||||
* @param {Object} user - User credentials
|
||||
*
|
||||
* @returns {Object} Logged in user with token
|
||||
*/
|
||||
login: {
|
||||
params: {
|
||||
user: { type: "object", props: {
|
||||
email: { type: "email" },
|
||||
password: { type: "string", min: 1 }
|
||||
}}
|
||||
},
|
||||
handler(ctx) {
|
||||
const { email, password } = ctx.params.user;
|
||||
|
||||
return this.Promise.resolve()
|
||||
.then(() => this.adapter.findOne({ email }))
|
||||
.then(user => {
|
||||
if (!user)
|
||||
return this.Promise.reject(new MoleculerClientError("Email or password is invalid!", 422, "", [{ field: "email", message: "is not found"}]));
|
||||
|
||||
return bcrypt.compare(password, user.password).then(res => {
|
||||
if (!res)
|
||||
return Promise.reject(new MoleculerClientError("Wrong password!", 422, "", [{ field: "email", message: "is not found"}]));
|
||||
|
||||
// Transform user entity (remove password and all protected fields)
|
||||
return this.transformDocuments(ctx, {}, user);
|
||||
});
|
||||
})
|
||||
.then(user => this.transformEntity(user, true, ctx.meta.token));
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get user by JWT token (for API GW authentication)
|
||||
*
|
||||
* @actions
|
||||
* @param {String} token - JWT token
|
||||
*
|
||||
* @returns {Object} Resolved user
|
||||
*/
|
||||
resolveToken: {
|
||||
cache: {
|
||||
keys: ["token"],
|
||||
ttl: 60 * 60 // 1 hour
|
||||
},
|
||||
params: {
|
||||
token: "string"
|
||||
},
|
||||
handler(ctx) {
|
||||
return new this.Promise((resolve, reject) => {
|
||||
jwt.verify(ctx.params.token, this.settings.JWT_SECRET, (err, decoded) => {
|
||||
if (err)
|
||||
return reject(err);
|
||||
|
||||
resolve(decoded);
|
||||
});
|
||||
|
||||
})
|
||||
.then(decoded => {
|
||||
if (decoded.id)
|
||||
return this.getById(decoded.id);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get current user entity.
|
||||
* Auth is required!
|
||||
*
|
||||
* @actions
|
||||
*
|
||||
* @returns {Object} User entity
|
||||
*/
|
||||
me: {
|
||||
auth: "required",
|
||||
cache: {
|
||||
keys: ["#token"]
|
||||
},
|
||||
handler(ctx) {
|
||||
return this.getById(ctx.meta.user._id)
|
||||
.then(user => {
|
||||
if (!user)
|
||||
return this.Promise.reject(new MoleculerClientError("User not found!", 400));
|
||||
|
||||
return this.transformDocuments(ctx, {}, user);
|
||||
})
|
||||
.then(user => this.transformEntity(user, true, ctx.meta.token));
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update current user entity.
|
||||
* Auth is required!
|
||||
*
|
||||
* @actions
|
||||
*
|
||||
* @param {Object} user - Modified fields
|
||||
* @returns {Object} User entity
|
||||
*/
|
||||
updateMyself: {
|
||||
auth: "required",
|
||||
params: {
|
||||
user: { type: "object", props: {
|
||||
username: { type: "string", min: 2, optional: true, pattern: /^[a-zA-Z0-9]+$/ },
|
||||
password: { type: "string", min: 6, optional: true },
|
||||
email: { type: "email", optional: true },
|
||||
bio: { type: "string", optional: true },
|
||||
image: { type: "string", optional: true },
|
||||
}}
|
||||
},
|
||||
handler(ctx) {
|
||||
const newData = ctx.params.user;
|
||||
return this.Promise.resolve()
|
||||
.then(() => {
|
||||
if (newData.username)
|
||||
return this.adapter.findOne({ username: newData.username })
|
||||
.then(found => {
|
||||
if (found && found._id.toString() !== ctx.meta.user._id.toString())
|
||||
return Promise.reject(new MoleculerClientError("Username is exist!", 422, "", [{ field: "username", message: "is exist"}]));
|
||||
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
if (newData.email)
|
||||
return this.adapter.findOne({ email: newData.email })
|
||||
.then(found => {
|
||||
if (found && found._id.toString() !== ctx.meta.user._id.toString())
|
||||
return Promise.reject(new MoleculerClientError("Email is exist!", 422, "", [{ field: "email", message: "is exist"}]));
|
||||
});
|
||||
|
||||
})
|
||||
.then(() => {
|
||||
newData.updatedAt = new Date();
|
||||
const update = {
|
||||
"$set": newData
|
||||
};
|
||||
return this.adapter.updateById(ctx.meta.user._id, update);
|
||||
})
|
||||
.then(doc => this.transformDocuments(ctx, {}, doc))
|
||||
.then(user => this.transformEntity(user, true, ctx.meta.token))
|
||||
.then(json => this.entityChanged("updated", json, ctx).then(() => json));
|
||||
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a user profile.
|
||||
*
|
||||
* @actions
|
||||
*
|
||||
* @param {String} username - Username
|
||||
* @returns {Object} User entity
|
||||
*/
|
||||
profile: {
|
||||
cache: {
|
||||
keys: ["#token", "username"]
|
||||
},
|
||||
params: {
|
||||
username: { type: "string" }
|
||||
},
|
||||
handler(ctx) {
|
||||
return this.adapter.findOne({ username: ctx.params.username })
|
||||
.then(user => {
|
||||
if (!user)
|
||||
return this.Promise.reject(new MoleculerClientError("User not found!", 404));
|
||||
|
||||
return this.transformDocuments(ctx, {}, user);
|
||||
})
|
||||
.then(user => this.transformProfile(ctx, user, ctx.meta.user));
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Follow a user
|
||||
* Auth is required!
|
||||
*
|
||||
* @actions
|
||||
*
|
||||
* @param {String} username - Followed username
|
||||
* @returns {Object} Current user entity
|
||||
*/
|
||||
follow: {
|
||||
auth: "required",
|
||||
params: {
|
||||
username: { type: "string" }
|
||||
},
|
||||
handler(ctx) {
|
||||
return this.adapter.findOne({ username: ctx.params.username })
|
||||
.then(user => {
|
||||
if (!user)
|
||||
return this.Promise.reject(new MoleculerClientError("User not found!", 404));
|
||||
|
||||
return ctx.call("follows.add", { user: ctx.meta.user._id.toString(), follow: user._id.toString() })
|
||||
.then(() => this.transformDocuments(ctx, {}, user));
|
||||
})
|
||||
.then(user => this.transformProfile(ctx, user, ctx.meta.user));
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Unfollow a user
|
||||
* Auth is required!
|
||||
*
|
||||
* @actions
|
||||
*
|
||||
* @param {String} username - Unfollowed username
|
||||
* @returns {Object} Current user entity
|
||||
*/
|
||||
unfollow: {
|
||||
auth: "required",
|
||||
params: {
|
||||
username: { type: "string" }
|
||||
},
|
||||
handler(ctx) {
|
||||
return this.adapter.findOne({ username: ctx.params.username })
|
||||
.then(user => {
|
||||
if (!user)
|
||||
return this.Promise.reject(new MoleculerClientError("User not found!", 404));
|
||||
|
||||
return ctx.call("follows.delete", { user: ctx.meta.user._id.toString(), follow: user._id.toString() })
|
||||
.then(() => this.transformDocuments(ctx, {}, user));
|
||||
})
|
||||
.then(user => this.transformProfile(ctx, user, ctx.meta.user));
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Methods
|
||||
*/
|
||||
methods: {
|
||||
/**
|
||||
* Generate a JWT token from user entity
|
||||
*
|
||||
* @param {Object} user
|
||||
*/
|
||||
generateJWT(user) {
|
||||
const today = new Date();
|
||||
const exp = new Date(today);
|
||||
exp.setDate(today.getDate() + 60);
|
||||
|
||||
return jwt.sign({
|
||||
id: user._id,
|
||||
username: user.username,
|
||||
exp: Math.floor(exp.getTime() / 1000)
|
||||
}, this.settings.JWT_SECRET);
|
||||
},
|
||||
|
||||
/**
|
||||
* Transform returned user entity. Generate JWT token if neccessary.
|
||||
*
|
||||
* @param {Object} user
|
||||
* @param {Boolean} withToken
|
||||
*/
|
||||
transformEntity(user, withToken, token) {
|
||||
if (user) {
|
||||
//user.image = user.image || "https://www.gravatar.com/avatar/" + crypto.createHash("md5").update(user.email).digest("hex") + "?d=robohash";
|
||||
user.image = user.image || "";
|
||||
if (withToken)
|
||||
user.token = token || this.generateJWT(user);
|
||||
}
|
||||
|
||||
return { user };
|
||||
},
|
||||
|
||||
/**
|
||||
* Transform returned user entity as profile.
|
||||
*
|
||||
* @param {Context} ctx
|
||||
* @param {Object} user
|
||||
* @param {Object?} loggedInUser
|
||||
*/
|
||||
transformProfile(ctx, user, loggedInUser) {
|
||||
//user.image = user.image || "https://www.gravatar.com/avatar/" + crypto.createHash("md5").update(user.email).digest("hex") + "?d=robohash";
|
||||
user.image = user.image || "/assets/images/smiley-cyrus.jpg";
|
||||
|
||||
if (loggedInUser) {
|
||||
return ctx.call("follows.has", { user: loggedInUser._id.toString(), follow: user._id.toString() })
|
||||
.then(res => {
|
||||
user.following = res;
|
||||
return { profile: user };
|
||||
});
|
||||
}
|
||||
|
||||
user.following = false;
|
||||
|
||||
return { profile: user };
|
||||
}
|
||||
},
|
||||
|
||||
events: {
|
||||
"cache.clean.users"() {
|
||||
if (this.broker.cacher)
|
||||
this.broker.cacher.clean(`${this.name}.*`);
|
||||
},
|
||||
"cache.clean.follows"() {
|
||||
if (this.broker.cacher)
|
||||
this.broker.cacher.clean(`${this.name}.*`);
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user