├── .env ├── .dockerignore ├── .eslintrc.yml ├── public └── favicon.ico ├── test ├── fixtures │ ├── logo.png │ ├── loop.mp4 │ ├── admin_app3rd.js │ ├── photos.js │ ├── videos.js │ ├── organizations.js │ ├── super_admin_user.js │ ├── normal_user.js │ ├── admin_user.js │ ├── public_relations_user.js │ ├── department_head_user.js │ ├── department_officer_user.js │ ├── organization_admin_user.js │ ├── departments.js │ ├── constants.js │ ├── activity_logs.js │ └── pins.js ├── .eslintrc.yml ├── services │ ├── pin │ │ ├── index.test.js │ │ ├── hooks │ │ │ ├── process.test.js │ │ │ └── prepare-activity-log.test.js │ │ └── get.test.js │ ├── app3rd │ │ └── index.test.js │ ├── activity-log │ │ └── index.test.js │ ├── pin-state-transition │ │ └── hooks │ │ │ ├── log-activity.test.js │ │ │ └── prepare-activity-log.test.js │ ├── pin-merging │ │ └── hooks │ │ │ └── prepare-activity-log.test.js │ ├── summary │ │ └── index.test.js │ ├── organization │ │ └── index.test.js │ ├── summarize-state │ │ └── index.test.js │ ├── searchnearby │ │ └── index.test.js │ ├── department │ │ └── index.test.js │ ├── photo │ │ └── index.test.js │ └── video │ │ └── index.test.js ├── test_helper.js └── app.test.js ├── src ├── utils │ ├── email-templates │ │ └── notification │ │ │ ├── text.pug │ │ │ └── html.pug │ ├── hooks │ │ ├── log-activity.js │ │ ├── validate-object-id-hook.js │ │ ├── restrict-to-owner-of-pin-hook.js │ │ ├── swap-lat-long.js │ │ └── send-notif-to-related-users.js │ ├── number.js │ ├── uploader.js │ ├── send-bot-notification.js │ ├── gcs-uploader.js │ └── send-mail-notification.js ├── views │ ├── index.pug │ ├── layout.pug │ ├── login.pug │ ├── signup.pug │ └── index.js ├── constants │ ├── defaults.js │ ├── strings.js │ ├── pin-states.js │ ├── roles.js │ └── actions.js ├── index.js ├── middleware │ ├── not-found-handler.js │ ├── attach-file-to-feathers.js │ ├── index.js │ ├── prepare-multipart.js │ └── logger.js ├── services │ ├── pin-merging │ │ ├── hooks │ │ │ ├── log-activity.js │ │ │ ├── index.js │ │ │ └── prepare-activity-log.js │ │ └── index.js │ ├── activity-log │ │ ├── index.js │ │ └── activity-log-model.js │ ├── pin │ │ ├── hooks │ │ │ ├── process.js │ │ │ ├── prepare-notif-info-for-created-pin.js │ │ │ ├── restrict-to-the-right-user-for-update.js │ │ │ ├── index.js │ │ │ └── prepare-activity-log.js │ │ └── pin-model.js │ ├── summarize-state │ │ ├── hooks │ │ │ └── index.js │ │ └── index.js │ ├── searchnearby │ │ ├── hooks │ │ │ └── index.js │ │ └── index.js │ ├── photo │ │ ├── hooks │ │ │ └── index.js │ │ └── photo-model.js │ ├── video │ │ ├── hooks │ │ │ └── index.js │ │ ├── video-model.js │ │ └── index.js │ ├── department │ │ ├── department-model.js │ │ ├── index.js │ │ └── hooks │ │ │ └── index.js │ ├── app3rd │ │ ├── app3rd-model.js │ │ ├── index.js │ │ └── hooks │ │ │ └── index.js │ ├── organization │ │ ├── organization-model.js │ │ ├── index.js │ │ └── hooks │ │ │ └── index.js │ ├── summary │ │ ├── index.js │ │ ├── summary-model.js │ │ └── hooks │ │ │ ├── modify-search-query.js │ │ │ ├── index.js │ │ │ └── trigger-calculation.js │ ├── pin-state-transition │ │ ├── hooks │ │ │ ├── index.js │ │ │ └── prepare-activity-log.js │ │ └── index.js │ ├── user │ │ ├── user-model.js │ │ ├── hooks │ │ │ ├── handle-facebook-create.js │ │ │ └── index.js │ │ └── index.js │ ├── index.js │ └── authentication │ │ └── index.js ├── apidoc.json ├── init.js ├── hooks │ └── index.js ├── header.md └── app.js ├── config ├── test.json ├── gcs │ └── youpin_gcs_credentials_development.template.json ├── production.template.json └── default.template.json ├── docker-compose.yml ├── .editorconfig ├── .travis.yml ├── Dockerfile ├── ecosystem.production.json ├── ecosystem.development.json ├── .gitignore ├── LICENSE ├── README.md └── package.json /.env: -------------------------------------------------------------------------------- 1 | NODE_ENV= 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.eslintrc.yml: -------------------------------------------------------------------------------- 1 | extends: airbnb/base 2 | rules: 3 | no-console: off 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/youpin-city/youpin-api/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /test/fixtures/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/youpin-city/youpin-api/HEAD/test/fixtures/logo.png -------------------------------------------------------------------------------- /test/fixtures/loop.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/youpin-city/youpin-api/HEAD/test/fixtures/loop.mp4 -------------------------------------------------------------------------------- /src/utils/email-templates/notification/text.pug: -------------------------------------------------------------------------------- 1 | | iCare Notification 2 | | 3 | | #{message} 4 | | 5 | | Pin Link: #{pinLink} 6 | -------------------------------------------------------------------------------- /src/views/index.pug: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | block content 4 | a(href="/login") Login 5 | a(href="/register") Register 6 | -------------------------------------------------------------------------------- /src/constants/defaults.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // default to Thailand Democracy Monument 3 | DEFAULT_LAT_LONG: [100.5018549, 13.756727], 4 | }; 5 | -------------------------------------------------------------------------------- /test/.eslintrc.yml: -------------------------------------------------------------------------------- 1 | env: 2 | mocha: true 3 | rules: 4 | no-console: off 5 | import/no-extraneous-dependencies: ["error", { "devDependencies": true }] 6 | -------------------------------------------------------------------------------- /src/constants/strings.js: -------------------------------------------------------------------------------- 1 | // String constants for various uses 2 | // Ex. non-assigned text for email notification 3 | module.exports = { 4 | EMAIL_NOTI_NON_ASSIGNED_TEXT: 'None', 5 | }; 6 | 7 | -------------------------------------------------------------------------------- /src/constants/pin-states.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ASSIGNED: 'assigned', 3 | PENDING: 'pending', 4 | PROCESSING: 'processing', 5 | REJECTED: 'rejected', 6 | RESOLVED: 'resolved', 7 | }; 8 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const app = require('./app'); 2 | 3 | const port = app.get('port'); 4 | const server = app.listen(port); 5 | 6 | server.on('listening', () => { 7 | console.log(`Feathers application started on ${app.get('host')}:${port}`); 8 | }); 9 | -------------------------------------------------------------------------------- /config/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "host": "localhost", 3 | "port": 9191, 4 | "mongodb": "mongodb://mongodb:27017/youpin-test", 5 | "default": { 6 | "location": { 7 | "lat": 13.756727, 8 | "long": 100.5018549 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/middleware/not-found-handler.js: -------------------------------------------------------------------------------- 1 | const errors = require('feathers-errors'); 2 | 3 | module.exports = function () { // eslint-disable-line func-names 4 | return (req, res, next) => { 5 | next(new errors.NotFound('Page not found')); 6 | }; 7 | }; 8 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | services: 4 | api: 5 | environment: 6 | - NODE_ENV=${NODE_ENV} 7 | build: . 8 | ports: 9 | - "9100:9100" 10 | depends_on: 11 | - mongodb 12 | mongodb: 13 | image: mongo 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: '6.13.0' 3 | services: mongodb 4 | addons: 5 | hosts: 6 | - mongodb 7 | install: 8 | - yarn 9 | cache: yarn 10 | before_script: 11 | - cp ${TRAVIS_BUILD_DIR}/config/default.template.json ${TRAVIS_BUILD_DIR}/config/default.json 12 | -------------------------------------------------------------------------------- /src/utils/hooks/log-activity.js: -------------------------------------------------------------------------------- 1 | // For after hook to log activity 2 | // Assume that a before hook attach logInfo in proper format already 3 | const logActivity = () => (hook) => { 4 | hook.app.service('/activity_logs').create(hook.data.logInfo); 5 | }; 6 | 7 | module.exports = logActivity; 8 | -------------------------------------------------------------------------------- /test/fixtures/admin_app3rd.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | _id: '579b04ac516706156da5bba1', 3 | // real apikey: ed545297-4024-4a75-89b4-c95fed1df436 4 | apikey: '$2a$10$7JWQ4uMSBmOvfbfJer9KeOp4x/6e2CVRq/TBmoL289hfHtwU0YoAO', 5 | name: 'GreenWorld', 6 | homepage: 'www.greenworld.green.green', 7 | }; 8 | -------------------------------------------------------------------------------- /src/services/pin-merging/hooks/log-activity.js: -------------------------------------------------------------------------------- 1 | // For after hook to log activity 2 | // Assume that a before hook attach logInfo in proper format already 3 | const logActivity = () => (hook) => { 4 | hook.app.service('/activity_logs').create(hook.data.logInfo); 5 | }; 6 | 7 | module.exports = logActivity; 8 | -------------------------------------------------------------------------------- /src/utils/number.js: -------------------------------------------------------------------------------- 1 | const isNumericString = (n) => !isNaN(parseFloat(n)) && isFinite(n); 2 | 3 | const isIntegerString = (n) => { 4 | const floatN = parseFloat(n); 5 | return Math.floor(floatN) === floatN; 6 | }; 7 | 8 | module.exports = { 9 | isNumericString, 10 | isIntegerString, 11 | }; 12 | -------------------------------------------------------------------------------- /src/constants/roles.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | DEPARTMENT_HEAD: 'department_head', 3 | DEPARTMENT_OFFICER: 'department_officer', 4 | EXECUTIVE_ADMIN: 'executive_admin', 5 | ORGANIZATION_ADMIN: 'organization_admin', 6 | PUBLIC_RELATIONS: 'public_relations', 7 | SUPER_ADMIN: 'super_admin', 8 | USER: 'user', 9 | }; 10 | -------------------------------------------------------------------------------- /src/services/activity-log/index.js: -------------------------------------------------------------------------------- 1 | const mongooseService = require('feathers-mongoose'); 2 | 3 | const ActivityLog = require('./activity-log-model'); 4 | 5 | module.exports = function registerActivityLogService() { 6 | const app = this; 7 | 8 | app.use('/activity_logs', mongooseService({ Model: ActivityLog })); 9 | }; 10 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM showpiper/alpine-node-yarn 2 | 3 | LABEL maintainer="YouPin Team " 4 | 5 | RUN apk add --update libc6-compat build-base 6 | COPY package.json /code/package.json 7 | COPY yarn.lock /code/yarn.lock 8 | RUN cd /code && yarn 9 | 10 | COPY . /code 11 | 12 | WORKDIR /code 13 | 14 | CMD ["npm", "start"] 15 | -------------------------------------------------------------------------------- /src/services/pin/hooks/process.js: -------------------------------------------------------------------------------- 1 | const defaults = {}; 2 | 3 | module.exports = (options) => { // eslint-disable-line no-unused-vars 4 | options = Object.assign({}, defaults, options); // eslint-disable-line no-param-reassign 5 | 6 | return (hook) => { 7 | hook.process = true; // eslint-disable-line no-param-reassign 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /src/services/summarize-state/hooks/index.js: -------------------------------------------------------------------------------- 1 | exports.before = { 2 | all: [], 3 | find: [], 4 | get: [], 5 | create: [], 6 | update: [], 7 | patch: [], 8 | remove: [], 9 | }; 10 | 11 | exports.after = { 12 | all: [], 13 | find: [], 14 | get: [], 15 | create: [], 16 | update: [], 17 | patch: [], 18 | remove: [], 19 | }; 20 | -------------------------------------------------------------------------------- /test/fixtures/photos.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | _id: '579331115563625d6281b111', 4 | url: 'https://youpin.city/logo.png', 5 | mimetype: 'image/png', 6 | size: 5000, 7 | }, 8 | { 9 | _id: '572221115562225d6281b222', 10 | url: 'https://youpin.city/test_image.jpg', 11 | mimetype: 'image/jpg', 12 | size: 1337, 13 | }, 14 | ]; 15 | -------------------------------------------------------------------------------- /test/fixtures/videos.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | _id: '57933111556362511181b111', 4 | url: 'https://youpin.city/intro.mp4', 5 | mimetype: 'video/mp4', 6 | size: 5008820, 7 | }, 8 | { 9 | _id: '57222221556222226281b222', 10 | url: 'https://youpin.city/test_video.mp4', 11 | mimetype: 'video/mp4', 12 | size: 13370000, 13 | }, 14 | ]; 15 | -------------------------------------------------------------------------------- /src/apidoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "YouPin API", 3 | "version": "0.1.0", 4 | "title": "YouPin.city REST API", 5 | "description": "REST API for YouPin.city", 6 | "url": "https://api.youpin.city", 7 | "header": { 8 | "title": "Introduction", 9 | "filename": "header.md" 10 | }, 11 | "template": { 12 | "withCompare": false, 13 | "withGenerator": false 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/utils/uploader.js: -------------------------------------------------------------------------------- 1 | const multer = require('multer'); 2 | 3 | const uploader = function uploader(fileSize) { 4 | return multer({ 5 | inMemory: true, 6 | fileSize, 7 | rename(fieldname, filename) { 8 | // generate a unique filename 9 | return filename.replace(/\W+/g, '-').toLowerCase() + Date.now(); 10 | }, 11 | }); 12 | }; 13 | 14 | module.exports = uploader; 15 | -------------------------------------------------------------------------------- /src/init.js: -------------------------------------------------------------------------------- 1 | module.exports = () => { 2 | // Shorthand function to allow checking available interface of an object 3 | Object.defineProperty(Object.prototype, 'can', { // eslint-disable-line no-extend-native 4 | writable: true, 5 | value: function (method) { // eslint-disable-line object-shorthand, func-names 6 | return (typeof this[method] === 'function'); 7 | }, 8 | }); 9 | }; 10 | -------------------------------------------------------------------------------- /src/services/searchnearby/hooks/index.js: -------------------------------------------------------------------------------- 1 | const swapLatLong = require('../../../utils/hooks/swap-lat-long'); 2 | 3 | exports.before = { 4 | all: [], 5 | find: [], 6 | get: [], 7 | create: [], 8 | update: [], 9 | patch: [], 10 | remove: [], 11 | }; 12 | 13 | exports.after = { 14 | all: [swapLatLong()], 15 | find: [], 16 | get: [], 17 | create: [], 18 | update: [], 19 | patch: [], 20 | remove: [], 21 | }; 22 | -------------------------------------------------------------------------------- /src/utils/hooks/validate-object-id-hook.js: -------------------------------------------------------------------------------- 1 | const errors = require('feathers-errors'); 2 | const mongoose = require('mongoose'); 3 | 4 | const validateObjectId = function validateObjectId() { 5 | return (hook) => { 6 | const id = hook.id; 7 | if (id && !mongoose.Types.ObjectId.isValid(id)) { 8 | throw new errors.NotFound(`No record found for id '${id}'`); 9 | } 10 | }; 11 | }; 12 | 13 | module.exports = validateObjectId; 14 | -------------------------------------------------------------------------------- /test/services/pin/index.test.js: -------------------------------------------------------------------------------- 1 | // Test helper functions 2 | const { 3 | assertTestEnv, 4 | expect, 5 | } = require('../../test_helper'); 6 | 7 | // App stuff 8 | const app = require('../../../src/app'); 9 | 10 | // Exit test if NODE_ENV is not equal `test` 11 | assertTestEnv(); 12 | 13 | describe('Pin service', () => { 14 | it('registered the pins service', () => { 15 | expect(app.service('pins')).to.be.ok(); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/views/layout.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | title= title 5 | meta(name='viewport', content='width=device-width, initial-scale=1.0') 6 | link(href='https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/css/bootstrap.min.css', rel='stylesheet', media='screen') 7 | body 8 | block content 9 | 10 | script(src='https://code.jquery.com/jquery.js') 11 | script(src='https://maxcdn.bootstrapcdn.com/bootstrap/3.3.2/js/bootstrap.min.js') 12 | -------------------------------------------------------------------------------- /src/services/photo/hooks/index.js: -------------------------------------------------------------------------------- 1 | const validateObjectId = require('../../../utils/hooks/validate-object-id-hook'); 2 | 3 | exports.before = { 4 | all: [], 5 | find: [], 6 | get: [ 7 | validateObjectId(), 8 | ], 9 | create: [], 10 | update: [], 11 | patch: [], 12 | remove: [], 13 | }; 14 | 15 | exports.after = { 16 | all: [], 17 | find: [], 18 | get: [], 19 | create: [], 20 | update: [], 21 | patch: [], 22 | remove: [], 23 | }; 24 | -------------------------------------------------------------------------------- /src/services/video/hooks/index.js: -------------------------------------------------------------------------------- 1 | const validateObjectId = require('../../../utils/hooks/validate-object-id-hook'); 2 | 3 | exports.before = { 4 | all: [], 5 | find: [], 6 | get: [ 7 | validateObjectId(), 8 | ], 9 | create: [], 10 | update: [], 11 | patch: [], 12 | remove: [], 13 | }; 14 | 15 | exports.after = { 16 | all: [], 17 | find: [], 18 | get: [], 19 | create: [], 20 | update: [], 21 | patch: [], 22 | remove: [], 23 | }; 24 | -------------------------------------------------------------------------------- /config/gcs/youpin_gcs_credentials_development.template.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "service_account", 3 | "project_id": "", 4 | "private_key_id": "", 5 | "private_key": "", 6 | "client_email": "", 7 | "client_id": "", 8 | "auth_uri": "https://accounts.google.com/o/oauth2/auth", 9 | "token_uri": "https://accounts.google.com/o/oauth2/token", 10 | "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", 11 | "client_x509_cert_url": "" 12 | } 13 | -------------------------------------------------------------------------------- /src/services/photo/photo-model.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | require('mongoose-type-url'); 3 | 4 | const Schema = mongoose.Schema; 5 | const Url = mongoose.SchemaTypes.Url; 6 | 7 | const photoSchema = new Schema({ 8 | mimetype: { type: String, required: true }, 9 | size: { type: Number, required: true }, 10 | url: { type: Url, required: true }, 11 | }); 12 | 13 | const Photo = mongoose.model('Photo', photoSchema); 14 | 15 | module.exports = Photo; 16 | -------------------------------------------------------------------------------- /src/services/video/video-model.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | require('mongoose-type-url'); 3 | 4 | const Schema = mongoose.Schema; 5 | const Url = mongoose.SchemaTypes.Url; 6 | 7 | const videoSchema = new Schema({ 8 | url: { type: Url, required: true }, 9 | mimetype: { type: String, required: true }, 10 | size: { type: Number, required: true }, 11 | }); 12 | 13 | const Video = mongoose.model('Video', videoSchema); 14 | 15 | module.exports = Video; 16 | -------------------------------------------------------------------------------- /src/services/department/department-model.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | const Schema = mongoose.Schema; 4 | 5 | const departmentSchema = new Schema({ 6 | created_time: { type: Date, default: Date.now }, 7 | detail: { type: String }, 8 | name: { type: String, required: true }, 9 | updated_time: { type: Date, default: Date.now }, 10 | }); 11 | 12 | const Department = mongoose.model('Department', departmentSchema); 13 | 14 | module.exports = Department; 15 | -------------------------------------------------------------------------------- /src/middleware/attach-file-to-feathers.js: -------------------------------------------------------------------------------- 1 | // Middleware to attach a file from multer (uploader) to the req object 2 | module.exports = function () { // eslint-disable-line func-names 3 | return (req, res, next) => { 4 | // Bypass this middleware if it's not a POST request or file is not available 5 | if (req.method.toLowerCase() === 'post' && req.file) { 6 | req.feathers.file = req.file; // eslint-disable-line no-param-reassign 7 | } 8 | 9 | next(); 10 | }; 11 | }; 12 | -------------------------------------------------------------------------------- /test/services/app3rd/index.test.js: -------------------------------------------------------------------------------- 1 | // Test helper functions 2 | const assertTestEnv = require('../../test_helper').assertTestEnv; 3 | const expect = require('../../test_helper').expect; 4 | 5 | // App stuff 6 | const app = require('../../../src/app'); 7 | 8 | // Exit test if NODE_ENV is not equal `test` 9 | assertTestEnv(); 10 | 11 | describe('app3rd service', () => { 12 | it('registered the app3rds service', () => { 13 | expect(app.service('app3rds')).to.be.ok(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/services/app3rd/app3rd-model.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | const Schema = mongoose.Schema; 4 | 5 | const app3rdSchema = new Schema({ 6 | apikey: { type: String, required: true }, 7 | createdAt: { type: Date, default: Date.now }, 8 | homepage: { type: String }, 9 | name: { type: String, required: true }, 10 | updatedAt: { type: Date, default: Date.now }, 11 | }); 12 | 13 | const App3rd = mongoose.model('App3rd', app3rdSchema); 14 | 15 | module.exports = App3rd; 16 | -------------------------------------------------------------------------------- /test/fixtures/organizations.js: -------------------------------------------------------------------------------- 1 | const ObjectId = require('mongoose').Types.ObjectId; 2 | const ORGANIZATION_ID = require('./constants').ORGANIZATION_ID; 3 | 4 | module.exports = [ 5 | { 6 | _id: ObjectId(ORGANIZATION_ID), // eslint-disable-line new-cap 7 | name: 'YouPin', 8 | // superAdminUser, organizationAdminUser, departmentHeadUser 9 | users: ['579334c74443625d6281b6ff', '579334c74443625d6281b6dd', '579334c75553625d6281b6cc'], 10 | detail: 'An awesome organization', 11 | }, 12 | ]; 13 | -------------------------------------------------------------------------------- /test/services/activity-log/index.test.js: -------------------------------------------------------------------------------- 1 | // Test helper functions 2 | const assertTestEnv = require('../../test_helper').assertTestEnv; 3 | const expect = require('../../test_helper').expect; 4 | 5 | // App stuff 6 | const app = require('../../../src/app'); 7 | 8 | // Exit test if NODE_ENV is not equal `test` 9 | assertTestEnv(); 10 | 11 | describe('ActivityLog service', () => { 12 | it('registered the activity_logs service', () => { 13 | expect(app.service('activity_logs')).to.be.ok(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /src/middleware/index.js: -------------------------------------------------------------------------------- 1 | const handler = require('feathers-errors/handler'); 2 | 3 | const logger = require('./logger'); 4 | const notFound = require('./not-found-handler'); 5 | 6 | module.exports = function () { // eslint-disable-line func-names 7 | // Add your custom middleware here. Remember, that 8 | // just like Express the order matters, so error 9 | // handling middleware should go last. 10 | const app = this; 11 | 12 | app.use(notFound()); 13 | app.use(logger(app)); 14 | app.use(handler()); 15 | }; 16 | -------------------------------------------------------------------------------- /src/middleware/prepare-multipart.js: -------------------------------------------------------------------------------- 1 | // Middleware to handle file uploading 2 | module.exports = function (fieldName, fileSize) { // eslint-disable-line func-names 3 | const uploader = require('../utils/uploader')(fileSize); // eslint-disable-line global-require 4 | 5 | return (req, res, next) => { 6 | // Bypass this middleware if it's not a POST request 7 | if (req.method.toLowerCase() === 'post') { 8 | return uploader.single(fieldName)(req, res, next); 9 | } 10 | 11 | return next(); 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /src/services/organization/organization-model.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | const Schema = mongoose.Schema; 4 | 5 | const organizationSchema = new Schema({ 6 | name: { type: String, required: true, unique: true }, 7 | users: [{ type: Schema.Types.ObjectId, ref: 'User' }], 8 | detail: { type: String }, 9 | created_time: { type: Date, default: Date.now }, 10 | updated_time: { type: Date, default: Date.now }, 11 | }); 12 | 13 | const Organization = mongoose.model('Organization', organizationSchema); 14 | 15 | module.exports = Organization; 16 | -------------------------------------------------------------------------------- /src/services/summary/index.js: -------------------------------------------------------------------------------- 1 | const service = require('feathers-mongoose'); 2 | 3 | const hooks = require('./hooks'); 4 | 5 | const Summary = require('./summary-model'); 6 | 7 | module.exports = function () { // eslint-disable-line func-names 8 | const app = this; 9 | 10 | const options = { 11 | Model: Summary, 12 | paginate: { 13 | default: 5, 14 | max: 25, 15 | }, 16 | }; 17 | 18 | app.use('/summaries', service(options)); 19 | const summaryService = app.service('/summaries'); 20 | summaryService.before(hooks.before); 21 | summaryService.after(hooks.after); 22 | }; 23 | -------------------------------------------------------------------------------- /src/utils/hooks/restrict-to-owner-of-pin-hook.js: -------------------------------------------------------------------------------- 1 | const errors = require('feathers-errors'); 2 | 3 | const restrictToOwnerOfPin = () => (hook) => { 4 | const pinOwner = hook.data.owner; 5 | const tokenOwner = hook.params.user._id.toString(); // eslint-disable-line no-underscore-dangle 6 | if (!pinOwner) { 7 | throw new Error('owner field should be provided'); 8 | } 9 | if (pinOwner !== tokenOwner) { 10 | throw new errors.NotAuthenticated( 11 | 'Owner field (id) does not matched with the token owner id.'); 12 | } 13 | return Promise.resolve(hook); 14 | }; 15 | 16 | module.exports = restrictToOwnerOfPin; 17 | -------------------------------------------------------------------------------- /test/fixtures/super_admin_user.js: -------------------------------------------------------------------------------- 1 | const DEPARTMENT_SUPER_ADMIN_ID = require('./constants').DEPARTMENT_SUPER_ADMIN_ID; 2 | const USER_SUPER_ADMIN_ID = require('./constants').USER_SUPER_ADMIN_ID; 3 | // roles 4 | const SUPER_ADMIN = require('../../src/constants/roles').SUPER_ADMIN; 5 | 6 | module.exports = { 7 | _id: USER_SUPER_ADMIN_ID, 8 | name: 'YouPin Super Admin', 9 | phone: '081-985-2586', 10 | fb_id: 'youpin_fb_id', 11 | // hash of 'youpin_admin' password 12 | password: '$2a$10$iorOMFOPboPeF20W20DKruey2UXXa4eOQSuReOMlxXnqNe5t6Egaq', 13 | email: 'super_admin@youpin.city', 14 | department: DEPARTMENT_SUPER_ADMIN_ID, 15 | role: SUPER_ADMIN, 16 | }; 17 | -------------------------------------------------------------------------------- /test/fixtures/normal_user.js: -------------------------------------------------------------------------------- 1 | const USER = require('../../src/constants/roles').USER; 2 | 3 | module.exports = { 4 | _id: '579334c74443625d6281b699', 5 | name: 'YouPin Normal User', 6 | phone: '081-111-1111', 7 | fb_id: 'youpin_fb_id', 8 | // hash of 'youpin_user' password 9 | password: '$2a$10$VnzRyMnaSHEBwuHHc0TT8OJsmDjUoHUtJ2WydUbvHQbDQ0Okr.GvG', 10 | email: 'user@youpin.city', 11 | organization_and_role_pairs: [ 12 | { organization: '579334c74443625d6281b6dd', role: USER }, 13 | ], 14 | organization_and_department_pairs: [ 15 | { organization: '579334c74443625d6281b6dd', department: '57933111556362511181bbb1' }, 16 | ], 17 | role: USER, 18 | }; 19 | -------------------------------------------------------------------------------- /test/fixtures/admin_user.js: -------------------------------------------------------------------------------- 1 | const ObjectId = require('mongoose').Types.ObjectId; 2 | 3 | const DEPARTMENT_SUPER_ADMIN_ID = require('./constants').DEPARTMENT_SUPER_ADMIN_ID; 4 | const USER_ADMIN_ID = require('./constants').USER_ADMIN_ID; 5 | 6 | module.exports = { 7 | _id: ObjectId(USER_ADMIN_ID), // eslint-disable-line new-cap 8 | name: 'YouPin Admin', 9 | phone: '081-985-2586', 10 | fb_id: 'youpin_fb_id', 11 | // hash of 'youpin_admin' password 12 | password: '$2a$10$iorOMFOPboPeF20W20DKruey2UXXa4eOQSuReOMlxXnqNe5t6Egaq', 13 | email: 'contact@youpin.city', 14 | department: ObjectId(DEPARTMENT_SUPER_ADMIN_ID), // eslint-disable-line new-cap 15 | role: 'super_admin', 16 | }; 17 | -------------------------------------------------------------------------------- /test/services/pin/hooks/process.test.js: -------------------------------------------------------------------------------- 1 | // Test helper functions 2 | const assertTestEnv = require('../../../test_helper').assertTestEnv; 3 | const expect = require('../../../test_helper').expect; 4 | 5 | // App stuff 6 | const process = require('../../../../src/services/pin/hooks/process'); 7 | 8 | // Exit test if NODE_ENV is not equal `test` 9 | assertTestEnv(); 10 | 11 | describe('Pin process hook', () => { 12 | it('hook can be used', () => { 13 | const mockHook = { 14 | type: 'before', 15 | app: {}, 16 | params: {}, 17 | result: {}, 18 | data: {}, 19 | }; 20 | 21 | process()(mockHook); 22 | 23 | expect(mockHook.process).to.be.ok(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/services/pin/hooks/prepare-notif-info-for-created-pin.js: -------------------------------------------------------------------------------- 1 | // Roles 2 | const ORGANIZATION_ADMIN = require('../../../constants/roles').ORGANIZATION_ADMIN; 3 | 4 | // We assign related department/roles/users to be notified here which will 5 | // get processed again in after hook by sendNotifToRelatedUsers() 6 | const prepareNotifInfoForCreatedPin = () => (hook) => { 7 | hook.data.toBeNotifiedRoles = [ORGANIZATION_ADMIN]; // eslint-disable-line no-param-reassign 8 | hook.data.logInfo = { // eslint-disable-line no-param-reassign 9 | description: 'A new pin is created.', 10 | pin_id: hook.result._id, // eslint-disable-line no-underscore-dangle 11 | }; 12 | }; 13 | 14 | module.exports = prepareNotifInfoForCreatedPin; 15 | -------------------------------------------------------------------------------- /test/fixtures/public_relations_user.js: -------------------------------------------------------------------------------- 1 | const DEPARTMENT_PUBLIC_RELATIONS_ID = require('./constants').DEPARTMENT_PUBLIC_RELATIONS_ID; 2 | const USER_PUBLIC_RELATIONS_ID = require('./constants').USER_PUBLIC_RELATIONS_ID; 3 | // roles 4 | const PUBLIC_RELATIONS = require('../../src/constants/roles').PUBLIC_RELATIONS; 5 | 6 | module.exports = { 7 | _id: USER_PUBLIC_RELATIONS_ID, 8 | name: 'YouPin Public Relations', 9 | phone: '081-985-2586', 10 | fb_id: 'youpin_fb_id', 11 | // hash of 'youpin_public_relations' password 12 | password: '$2a$10$wtr6UJqzMRDbe5e6xypVrecxM8jtSIdD4pw8hf6QEPEAMUV1ctH/a', 13 | email: 'public_relations@youpin.city', 14 | department: DEPARTMENT_PUBLIC_RELATIONS_ID, 15 | role: PUBLIC_RELATIONS, 16 | }; 17 | -------------------------------------------------------------------------------- /src/views/login.pug: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | block content 4 | .container 5 | h1 Login Page 6 | p= info 7 | p= error 8 | br 9 | form(role='form', action="/login",method="post", style='max-width: 300px;') 10 | .form-group 11 | input.form-control(type='text', name="email", placeholder='Enter Email') 12 | .form-group 13 | input.form-control(type='password', name="password", placeholder='Password') 14 | input.form-control(type='hidden', name="redirect_uri", value=redirect_uri) 15 | button.form-control.btn.btn-success(type='submit') Log In 16 | p.text-center OR 17 | a(href='/signup?redirect_uri=' + redirect_uri) 18 | button.btn.btn-primary.btn-block(type="button") Sign Up 19 | -------------------------------------------------------------------------------- /src/services/app3rd/index.js: -------------------------------------------------------------------------------- 1 | const service = require('feathers-mongoose'); 2 | 3 | const App3rd = require('./app3rd-model'); 4 | const hooks = require('./hooks'); 5 | 6 | module.exports = function () { // eslint-disable-line func-names 7 | const app = this; 8 | 9 | const options = { 10 | Model: App3rd, 11 | paginate: { 12 | default: 5, 13 | max: 25, 14 | }, 15 | }; 16 | 17 | // Initialize our service with any options it requires 18 | app.use('/app3rds', service(options)); 19 | 20 | // Get our initialize service to that we can bind hooks 21 | const app3rdService = app.service('/app3rds'); 22 | 23 | // Set up our before hooks 24 | app3rdService.before(hooks.before); 25 | 26 | // Set up our after hooks 27 | app3rdService.after(hooks.after); 28 | }; 29 | -------------------------------------------------------------------------------- /test/fixtures/department_head_user.js: -------------------------------------------------------------------------------- 1 | const ObjectId = require('mongoose').Types.ObjectId; 2 | 3 | const DEPARTMENT_HEAD = require('../../src/constants/roles').DEPARTMENT_HEAD; 4 | const DEPARTMENT_GENERAL_ID = require('./constants').DEPARTMENT_GENERAL_ID; 5 | const USER_DEPARTMENT_HEAD_ID = require('./constants').USER_DEPARTMENT_HEAD_ID; 6 | 7 | module.exports = { 8 | _id: USER_DEPARTMENT_HEAD_ID, // eslint-disable-line new-cap 9 | name: 'YouPin Department Head', 10 | phone: '081-985-2586', 11 | fb_id: 'youpin_fb_id', 12 | // hash of 'youpin_admin' password 13 | password: '$2a$10$iorOMFOPboPeF20W20DKruey2UXXa4eOQSuReOMlxXnqNe5t6Egaq', 14 | email: 'department_head@youpin.city', 15 | department: ObjectId(DEPARTMENT_GENERAL_ID), // eslint-disable-line new-cap 16 | role: DEPARTMENT_HEAD, 17 | }; 18 | -------------------------------------------------------------------------------- /src/views/signup.pug: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | block content 4 | .container 5 | h1 Signup Page 6 | p= error 7 | br 8 | form(role='form', action="/signup",method="post", style='max-width: 300px;') 9 | .form-group 10 | input.form-control(type='text', name="name", placeholder='Enter your full name') 11 | .form-group 12 | input.form-control(type='text', name="email", placeholder='Enter your email') 13 | .form-group 14 | input.form-control(type='password', name="password", placeholder='Enter your wanted password') 15 | input.form-control(type='hidden', name="redirect_uri", value=redirect_uri) 16 | button.btn.btn-default(type='submit') Sign Up 17 | a(href='/login?redirect_uri=' + redirect_uri) 18 | button.btn.btn-primary(type="button") Cancel 19 | -------------------------------------------------------------------------------- /src/services/department/index.js: -------------------------------------------------------------------------------- 1 | const service = require('feathers-mongoose'); 2 | 3 | const Department = require('./department-model'); 4 | const hooks = require('./hooks'); 5 | 6 | module.exports = function () { // eslint-disable-line func-names 7 | const app = this; 8 | 9 | const options = { 10 | Model: Department, 11 | paginate: { 12 | default: 5, 13 | max: 25, 14 | }, 15 | }; 16 | 17 | // Initialize our service with any options it requires 18 | app.use('/departments', service(options)); 19 | 20 | // Get our initialize service to that we can bind hooks 21 | const departmentService = app.service('/departments'); 22 | 23 | // Set up our before hooks 24 | departmentService.before(hooks.before); 25 | 26 | // Set up our after hooks 27 | departmentService.after(hooks.after); 28 | }; 29 | -------------------------------------------------------------------------------- /src/services/organization/index.js: -------------------------------------------------------------------------------- 1 | const service = require('feathers-mongoose'); 2 | 3 | const hooks = require('./hooks'); 4 | const Organization = require('./organization-model'); 5 | 6 | module.exports = function () { // eslint-disable-line func-names 7 | const app = this; 8 | 9 | const options = { 10 | Model: Organization, 11 | paginate: { 12 | default: 5, 13 | max: 25, 14 | }, 15 | }; 16 | 17 | // Initialize our service with any options it requires 18 | app.use('/organizations', service(options)); 19 | 20 | // Get our initialize service to that we can bind hooks 21 | const organizationService = app.service('/organizations'); 22 | 23 | // Set up our before hooks 24 | organizationService.before(hooks.before); 25 | 26 | // Set up our after hooks 27 | organizationService.after(hooks.after); 28 | }; 29 | -------------------------------------------------------------------------------- /test/fixtures/department_officer_user.js: -------------------------------------------------------------------------------- 1 | const ObjectId = require('mongoose').Types.ObjectId; 2 | 3 | const DEPARTMENT_OFFICER = require('../../src/constants/roles').DEPARTMENT_OFFICER; 4 | const DEPARTMENT_GENERAL_ID = require('./constants').DEPARTMENT_GENERAL_ID; 5 | const USER_DEPARTMENT_OFFICER_ID = require('./constants').USER_DEPARTMENT_OFFICER_ID; 6 | 7 | module.exports = { 8 | _id: USER_DEPARTMENT_OFFICER_ID, // eslint-disable-line new-cap 9 | name: 'YouPin Department Officer', 10 | phone: '081-985-2586', 11 | fb_id: 'youpin_fb_id', 12 | // hash of 'youpin_department_officer' password 13 | password: '$2a$10$oSKoBrWehNJr.YRWuY5j0uCZrn2QZvsVPofpxpTdISjZI.ukEIitG', 14 | email: 'department_officer@youpin.city', 15 | department: ObjectId(DEPARTMENT_GENERAL_ID), // eslint-disable-line new-cap 16 | role: DEPARTMENT_OFFICER, 17 | }; 18 | -------------------------------------------------------------------------------- /src/services/pin-state-transition/hooks/index.js: -------------------------------------------------------------------------------- 1 | const auth = require('feathers-authentication').hooks; 2 | 3 | const logActivity = require('../../../utils/hooks/log-activity'); 4 | const sendNotifToRelatedUsers = require('../../../utils/hooks/send-notif-to-related-users'); 5 | const prepareActivityLog = require('./prepare-activity-log'); 6 | 7 | exports.before = { 8 | all: [], 9 | find: [], 10 | get: [], 11 | create: [ 12 | auth.verifyToken(), 13 | auth.populateUser(), 14 | auth.restrictToAuthenticated(), 15 | prepareActivityLog(), 16 | ], 17 | update: [], 18 | patch: [], 19 | remove: [], 20 | }; 21 | 22 | exports.after = { 23 | all: [], 24 | find: [], 25 | get: [], 26 | create: [ 27 | logActivity(), 28 | sendNotifToRelatedUsers(), 29 | ], 30 | update: [], 31 | patch: [], 32 | remove: [], 33 | }; 34 | -------------------------------------------------------------------------------- /src/services/activity-log/activity-log-model.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | const Schema = mongoose.Schema; 4 | const activityLogSchema = new Schema({ 5 | action: { type: String }, // See 'src/constants/actions.js' 6 | actionType: { type: String }, // See 'src/constants/actions.js' 7 | changed_fields: [{ type: String }], 8 | department: { type: String }, 9 | description: { type: String }, // Human readable description 10 | organization: { type: String }, 11 | pin_id: { type: Schema.Types.ObjectId, ref: 'Pin', required: true }, 12 | previous_values: [{ type: String }], 13 | timestamp: { type: Date, required: true, default: Date.now }, 14 | updated_values: [{ type: String }], 15 | user: { type: String }, 16 | }); 17 | 18 | const ActivityLog = mongoose.model('ActivityLog', activityLogSchema); 19 | 20 | module.exports = ActivityLog; 21 | -------------------------------------------------------------------------------- /test/fixtures/organization_admin_user.js: -------------------------------------------------------------------------------- 1 | const ObjectId = require('mongoose').Types.ObjectId; 2 | 3 | const DEPARTMENT_ORGANIZATION_ADMIN_ID = require('./constants').DEPARTMENT_ORGANIZATION_ADMIN_ID; 4 | const ORGANIZATION_ADMIN = require('../../src/constants/roles').ORGANIZATION_ADMIN; 5 | const USER_ORGANIZATION_ADMIN_ID = require('./constants').USER_ORGANIZATION_ADMIN_ID; 6 | 7 | module.exports = { 8 | _id: ObjectId(USER_ORGANIZATION_ADMIN_ID), // eslint-disable-line new-cap 9 | name: 'YouPin Organization Admin', 10 | phone: '081-985-2586', 11 | fb_id: 'youpin_fb_id', 12 | // hash of 'youpin_admin' password 13 | password: '$2a$10$iorOMFOPboPeF20W20DKruey2UXXa4eOQSuReOMlxXnqNe5t6Egaq', 14 | email: 'organization_admin@youpin.city', 15 | department: ObjectId(DEPARTMENT_ORGANIZATION_ADMIN_ID), // eslint-disable-line new-cap 16 | role: ORGANIZATION_ADMIN, 17 | }; 18 | -------------------------------------------------------------------------------- /src/utils/send-bot-notification.js: -------------------------------------------------------------------------------- 1 | // Trigger bot to send 'message' to the specified user 'id'. 2 | const request = require('superagent'); 3 | 4 | const sendBotNotification = (botUrl, notificationToken, id, message) => { 5 | if (!botUrl || !notificationToken) { 6 | return Promise.reject('No proper bot config. The notification will not be sent.'); 7 | } 8 | return new Promise((resolve, reject) => request 9 | .post(botUrl) 10 | .query({ NOTIFICATION_TOKEN: notificationToken }) 11 | .send({ id, message }) 12 | .end((err, res) => { 13 | if (err) { 14 | return reject(err); 15 | } 16 | if (res.status !== 200) { 17 | return reject( 18 | `Failed to send notification to Bot:${botUrl} with token:${notificationToken}`); 19 | } 20 | return resolve(res.body); 21 | }) 22 | ); 23 | }; 24 | 25 | module.exports = sendBotNotification; 26 | -------------------------------------------------------------------------------- /ecosystem.production.json: -------------------------------------------------------------------------------- 1 | { 2 | "apps" : [ 3 | { 4 | "name" : "youpin-api-production", 5 | "script" : "src/", 6 | "no-daemon" : true, 7 | "log_date_format" : "YYYY-MM-DD HH:mm", 8 | "watch" : true, 9 | "ignore_watch" : ["node_modules", "data", ".git"], 10 | "env_production": { 11 | NODE_ENV: "production" 12 | } 13 | } 14 | ], 15 | "deploy" : { 16 | "production" : { 17 | "user" : "root", 18 | "host" : ["128.199.87.142"], 19 | "ref" : "origin/master", 20 | "repo" : "git@github.com:youpin-city/youpin-api.git", 21 | "path" : "/opt/youpin-api.production", 22 | "post-deploy" : "npm install --production && pm2 startOrRestart ecosystem.production.json --env production", 23 | "pre-deploy-local" : "echo '[production] deploy completed.'", 24 | "env" : { 25 | NODE_ENV: "production" 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/services/summary/summary-model.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | const Schema = mongoose.Schema; 4 | 5 | const summarySchema = new Schema({ 6 | organization: { type: String, required: true }, 7 | date: { 8 | type: String, 9 | required: true, 10 | validate: { 11 | validator: (v) => /\d{4}-\d{2}-\d{2}/.test(v), 12 | message: '{VALUE} has to be in YYYY-MM-DD format!', 13 | }, 14 | }, 15 | total_pending: { type: Number }, 16 | total_assigned: { type: Number }, 17 | total_processing: { type: Number }, 18 | total_resolved: { type: Number }, 19 | total_rejected: { type: Number }, 20 | by_department: Schema.Types.Mixed, 21 | createdAt: { type: Date, default: Date.now }, 22 | updatedAt: { type: Date, default: Date.now }, 23 | }); 24 | 25 | summarySchema.index({ organization: 1, date: 1 }, { unique: true }); 26 | 27 | const Summary = mongoose.model('Summary', summarySchema); 28 | 29 | module.exports = Summary; 30 | -------------------------------------------------------------------------------- /ecosystem.development.json: -------------------------------------------------------------------------------- 1 | { 2 | "apps" : [ 3 | { 4 | "name" : "youpin-api-development", 5 | "script" : "src/", 6 | "no-daemon" : true, 7 | "log_date_format" : "YYYY-MM-DD HH:mm", 8 | "watch" : true, 9 | "ignore_watch" : ["node_modules", "data", ".git"], 10 | "env": { 11 | NODE_ENV: "local" 12 | }, 13 | "env_development": { 14 | NODE_ENV: "development" 15 | } 16 | } 17 | ], 18 | "deploy" : { 19 | "development" : { 20 | "user" : "root", 21 | "host" : ["128.199.87.142"], 22 | "ref" : "origin/master", 23 | "repo" : "git@github.com:youpin-city/youpin-api.git", 24 | "path" : "/opt/youpin-api.development", 25 | "post-deploy" : "npm install --production && pm2 startOrRestart ecosystem.development.json --env development", 26 | "pre-deploy-local" : "echo '[development] deploy completed.'", 27 | "env" : { 28 | NODE_ENV: "development" 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/services/app3rd/hooks/index.js: -------------------------------------------------------------------------------- 1 | const auth = require('feathers-authentication').hooks; 2 | const uuid = require('uuid'); 3 | 4 | function generateAPIKey() { 5 | return (hook) => { 6 | hook.data.apikey = uuid.v4(); // eslint-disable-line no-param-reassign 7 | hook.tmpdata = { apikey: hook.data.apikey }; // eslint-disable-line no-param-reassign 8 | }; 9 | } 10 | 11 | function returnAPIKeyFromTmpData() { 12 | return (hook) => { 13 | hook.result.apikey = hook.tmpdata.apikey; // eslint-disable-line no-param-reassign 14 | }; 15 | } 16 | 17 | exports.before = { 18 | all: [ 19 | auth.verifyToken(), 20 | auth.populateUser(), 21 | auth.restrictToAuthenticated(), 22 | ], 23 | find: [], 24 | get: [], 25 | create: [ 26 | generateAPIKey(), 27 | auth.hashPassword({ passwordField: 'apikey' }), 28 | ], 29 | update: [], 30 | patch: [], 31 | remove: [], 32 | }; 33 | 34 | exports.after = { 35 | all: [], 36 | find: [], 37 | get: [], 38 | create: [ 39 | returnAPIKeyFromTmpData(), 40 | ], 41 | update: [], 42 | patch: [], 43 | remove: [], 44 | }; 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Credentials 2 | youpin_credentials.json 3 | youpin_gcs_credentials.json 4 | deploy_dev.sh 5 | # Config containing credentials 6 | config/development.json 7 | config/production.json 8 | config/default.json 9 | config/gcs/youpin_gcs_credentials_development.json 10 | config/gcs/youpin_gcs_credentials_production.json 11 | 12 | # Temp files 13 | *.swp 14 | 15 | # macOS files 16 | .DS_Store 17 | 18 | # Logs 19 | logs 20 | *.log 21 | 22 | # Runtime data 23 | pids 24 | *.pid 25 | *.seed 26 | 27 | # Directory for instrumented libs generated by jscoverage/JSCover 28 | lib-cov 29 | 30 | # Coverage directory used by tools like istanbul 31 | coverage 32 | 33 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 34 | .grunt 35 | 36 | # Compiled binary addons (http://nodejs.org/api/addons.html) 37 | build/Release 38 | 39 | # Dependency directory 40 | # Commenting this out is preferred by some people, see 41 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 42 | node_modules 43 | 44 | # Users Environment Variables 45 | .lock-wscript 46 | 47 | lib/ 48 | data/ 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Youpin ยุพิน 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/services/pin-merging/hooks/index.js: -------------------------------------------------------------------------------- 1 | const auth = require('feathers-authentication').hooks; 2 | 3 | const logActivity = require('./log-activity'); 4 | const prepareActivityLog = require('./prepare-activity-log'); 5 | 6 | const { 7 | DEPARTMENT_HEAD, 8 | DEPARTMENT_OFFICER, 9 | EXECUTIVE_ADMIN, 10 | ORGANIZATION_ADMIN, 11 | PUBLIC_RELATIONS, 12 | SUPER_ADMIN, 13 | } = require('../../../constants/roles'); 14 | 15 | exports.before = { 16 | all: [], 17 | find: [], 18 | get: [], 19 | create: [ 20 | auth.verifyToken(), 21 | auth.populateUser(), 22 | auth.restrictToAuthenticated(), 23 | auth.restrictToRoles({ 24 | roles: [ 25 | DEPARTMENT_HEAD, 26 | DEPARTMENT_OFFICER, 27 | EXECUTIVE_ADMIN, 28 | ORGANIZATION_ADMIN, 29 | PUBLIC_RELATIONS, 30 | SUPER_ADMIN, 31 | ], 32 | fieldName: 'role', 33 | }), 34 | prepareActivityLog(), 35 | ], 36 | update: [], 37 | patch: [], 38 | remove: [], 39 | }; 40 | 41 | exports.after = { 42 | all: [], 43 | find: [], 44 | get: [], 45 | create: [logActivity()], 46 | update: [], 47 | patch: [], 48 | remove: [], 49 | }; 50 | -------------------------------------------------------------------------------- /test/fixtures/departments.js: -------------------------------------------------------------------------------- 1 | const ObjectId = require('mongoose').Types.ObjectId; 2 | 3 | const DEPARTMENT_GENERAL_ID = require('./constants').DEPARTMENT_GENERAL_ID; 4 | const DEPARTMENT_GENERAL_NAME = require('./constants').DEPARTMENT_GENERAL_NAME; 5 | const DEPARTMENT_PUBLIC_RELATIONS_ID = require('./constants').DEPARTMENT_PUBLIC_RELATIONS_ID; 6 | const DEPARTMENT_PUBLIC_RELATIONS_NAME = require('./constants').DEPARTMENT_PUBLIC_RELATIONS_NAME; 7 | const DEPARTMENT_SUPER_ADMIN_ID = require('./constants').DEPARTMENT_SUPER_ADMIN_ID; 8 | const DEPARTMENT_SUPER_ADMIN_NAME = require('./constants').DEPARTMENT_SUPER_ADMIN_NAME; 9 | 10 | module.exports = [ 11 | { 12 | _id: ObjectId(DEPARTMENT_SUPER_ADMIN_ID), // eslint-disable-line new-cap 13 | name: DEPARTMENT_SUPER_ADMIN_NAME, 14 | detail: 'Admins live here', 15 | }, 16 | { 17 | _id: ObjectId(DEPARTMENT_GENERAL_ID), // eslint-disable-line new-cap 18 | name: DEPARTMENT_GENERAL_NAME, 19 | detail: 'An awesome department', 20 | }, 21 | { 22 | _id: ObjectId(DEPARTMENT_PUBLIC_RELATIONS_ID), // eslint-disable-line new-cap 23 | name: DEPARTMENT_PUBLIC_RELATIONS_NAME, 24 | detail: 'PR officers live here', 25 | }, 26 | ]; 27 | -------------------------------------------------------------------------------- /src/services/summary/hooks/modify-search-query.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const errors = require('feathers-errors'); 3 | 4 | // modifySearchQuery is a before hook function to transform a summary 5 | // query into the correct mongoose format. 6 | const modifySearchQuery = () => (hook) => { 7 | const startDate = hook.params.query.start_date; 8 | const endDate = hook.params.query.end_date; 9 | const organizationName = hook.params.query.organization; 10 | const date = {}; 11 | // Clean slate the query. 12 | hook.params.query = {}; // eslint-disable-line no-param-reassign 13 | // Change start_date and end_date to mongoose query format. 14 | if (startDate) { 15 | date.$gte = startDate; 16 | } 17 | if (endDate) { 18 | date.$lte = endDate; 19 | } 20 | if (!_.isEmpty(date)) { 21 | hook.params.query.date = date; // eslint-disable-line no-param-reassign 22 | } 23 | // Return error if no organization is specified. 24 | if (!organizationName) { 25 | throw new errors.BadRequest('No `organization` specified in a query'); 26 | } 27 | hook.params.query.organization = organizationName; // eslint-disable-line no-param-reassign 28 | return Promise.resolve(hook); 29 | }; 30 | 31 | module.exports = modifySearchQuery; 32 | -------------------------------------------------------------------------------- /src/constants/actions.js: -------------------------------------------------------------------------------- 1 | // Action constants for activity logging 2 | module.exports = { 3 | // action types 4 | types: { 5 | MERGING: 'ACTION_TYPE/MERGING', 6 | METADATA: 'ACTION_TYPE/METADATA', 7 | PROGRESS: 'ACTION_TYPE/PROGRESS', 8 | STATE_TRANSITION: 'ACTION_TYPE/STATE_TRANSITION', 9 | }, 10 | // actions for state transition type 11 | ASSIGN: 'STATE_TRANSITION/ASSIGN', // PENDING -> ASSIGNED 12 | DENY: 'STATE_TRANSITION/DENY', // ASSIGNED -> PENDING 13 | PROCESS: 'STATE_TRANSITION/PROCESS', // ASSIGNED -> PROCESSING 14 | REJECT: 'STATE_TRANSITION/REJECT', // PENDING -> REJECT 15 | RE_OPEN: 'STATE_TRANSITION/RE_OPEN', // RESOLVED/REJECTED -> PENDING 16 | RE_PROCESS: 'STATE_TRANSITION/RE_PROCESS', // RESOLVED -> PROCESSING 17 | RESOLVE: 'STATE_TRANSITION/RESOLVE', // PROCESSING -> RESOLVED 18 | // actions for metadata type 19 | CREATE_PIN: 'METADATA/CREATE_PIN', 20 | DELETE_PIN: 'METADATA/DELETE_PIN', 21 | UPDATE_PIN: 'METADATA/UPDATE_PIN', 22 | // actions for progress type 23 | CREATE_PROGRESS: 'PROGRESS/CREATE_PROGRESS', 24 | DELETE_PROGRESS: 'PROGRESS/DELETE_PROGRESS', 25 | UPDATE_PROGRESS: 'PROGRESS/UPDATE_PROGRESS', 26 | // actions for pin merging type 27 | MERGE_PIN: 'MERGING/MERGE_PIN', 28 | }; 29 | -------------------------------------------------------------------------------- /src/services/organization/hooks/index.js: -------------------------------------------------------------------------------- 1 | const auth = require('feathers-authentication').hooks; 2 | 3 | const validateObjectId = require('../../../utils/hooks/validate-object-id-hook'); 4 | const SUPER_ADMIN = require('../../../constants/roles').SUPER_ADMIN; 5 | 6 | exports.before = { 7 | all: [], 8 | find: [], 9 | get: [ 10 | validateObjectId(), 11 | ], 12 | create: [ 13 | auth.verifyToken(), 14 | auth.populateUser(), 15 | auth.restrictToRoles({ 16 | roles: [SUPER_ADMIN], 17 | fieldName: 'role', 18 | }), 19 | ], 20 | update: [ 21 | auth.verifyToken(), 22 | auth.populateUser(), 23 | auth.restrictToRoles({ 24 | roles: [SUPER_ADMIN], 25 | fieldName: 'role', 26 | }), 27 | ], 28 | patch: [ 29 | auth.verifyToken(), 30 | auth.populateUser(), 31 | auth.restrictToRoles({ 32 | roles: [SUPER_ADMIN], 33 | fieldName: 'role', 34 | }), 35 | ], 36 | remove: [ 37 | auth.verifyToken(), 38 | auth.populateUser(), 39 | auth.restrictToRoles({ 40 | roles: [SUPER_ADMIN], 41 | fieldName: 'role', 42 | }), 43 | ], 44 | }; 45 | 46 | exports.after = { 47 | all: [], 48 | find: [], 49 | get: [], 50 | create: [], 51 | update: [], 52 | patch: [], 53 | remove: [], 54 | }; 55 | -------------------------------------------------------------------------------- /src/services/user/user-model.js: -------------------------------------------------------------------------------- 1 | const validator = require('validator'); 2 | const mongoose = require('mongoose'); 3 | 4 | const USER = require('../../constants/roles').USER; 5 | 6 | const Schema = mongoose.Schema; 7 | 8 | const userSchema = new Schema({ 9 | name: { type: String, required: true }, 10 | email: { 11 | type: String, 12 | unique: true, 13 | validate: { validator: validator.isEmail, message: '{VALUE} is not a valid email!' }, 14 | }, 15 | password: { type: String }, 16 | facebookId: { type: String }, 17 | phone: { 18 | type: String, 19 | validate: { 20 | validator: (v) => validator.matches(v, /[0-9]{3}-[0-9]{3}-[0-9]{4}/), 21 | message: '{VALUE} is not a valid phone number!', 22 | }, 23 | }, 24 | department: { type: Schema.Types.ObjectId, ref: 'Department' }, 25 | created_time: { type: Date, default: Date.now }, 26 | updated_time: { type: Date, default: Date.now }, 27 | customer_app_id: [Schema.Types.ObjectId], 28 | role: { type: String, required: true, default: USER }, 29 | owner_app_id: [Schema.Types.ObjectId], 30 | }); 31 | 32 | userSchema.pre('find', function populateDepartment(next) { 33 | this.populate('department'); 34 | next(); 35 | }); 36 | 37 | const User = mongoose.model('User', userSchema); 38 | 39 | module.exports = User; 40 | -------------------------------------------------------------------------------- /test/test_helper.js: -------------------------------------------------------------------------------- 1 | const chai = require('chai'); 2 | const dirtyChai = require('dirty-chai'); 3 | const loadFixture = require('mongoose-fixture-loader'); 4 | const mongoose = require('mongoose'); 5 | const sinon = require('sinon'); 6 | const sinonChai = require('sinon-chai'); 7 | const request = require('supertest-as-promised'); 8 | 9 | const expect = chai.expect; 10 | const spy = sinon.spy; 11 | const stub = sinon.stub; 12 | 13 | chai.use(dirtyChai); 14 | chai.use(sinonChai); 15 | 16 | const assertTestEnv = () => { 17 | // Makes sure that this is actually TEST environment 18 | if (process.env.NODE_ENV !== 'test') { 19 | console.log('Woops, you want NODE_ENV=test before you try this again!'); 20 | process.exit(1); 21 | } 22 | 23 | // Makes sure that db is youpin-test 24 | if (mongoose.connection.db.s.databaseName !== 'youpin-test') { 25 | console.log('Woops, it seems you are using not-for-testing database. Change it now!'); 26 | process.exit(1); 27 | } 28 | }; 29 | 30 | const login = (app, email, password) => ( 31 | request(app) 32 | .post('/auth/local') 33 | .set('Content-type', 'application/json') 34 | .send({ 35 | email, 36 | password, 37 | }) 38 | ); 39 | 40 | 41 | module.exports = { 42 | assertTestEnv, 43 | expect, 44 | loadFixture, 45 | spy, 46 | stub, 47 | login, 48 | }; 49 | -------------------------------------------------------------------------------- /src/middleware/logger.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const winston = require('winston'); 4 | 5 | module.exports = function (app) { // eslint-disable-line func-names 6 | // Add a logger to our app object for convenience 7 | app.logger = winston; // eslint-disable-line no-param-reassign 8 | 9 | // Get log setting from config file 10 | const logConfig = app.get('logger'); 11 | 12 | // Let winston write logs to a file 13 | if (process.env.NODE_ENV !== 'test') { 14 | // Create a log folder if it does not exist 15 | if (!fs.existsSync(logConfig.path)) { 16 | fs.mkdirSync(logConfig.path); 17 | } 18 | // Set file to be written 19 | winston.add(winston.transports.File, { 20 | filename: path.join(logConfig.path, logConfig.filename), 21 | }); 22 | } else { 23 | // In test environment, do not print log to console 24 | winston.remove(winston.transports.Console); 25 | } 26 | 27 | return (error, req, res, next) => { 28 | if (error) { 29 | const message = `${error.code ? 30 | `(${error.code}) ` : ''}Route: ${req.url} - ${error.message}`; 31 | 32 | if (error.code === 404) { 33 | winston.info(message); 34 | } else { 35 | winston.error(message); 36 | winston.info(error.stack); 37 | } 38 | } 39 | 40 | next(error); 41 | }; 42 | }; 43 | -------------------------------------------------------------------------------- /src/services/pin/hooks/restrict-to-the-right-user-for-update.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const errors = require('feathers-errors'); 3 | 4 | const Pin = require('../../pin/pin-model'); 5 | 6 | const { USER } = require('../../../constants/roles'); 7 | 8 | 9 | const restrictToTheRightUserForUpdate = () => (hook) => { 10 | const user = hook.params.user; 11 | const pinId = hook.id; 12 | // TODO: Remove unnecessary top-level 'assigned_department' field. 13 | // This is a workaround since using just $push returns error from mongoose. 14 | // We need to add one top-level field to surpass this problem. 15 | return Pin.findById(pinId) 16 | .then(pin => { 17 | // If only using $push, add one more top-level field to surpass mongoose error. 18 | if (_.has(hook.data, '$push') && _.keys(hook.data).length === 1) { 19 | /* eslint-disable no-param-reassign */ 20 | hook.data.assigned_department = pin.assigned_department; 21 | /* eslint-enable no-param-reassign */ 22 | } 23 | // Allow all authenticated users except normal users 24 | if (user.role === USER) { 25 | throw new errors.NotAuthenticated('You are not authorized to update this pin.'); 26 | } 27 | 28 | return Promise.resolve(hook); 29 | }) 30 | .catch(error => { 31 | throw error; 32 | }); 33 | }; 34 | 35 | module.exports = restrictToTheRightUserForUpdate; 36 | -------------------------------------------------------------------------------- /src/services/index.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const Promise = require('bluebird'); 3 | 4 | // Services 5 | const activityLog = require('./activity-log'); 6 | const app3rd = require('./app3rd'); 7 | const authentication = require('./authentication'); 8 | const department = require('./department'); 9 | const organization = require('./organization'); 10 | const photo = require('./photo'); 11 | const pin = require('./pin'); 12 | const pinMerging = require('./pin-merging'); 13 | const pinStateTransition = require('./pin-state-transition'); 14 | const searchnearby = require('./searchnearby'); 15 | const summarizeState = require('./summarize-state'); 16 | const summary = require('./summary'); 17 | const user = require('./user'); 18 | const video = require('./video'); 19 | 20 | module.exports = function () { // eslint-disable-line func-names 21 | const app = this; 22 | 23 | mongoose.connect(app.get('mongodb')); 24 | mongoose.Promise = Promise; 25 | 26 | app.configure(activityLog); 27 | app.configure(app3rd); 28 | app.configure(authentication); 29 | app.configure(department); 30 | app.configure(organization); 31 | app.configure(photo); 32 | app.configure(pin); 33 | app.configure(pinMerging); 34 | app.configure(pinStateTransition); // must place after pin service 35 | app.configure(searchnearby); 36 | app.configure(summarizeState); 37 | app.configure(summary); 38 | app.configure(user); 39 | app.configure(video); 40 | }; 41 | -------------------------------------------------------------------------------- /src/services/department/hooks/index.js: -------------------------------------------------------------------------------- 1 | const auth = require('feathers-authentication').hooks; 2 | 3 | const validateObjectId = require('../../../utils/hooks/validate-object-id-hook'); 4 | const { 5 | EXECUTIVE_ADMIN, 6 | ORGANIZATION_ADMIN, 7 | SUPER_ADMIN, 8 | } = require('../../../constants/roles'); 9 | 10 | exports.before = { 11 | all: [], 12 | find: [], 13 | get: [validateObjectId()], 14 | create: [ 15 | auth.verifyToken(), 16 | auth.populateUser(), 17 | auth.restrictToRoles({ 18 | roles: [SUPER_ADMIN, ORGANIZATION_ADMIN, EXECUTIVE_ADMIN], 19 | fieldName: 'role', 20 | }), 21 | ], 22 | update: [ 23 | auth.verifyToken(), 24 | auth.populateUser(), 25 | auth.restrictToRoles({ 26 | roles: [SUPER_ADMIN, ORGANIZATION_ADMIN, EXECUTIVE_ADMIN], 27 | fieldName: 'role', 28 | }), 29 | ], 30 | patch: [ 31 | auth.verifyToken(), 32 | auth.populateUser(), 33 | auth.restrictToRoles({ 34 | roles: [SUPER_ADMIN, ORGANIZATION_ADMIN, EXECUTIVE_ADMIN], 35 | fieldName: 'role', 36 | }), 37 | ], 38 | remove: [ 39 | auth.verifyToken(), 40 | auth.populateUser(), 41 | auth.restrictToRoles({ 42 | roles: [SUPER_ADMIN, ORGANIZATION_ADMIN, EXECUTIVE_ADMIN], 43 | fieldName: 'role', 44 | }), 45 | ], 46 | }; 47 | 48 | exports.after = { 49 | all: [], 50 | find: [], 51 | get: [], 52 | create: [], 53 | update: [], 54 | patch: [], 55 | remove: [], 56 | }; 57 | -------------------------------------------------------------------------------- /config/production.template.json: -------------------------------------------------------------------------------- 1 | { 2 | "host": "localhost", 3 | "port": 9000, 4 | "mongodb": "mongodb://mongodb:27017/youpin", 5 | "public": "../public/", 6 | "auth": { 7 | "successRedirect": "/auth/facebook/success", 8 | "failureRedirect": "/auth/facebook/failure", 9 | "token": { 10 | "secret": "SECRET_AUTH_TOKEN" 11 | }, 12 | "facebook": { 13 | "clientID": "your facebook client id", 14 | "clientSecret": "your facebook client secret", 15 | "permissions": { 16 | "authType": "rerequest", 17 | "scope": ["public_profile","email"] 18 | }, 19 | "profileFields": ["id", "first_name", "last_name", "email"] 20 | } 21 | }, 22 | "enableStateTransitionCheck": false, 23 | "admin": { 24 | "adminUrl": "your admin page url" 25 | }, 26 | "bot": { 27 | "botUrl": "your bot url", 28 | "notificationToken": "your bot notification token" 29 | }, 30 | "mailService": { 31 | "providerConfig": { 32 | "service": "your mail service provider", 33 | "auth": { 34 | "user": "username", 35 | "pass": "password" 36 | } 37 | }, 38 | "content": { 39 | "from": "YouPin Admin ", 40 | "title": "YouPin Notification", 41 | "logoUrl": "https://youpin.image/public/image/logo@2x.png" 42 | } 43 | }, 44 | "gcs": { 45 | "gcsUrl": "https://storage.googleapis.com", 46 | "bucket": "your GCS bucket name", 47 | "projectId": "your GGS project ID", 48 | "keyFile": "your credentials key file path (usually putting under config/gcs)." 49 | }, 50 | "logger": { 51 | "path": "logs", 52 | "filename": "api.log" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/services/summary/hooks/index.js: -------------------------------------------------------------------------------- 1 | const auth = require('feathers-authentication').hooks; 2 | 3 | const modifySearchQuery = require('./modify-search-query'); 4 | const triggerCalculation = require('./trigger-calculation.js'); 5 | const validateObjectId = require('../../../utils/hooks/validate-object-id-hook'); 6 | 7 | // roles 8 | const { 9 | EXECUTIVE_ADMIN, 10 | ORGANIZATION_ADMIN, 11 | SUPER_ADMIN, 12 | } = require('../../../constants/roles'); 13 | 14 | exports.before = { 15 | all: [], 16 | find: [ 17 | triggerCalculation(), 18 | modifySearchQuery(), 19 | ], 20 | get: [validateObjectId()], 21 | create: [ 22 | auth.verifyToken(), 23 | auth.populateUser(), 24 | auth.restrictToRoles({ 25 | roles: [SUPER_ADMIN, ORGANIZATION_ADMIN, EXECUTIVE_ADMIN], 26 | fieldName: 'role', 27 | }), 28 | ], 29 | update: [ 30 | auth.verifyToken(), 31 | auth.populateUser(), 32 | auth.restrictToRoles({ 33 | roles: [SUPER_ADMIN, ORGANIZATION_ADMIN, EXECUTIVE_ADMIN], 34 | fieldName: 'role', 35 | }), 36 | ], 37 | patch: [ 38 | auth.verifyToken(), 39 | auth.populateUser(), 40 | auth.restrictToRoles({ 41 | roles: [SUPER_ADMIN, ORGANIZATION_ADMIN, EXECUTIVE_ADMIN], 42 | fieldName: 'role', 43 | }), 44 | ], 45 | remove: [ 46 | auth.verifyToken(), 47 | auth.populateUser(), 48 | auth.restrictToRoles({ 49 | roles: [SUPER_ADMIN, ORGANIZATION_ADMIN, EXECUTIVE_ADMIN], 50 | fieldName: 'role', 51 | }), 52 | ], 53 | }; 54 | 55 | exports.after = { 56 | all: [], 57 | find: [], 58 | get: [], 59 | create: [], 60 | update: [], 61 | patch: [], 62 | remove: [], 63 | }; 64 | -------------------------------------------------------------------------------- /src/utils/hooks/swap-lat-long.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | 3 | function swapLatLongHelper(data) { 4 | if (Array.isArray(data)) { 5 | data = data.map(obj => { // eslint-disable-line no-param-reassign 6 | if (obj.location && obj.location.coordinates) { 7 | obj.location.coordinates = // eslint-disable-line no-param-reassign 8 | [obj.location.coordinates[1], obj.location.coordinates[0]]; 9 | } 10 | return obj; 11 | }); 12 | } else if (data.location && data.location.coordinates) { 13 | // Single object 14 | data.location.coordinates = // eslint-disable-line no-param-reassign 15 | [data.location.coordinates[1], data.location.coordinates[0]]; 16 | } 17 | return data; 18 | } 19 | 20 | // Mongo stores as [Long,Lat] but we want [Lat, Long]. So, swap them. 21 | /* eslint-disable func-names */ 22 | function swapLatLong(options) { // eslint-disable-line no-unused-vars 23 | return (hook) => { 24 | // BeforeHook 25 | let data = _.get(hook, 'data'); 26 | if (data) { 27 | hook.data = swapLatLongHelper(data); // eslint-disable-line no-param-reassign 28 | return; 29 | } 30 | data = _.get(hook, 'result'); 31 | if (data) { 32 | // check if it is array -> result: { data: []} 33 | // or single object -> result: { id: ..., detail: ...} 34 | if (data.data) { 35 | hook.result.data = swapLatLongHelper(data.data); // eslint-disable-line no-param-reassign 36 | } else { 37 | hook.result = swapLatLongHelper(data); // eslint-disable-line no-param-reassign 38 | } 39 | } 40 | }; 41 | } 42 | /* eslint-enable func-names */ 43 | 44 | module.exports = swapLatLong; 45 | -------------------------------------------------------------------------------- /test/app.test.js: -------------------------------------------------------------------------------- 1 | // Test helper functions 2 | const assertTestEnv = require('./test_helper').assertTestEnv; 3 | const expect = require('./test_helper').expect; 4 | const request = require('request'); 5 | 6 | // App stuff 7 | const app = require('../src/app'); 8 | 9 | // Exit test if NODE_ENV is not equal `test` 10 | assertTestEnv(); 11 | 12 | describe('Feathers application tests', () => { 13 | before((done) => { 14 | this.server = app.listen(3030); 15 | this.server.once('listening', () => done()); 16 | }); 17 | 18 | after((done) => { 19 | this.server.close(done); 20 | }); 21 | 22 | it('starts and shows the index page', (done) => { 23 | request('http://localhost:3030', (err, res, body) => { 24 | expect(body.indexOf('') !== -1).to.be.ok(); 25 | done(err); 26 | }); 27 | }); 28 | 29 | describe('404', () => { 30 | it('shows a 404 HTML page', (done) => { 31 | request({ 32 | url: 'http://localhost:3030/path/to/nowhere', 33 | headers: { 34 | Accept: 'text/html', 35 | }, 36 | }, (err, res, body) => { 37 | expect(res.statusCode).to.equal(404); 38 | expect(body.indexOf('') !== -1).to.be.ok(); 39 | done(err); 40 | }); 41 | }); 42 | 43 | it('shows a 404 JSON error without stack trace', (done) => { 44 | request({ 45 | url: 'http://localhost:3030/path/to/nowhere', 46 | json: true, 47 | }, (err, res, body) => { 48 | expect(res.statusCode).to.equal(404); 49 | expect(body.code).to.equal(404); 50 | expect(body.message).to.equal('Page not found'); 51 | expect(body.name).to.equal('NotFound'); 52 | done(err); 53 | }); 54 | }); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/utils/email-templates/notification/html.pug: -------------------------------------------------------------------------------- 1 | doctype transitional 2 | html 3 | head 4 | meta(http-equiv="Content-Type" content="text/html; charset=UTF-8") 5 | style(type='text/css'). 6 | .pinButton { 7 | -moz-border-radius:12px; 8 | -webkit-border-radius:12px; 9 | background-color:#c74545; 10 | border-color: #45b7af; 11 | border-collapse:separate !important; 12 | border-radius:12px; 13 | border:1px solid #ab1919; 14 | padding:10px 26px; 15 | } 16 | .pinButton, .pinButton a { 17 | text-shadow:0px 1px 0px #662830; 18 | color:#FFFFFF; 19 | font-family:Arial; 20 | font-size:15px; 21 | font-weight:bold; 22 | letter-spacing:-.5px; 23 | line-height:100%; 24 | text-align:center; 25 | text-decoration:none; 26 | } 27 | .infoTable { 28 | border-collapse: collapse; 29 | } 30 | .infoTable th, .infoTable td { 31 | border: 1px solid black; 32 | text-align: center; 33 | } 34 | body 35 | center 36 | img(src=logoUrl) 37 | h1= title 38 | p= message 39 | p 40 | table.pinButton(border="0" cellpadding="15" cellspacing="0") 41 | tr 42 | td(valign="middle") 43 | div 44 | a(href=pinLink) GO TO PIN 45 | p 46 | table.infoTable(height="100%" width="100%") 47 | tr 48 | th # 49 | th Changed Field 50 | th Previous Value 51 | th Current Value 52 | - for (let i = 0; i < changedFields.length; i++) 53 | tr 54 | td= i + 1 55 | td= changedFields[i] 56 | td= previousValues[i] 57 | td= updatedValues[i] 58 | -------------------------------------------------------------------------------- /config/default.template.json: -------------------------------------------------------------------------------- 1 | { 2 | "host": "localhost", 3 | "port": 9100, 4 | "nedb": "../data/", 5 | "mongodb": "mongodb://mongodb:27017/youpin-dev", 6 | "public": "../public/", 7 | "auth": { 8 | "successRedirect": "/auth/facebook/success", 9 | "failureRedirect": "/auth/facebook/failure", 10 | "token": { 11 | "secret": "SECRET_AUTH_TOKEN" 12 | }, 13 | "facebook": { 14 | "clientID": "your facebook client id", 15 | "clientSecret": "your facebook client secret", 16 | "permissions": { 17 | "authType": "rerequest", 18 | "scope": ["public_profile","email"] 19 | }, 20 | "profileFields": ["id", "first_name", "last_name", "email"] 21 | } 22 | }, 23 | "enableStateTransitionCheck": false, 24 | "default": { 25 | "location": { 26 | "lat": 13.756727, 27 | "long": 100.5018549 28 | } 29 | }, 30 | "admin": { 31 | "adminUrl": "your admin page url" 32 | }, 33 | "bot": { 34 | "botUrl": "your bot url", 35 | "notificationToken": "your bot notification token" 36 | }, 37 | "mailService": { 38 | "providerConfig": { 39 | "service": "your mail service provider", 40 | "auth": { 41 | "user": "username", 42 | "pass": "password" 43 | } 44 | }, 45 | "content": { 46 | "from": "YouPin Admin ", 47 | "title": "YouPin Notification", 48 | "logoUrl": "https://youpin.image/public/image/logo@2x.png" 49 | } 50 | }, 51 | "gcs": { 52 | "gcsUrl": "https://storage.googleapis.com", 53 | "bucket": "your GCS bucket name", 54 | "projectId": "your GCS project ID", 55 | "keyFile": "your credentials key file path (usually put under config/gcs/)." 56 | }, 57 | "logger": { 58 | "path": "logs", 59 | "filename": "api.log" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/hooks/index.js: -------------------------------------------------------------------------------- 1 | const bcrypt = require('bcryptjs'); 2 | const errors = require('feathers-errors'); 3 | 4 | // Add any common hooks you want to share across services in here. 5 | // 6 | // Below is an example of how a hook is written and exported. Please 7 | // see http://docs.feathersjs.com/hooks/readme.html for more details 8 | // on hooks. 9 | 10 | /* eslint-disable func-names */ 11 | exports.authenticateAPI = function (options) { // eslint-disable-line no-unused-vars 12 | return (hook) => { 13 | if (hook.type !== 'before') { 14 | throw new Error('The \'authenticateAPI\' hook should only be used as a \'before\' hook.'); 15 | } 16 | // If it was an internal call then skip this hook 17 | if (!hook.params.provider) { 18 | return hook; 19 | } 20 | const appKey = hook.params.youpinAppKey; 21 | if (!appKey) { 22 | throw new errors.NotAuthenticated('Authentication X-YOUPIN-3-APP-KEY is missing.'); 23 | } 24 | const appKeySplit = appKey.split(':'); 25 | if (appKeySplit.length !== 2) { 26 | throw new errors.NotAuthenticated('Incorret X-YOUPIN-3-APP-KEY format'); 27 | } 28 | const appKeyId = appKeySplit[0]; 29 | const apiKeyPassword = appKeySplit[1]; 30 | console.log('Authenticated API 3rd App Id: ', appKeyId); 31 | return new Promise((resolve, reject) => { 32 | hook.app.service('/app3rds').get(appKeyId).then(app3rd => { 33 | const isVerified = bcrypt.compareSync(apiKeyPassword, app3rd.apikey); 34 | if (isVerified !== true) { 35 | throw new errors.NotAuthenticated('API KEY is not correct for this app id.'); 36 | } 37 | hook.params.app3rd = app3rd; // eslint-disable-line no-param-reassign 38 | return resolve(hook); 39 | }) 40 | .catch(reject); 41 | }); 42 | }; 43 | }; 44 | /* eslint-enable func-names */ 45 | -------------------------------------------------------------------------------- /test/services/pin-state-transition/hooks/log-activity.test.js: -------------------------------------------------------------------------------- 1 | // Test helper functions 2 | const assertTestEnv = require('../../../test_helper').assertTestEnv; 3 | const expect = require('../../../test_helper').expect; 4 | const spy = require('../../../test_helper').spy; 5 | 6 | // App stuff 7 | const logActivity = require('../../../../src/utils/hooks/log-activity'); 8 | 9 | // Exit test if NODE_ENV is not equal `test` 10 | assertTestEnv(); 11 | 12 | describe('Log Activity Hook', () => { 13 | it('calls "create" method of activity log service with correct log info', () => { 14 | // Mock logInfo to be written to activity log service 15 | const logInfo = { 16 | user: 'Aunt You-pin', 17 | organization: 'YouPin', 18 | department: 'Development', 19 | actionType: 'STATE_TRANSITION', 20 | action: 'STATE_TRANSITION/ASSIGNED', 21 | pin_id: 1234, 22 | changed_fields: ['status', 'assigned_department'], 23 | previous_values: ['pending', null], 24 | updated_values: ['assigned', '57933111556362511181bbb1'], 25 | description: 'Aunt You-pin assigned pin 1234 to department 57933111556362511181bbb1', 26 | timestamp: '2016-11-25', 27 | }; 28 | const createSpy = spy(); 29 | const mockHook = { 30 | type: 'after', 31 | app: { 32 | service: () => ({ 33 | create: createSpy, 34 | }), 35 | }, 36 | params: { }, 37 | result: {}, 38 | data: { 39 | logInfo, 40 | }, 41 | }; 42 | const serviceSpy = spy(mockHook.app, 'service'); 43 | 44 | // The following call must run mockHook.app.service('/activity_log').create(hook.data.logInfo) 45 | logActivity()(mockHook); 46 | 47 | expect(serviceSpy).to.have.been.calledWith('/activity_logs'); 48 | expect(createSpy).to.have.been.calledWith(logInfo); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/services/pin/hooks/index.js: -------------------------------------------------------------------------------- 1 | const auth = require('feathers-authentication').hooks; 2 | 3 | const logActivity = require('../../../utils/hooks/log-activity'); 4 | const prepareActivityLog = require('./prepare-activity-log'); 5 | const prepareNotifInfoForCreatedPin = require('./prepare-notif-info-for-created-pin'); 6 | const restrictToOwnerOfPin = require('../../../utils/hooks/restrict-to-owner-of-pin-hook'); 7 | const restrictToTheRightUserForUpdate = require('./restrict-to-the-right-user-for-update'); 8 | const sendNotifToRelatedUsers = require('../../../utils/hooks/send-notif-to-related-users'); 9 | const swapLatLong = require('../../../utils/hooks/swap-lat-long'); 10 | const validateObjectId = require('../../../utils/hooks/validate-object-id-hook'); 11 | 12 | exports.before = { 13 | all: [ 14 | swapLatLong(), 15 | ], 16 | find: [], 17 | get: [ 18 | validateObjectId(), 19 | ], 20 | create: [ 21 | auth.verifyToken(), 22 | auth.populateUser(), 23 | auth.restrictToAuthenticated(), 24 | restrictToOwnerOfPin(), 25 | ], 26 | update: [ 27 | auth.verifyToken(), 28 | auth.populateUser(), 29 | auth.restrictToAuthenticated(), 30 | restrictToTheRightUserForUpdate(), 31 | ], 32 | patch: [ 33 | auth.verifyToken(), 34 | auth.populateUser(), 35 | auth.restrictToAuthenticated(), 36 | restrictToTheRightUserForUpdate(), 37 | prepareActivityLog(), 38 | ], 39 | remove: [ 40 | auth.verifyToken(), 41 | auth.populateUser(), 42 | auth.restrictToAuthenticated(), 43 | // TODO: Allow super admin and organization admin 44 | restrictToOwnerOfPin(), 45 | ], 46 | }; 47 | 48 | exports.after = { 49 | all: [swapLatLong()], 50 | find: [], 51 | get: [], 52 | create: [ 53 | prepareNotifInfoForCreatedPin(), 54 | sendNotifToRelatedUsers(), 55 | ], 56 | update: [], 57 | patch: [ 58 | logActivity(), 59 | sendNotifToRelatedUsers(), 60 | ], 61 | remove: [], 62 | }; 63 | -------------------------------------------------------------------------------- /src/services/user/hooks/handle-facebook-create.js: -------------------------------------------------------------------------------- 1 | const handleFacebookCreate = () => (hook) => { 2 | // Pass through this hook if there's no facebook data 3 | if (!hook.data.facebookId 4 | || !hook.data.facebook 5 | || !hook.data.facebook.email 6 | || (!hook.data.facebook.name 7 | && !hook.data.facebook.first_name 8 | && !hook.data.facebook.last_name) 9 | ) { 10 | return Promise.resolve(hook); 11 | } 12 | 13 | const userService = hook.app.service('/users'); 14 | 15 | // Check whether a user with same facebook email exists 16 | return userService.find({ query: { email: hook.data.facebook.email } }) 17 | .then(results => { 18 | // Handle answer in both array and object forms 19 | const existingUser = results[0] || (results.data && results.data[0]); 20 | 21 | // User properties to be created or updated 22 | const data = { 23 | facebookId: hook.data.facebookId, 24 | email: hook.data.facebook.email, 25 | name: hook.data.facebook.name || 26 | `${hook.data.facebook.first_name} ${hook.data.facebook.last_name}`, 27 | }; 28 | 29 | // Patch existing user's properties 30 | if (existingUser) { 31 | return userService.patch(existingUser._id, data) // eslint-disable-line no-underscore-dangle 32 | .then(updatedUser => { 33 | // Set `hook.result` to skip the actual `create` since we updated it already 34 | hook.result = updatedUser; // eslint-disable-line no-param-reassign 35 | 36 | return Promise.resolve(hook); 37 | }); 38 | } 39 | // Create a new user if not exist 40 | return userService.create(data) 41 | .then(createdUser => { 42 | hook.result = createdUser; // eslint-disable-line no-param-reassign 43 | 44 | return Promise.resolve(hook); 45 | }) 46 | .catch(err => console.error(err)); 47 | }) 48 | .catch(err => console.error(err)); 49 | }; 50 | 51 | module.exports = handleFacebookCreate; 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # YouPin API 2 | 3 | [![Build Status](https://travis-ci.org/youpin-city/youpin-api.svg?branch=master)](https://travis-ci.org/youpin-city/youpin-api) 4 | 5 | API (dev environment): https://dev.api.youpin.city 6 | All data is sent and recieved as JSON. 7 | 8 | ## API Documents 9 | API Documents is auto-generated by [apidoc](http://apidocjs.com/) and can be accessed at https://youpin-city.github.io/youpin-api-docs. 10 | Moreover, we also have [POSTMAN examples](https://github.com/youpin-city/youpin-api/tree/master/usages) that you can try by importing it to your own POSTMAN. 11 | 12 | ## Run 13 | [Docker](https://www.docker.com/) is needed to run your local YouPin API. After you have Docker, please follow the following instructions: 14 | 15 | 1. Clone repo 16 | `git clone git@github.com:youpin-city/youpin-api.git`. 17 | 18 | 2. Get credential GCS private key. Currently, YouPin API depends on Google Cloud Storage (GCS) to store photos and videos. 19 | 20 | 1. Follow [instruction 1 to 9](https://cloud.google.com/storage/docs/authentication#generating-a-private-key) to get GCS private key. 21 | 2. Rename the key to `youpin_gcs_credentials_xxx.json` where xxx is `development` for DEV environment or `production` for PROD environment. 22 | 3. Place it under `config/gcs/` (Ex. `config/gcs/youpin_gcs_credentials_development.json`) 23 | 24 | 3. Setup your YouPin config. Always having `config/default.json` as a basic settings for DEV environment. If you want to run using PROD config, add additional `config/production.json` for PROD to replace some fields in default.json. Please find some useful templates under `config/` folder itself. 25 | 26 | 4. Start service with `docker-compose up -d`. 27 | The service will run on port 9100. To stop, run `docker-compose stop`. If you need to build a new docker image with modified code, run `docker-compose up --build -d`. To run on PROD, just add `.env` with the content `NODE_ENV=production` to YouPin root directory. 28 | 29 | 30 | ## Changelog 31 | 32 | __0.1.0__ 33 | 34 | - Initial release 35 | 36 | ## License 37 | 38 | Copyright (c) 2016 39 | 40 | Licensed under the [MIT license](LICENSE). 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "youpin-api", 3 | "description": "YouPin API", 4 | "version": "0.0.0", 5 | "main": "src/", 6 | "keywords": [ 7 | "feathers" 8 | ], 9 | "license": "MIT", 10 | "engines": { 11 | "node": "6.13.0" 12 | }, 13 | "scripts": { 14 | "lint": "eslint .", 15 | "pretest": "npm run lint", 16 | "test": "NODE_ENV=test mocha --recursive", 17 | "start": "node src/" 18 | }, 19 | "dependencies": { 20 | "bcryptjs": "^2.3.0", 21 | "bluebird": "^3.4.1", 22 | "body-parser": "^1.15.1", 23 | "compression": "^1.6.2", 24 | "cookie-parser": "^1.4.3", 25 | "cors": "^2.7.1", 26 | "email-templates": "^2.5.4", 27 | "express-session": "^1.14.0", 28 | "feathers": "^2.0.1", 29 | "feathers-authentication": "^0.7.8", 30 | "feathers-configuration": "^0.2.3", 31 | "feathers-errors": "^2.2.0", 32 | "feathers-hooks": "^1.5.7", 33 | "feathers-mongoose": "^3.6.1", 34 | "feathers-nedb": "^2.3.0", 35 | "feathers-rest": "^1.4.2", 36 | "feathers-socketio": "^1.4.1", 37 | "google-cloud": "^0.53.0", 38 | "jwt-decode": "^2.1.0", 39 | "lodash": "^4.13.1", 40 | "moment": "^2.17.0", 41 | "mongoose": "^4.5.9", 42 | "mongoose-type-url": "^1.0.2", 43 | "multer": "^1.1.0", 44 | "nedb": "^1.8.0", 45 | "nodemailer": "^3.1.3", 46 | "passport": "^0.3.2", 47 | "passport-facebook": "^2.1.1", 48 | "passport-facebook-token": "^3.2.0", 49 | "passport-github": "^1.1.0", 50 | "passport-github-token": "^2.1.0", 51 | "passport-google-oauth20": "^1.0.0", 52 | "passport-google-token": "^0.1.2", 53 | "pug": "^2.0.0-beta9", 54 | "serve-favicon": "^2.3.0", 55 | "superagent": "^2.0.0", 56 | "uuid": "^2.0.2", 57 | "validator": "^5.4.0", 58 | "winston": "^2.2.0" 59 | }, 60 | "devDependencies": { 61 | "casual": "^1.5.3", 62 | "chai": "^3.5.0", 63 | "dirty-chai": "^1.2.2", 64 | "eslint": "^3.3.1", 65 | "eslint-config-airbnb": "^10.0.1", 66 | "eslint-plugin-import": "^1.13.0", 67 | "mocha": "^2.5.3", 68 | "mongoose-fixture-loader": "^1.0.2", 69 | "request": "^2.72.0", 70 | "sinon": "^1.17.5", 71 | "sinon-chai": "^2.8.0", 72 | "supertest": "^1.2.0", 73 | "supertest-as-promised": "^3.2.0" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /test/fixtures/constants.js: -------------------------------------------------------------------------------- 1 | // Departments 2 | const DEPARTMENT_GENERAL_ID = '57933111556362511181bbb1'; 3 | const DEPARTMENT_GENERAL_NAME = 'Department of Nerds'; 4 | const DEPARTMENT_ORGANIZATION_ADMIN_ID = '57933111556362511181eee1'; 5 | const DEPARTMENT_PUBLIC_RELATIONS_ID = '57933111556362511181fff1'; 6 | const DEPARTMENT_PUBLIC_RELATIONS_NAME = 'Public Relations Department'; 7 | const DEPARTMENT_SUPER_ADMIN_ID = '57933111556362511181ccc1'; 8 | const DEPARTMENT_SUPER_ADMIN_NAME = 'Admin Department'; 9 | // Organization 10 | const ORGANIZATION_ID = '57933111556362511181aaa1'; 11 | // Pins 12 | const PIN_ASSIGNED_ID = '579334c75563625d6281b6f8'; 13 | const PIN_ASSIGNED_DETAIL = 'This is an assigned pin'; 14 | const PIN_PENDING_ID = '579334c75563625d6281b6f6'; 15 | const PIN_PENDING_DETAIL = 'This is a pending pin'; 16 | const PIN_PROCESSING_ID = '579334c75563625d6281b6a5'; 17 | const PIN_PROCESSING_DETAIL = 'This is a processing pin'; 18 | const PIN_RESOLVED_ID = '579334c75563625d6281b6a6'; 19 | const PIN_RESOLVED_DETAIL = 'This is a resolved pin'; 20 | const PIN_REJECTED_ID = '579334c75563625d6281b6a7'; 21 | const PIN_REJECTED_DETAIL = 'This is a rejected pin'; 22 | // Users 23 | const USER_ADMIN_ID = '579334c75563625d6281b6f1'; // TODO: Will not use in the future 24 | const USER_DEPARTMENT_HEAD_ID = '579334c74443625d6281b6f0'; 25 | const USER_DEPARTMENT_OFFICER_ID = '579334c74443625d6281b6f2'; 26 | const USER_ORGANIZATION_ADMIN_ID = '579334c74443625d6281b6dd'; 27 | const USER_PUBLIC_RELATIONS_ID = '579334c74443625d6281b6ee'; 28 | const USER_SUPER_ADMIN_ID = '579334c74443625d6281b6ff'; 29 | // Progresses 30 | const PROGRESS_DETAIL = 'This is a progress detail'; 31 | 32 | module.exports = { 33 | DEPARTMENT_GENERAL_ID, 34 | DEPARTMENT_GENERAL_NAME, 35 | DEPARTMENT_ORGANIZATION_ADMIN_ID, 36 | DEPARTMENT_PUBLIC_RELATIONS_ID, 37 | DEPARTMENT_PUBLIC_RELATIONS_NAME, 38 | DEPARTMENT_SUPER_ADMIN_ID, 39 | DEPARTMENT_SUPER_ADMIN_NAME, 40 | ORGANIZATION_ID, 41 | PIN_ASSIGNED_ID, 42 | PIN_ASSIGNED_DETAIL, 43 | PIN_PENDING_ID, 44 | PIN_PENDING_DETAIL, 45 | PIN_PROCESSING_ID, 46 | PIN_PROCESSING_DETAIL, 47 | PIN_RESOLVED_ID, 48 | PIN_RESOLVED_DETAIL, 49 | PIN_REJECTED_ID, 50 | PIN_REJECTED_DETAIL, 51 | PROGRESS_DETAIL, 52 | USER_ADMIN_ID, 53 | USER_DEPARTMENT_HEAD_ID, 54 | USER_DEPARTMENT_OFFICER_ID, 55 | USER_ORGANIZATION_ADMIN_ID, 56 | USER_PUBLIC_RELATIONS_ID, 57 | USER_SUPER_ADMIN_ID, 58 | }; 59 | -------------------------------------------------------------------------------- /src/services/authentication/index.js: -------------------------------------------------------------------------------- 1 | const authentication = require('feathers-authentication'); 2 | const FacebookStrategy = require('passport-facebook').Strategy; 3 | const FacebookTokenStrategy = require('passport-facebook-token'); 4 | 5 | module.exports = function () { // eslint-disable-line func-names 6 | const app = this; 7 | 8 | const config = app.get('auth'); 9 | 10 | config.facebook.strategy = FacebookStrategy; 11 | config.facebook.tokenStrategy = FacebookTokenStrategy; 12 | 13 | app.set('auth', config); 14 | app.configure(authentication(config)); 15 | }; 16 | 17 | /* eslint-disable max-len */ 18 | /** 19 | * @api {post} /auth/local Login 20 | * @apiDescription Login to system 21 | * @apiVersion 0.1.0 22 | * @apiName Login 23 | * @apiGroup User 24 | * 25 | * @apiExample Example usage: 26 | * curl -i -X POST https://api.youpin.city/auth/local \ 27 | * -H 'Content-type: application/json' \ 28 | * -d @- << EOF 29 | * { 30 | * "email": "aunt@youpin.city", 31 | * "password": "" 32 | * } 33 | * EOF 34 | * 35 | * @apiHeader Content-type=application/json 36 | * 37 | * @apiParam {String} email User's email. 38 | * @apiParam {String} password User's password. 39 | * 40 | * @apiSuccess (Created 201) {String} token JWT Access token. 41 | * @apiSuccess (Created 201) {Object} data User model object 42 | 43 | * @apiSuccessExample Success Response: 44 | * HTTP/1.1 201 Created 45 | * { 46 | * "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJfaWQiOiI1Nzk4OWVkNWYyNzc5MDdkMTFjZTMwMDUiLCJpYXQiOjE0Nzk3MTI5NDQsImV4cCI6MTQ3OTc5OTM0NCwiaXNzIjoiZmVhdGhlcnMifQ.lGkad0HiMEOO4pvUd-adP15UTGnx2_LvgOIbiN_Z8qA", 47 | * "data": { 48 | * "_id":"57989ed5f277907d11ce3005", 49 | * "name":"supasate", 50 | * "email":"supasate.c@gmail.com", 51 | * "role":"user", 52 | * "__v":0, 53 | * "owner_app_id":[], 54 | * "customer_app_id":[], 55 | * "updated_time":"2016-07-27T11:45:25.358Z", 56 | * "created_time":"2016-07-27T11:45:25.358Z", 57 | * "department":[] 58 | * } 59 | * } 60 | * 61 | * @apiError Unauthorized Invalid login. 62 | * 63 | * @apiErrorExample Error Response: 64 | * HTTP/1.1 401 Unauthorized 65 | * { 66 | * "name":"NotAuthenticated", 67 | * "message":"Invalid login.", 68 | * "code":401, 69 | * "className":"not-authenticated", 70 | * "errors":{} 71 | * } 72 | */ 73 | /* eslint-enable max-len */ 74 | -------------------------------------------------------------------------------- /src/services/user/hooks/index.js: -------------------------------------------------------------------------------- 1 | const auth = require('feathers-authentication').hooks; 2 | const hooks = require('feathers-hooks'); 3 | 4 | const handleFacebookCreate = require('./handle-facebook-create'); 5 | const validateObjectId = require('../../../utils/hooks/validate-object-id-hook'); 6 | 7 | // roles 8 | const { 9 | DEPARTMENT_OFFICER, 10 | DEPARTMENT_HEAD, 11 | EXECUTIVE_ADMIN, 12 | ORGANIZATION_ADMIN, 13 | SUPER_ADMIN, 14 | } = require('../../../constants/roles'); 15 | 16 | exports.before = { 17 | all: [], 18 | find: [ 19 | auth.verifyToken(), 20 | auth.populateUser(), 21 | auth.restrictToAuthenticated(), 22 | auth.restrictToRoles({ 23 | roles: [ 24 | SUPER_ADMIN, 25 | ORGANIZATION_ADMIN, 26 | EXECUTIVE_ADMIN, 27 | DEPARTMENT_HEAD, 28 | DEPARTMENT_OFFICER, 29 | ], 30 | fieldName: 'role', 31 | }), 32 | ], 33 | get: [ 34 | auth.verifyToken(), 35 | auth.populateUser(), 36 | auth.restrictToAuthenticated(), 37 | validateObjectId(), 38 | auth.restrictToRoles({ 39 | roles: [ 40 | SUPER_ADMIN, 41 | ORGANIZATION_ADMIN, 42 | EXECUTIVE_ADMIN, 43 | DEPARTMENT_HEAD, 44 | DEPARTMENT_OFFICER, 45 | ], 46 | fieldName: 'role', 47 | owner: true, 48 | ownerField: '_id', 49 | }), 50 | ], 51 | create: [ 52 | handleFacebookCreate(), 53 | auth.hashPassword(), 54 | ], 55 | update: [ 56 | auth.verifyToken(), 57 | auth.populateUser(), 58 | auth.restrictToAuthenticated(), 59 | auth.restrictToRoles({ 60 | roles: [SUPER_ADMIN, ORGANIZATION_ADMIN, EXECUTIVE_ADMIN], 61 | fieldName: 'role', 62 | owner: true, 63 | ownerField: '_id', 64 | }), 65 | ], 66 | patch: [ 67 | auth.verifyToken(), 68 | auth.populateUser(), 69 | auth.restrictToAuthenticated(), 70 | auth.restrictToRoles({ 71 | roles: [SUPER_ADMIN, ORGANIZATION_ADMIN, EXECUTIVE_ADMIN], 72 | fieldName: 'role', 73 | owner: true, 74 | ownerField: '_id', 75 | }), 76 | ], 77 | remove: [ 78 | auth.verifyToken(), 79 | auth.populateUser(), 80 | auth.restrictToAuthenticated(), 81 | auth.restrictToRoles({ 82 | roles: [SUPER_ADMIN, ORGANIZATION_ADMIN, EXECUTIVE_ADMIN], 83 | fieldName: 'role', 84 | owner: true, 85 | ownerField: '_id', 86 | }), 87 | ], 88 | }; 89 | 90 | exports.after = { 91 | all: [hooks.remove('password')], 92 | find: [], 93 | get: [], 94 | create: [], 95 | update: [], 96 | patch: [], 97 | remove: [], 98 | }; 99 | -------------------------------------------------------------------------------- /test/fixtures/activity_logs.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | { 3 | pin_id: '579334c75563625d6281b6f6', 4 | organization: 'Chulalongkorn', 5 | department: 'Engineering', 6 | actionType: 'ACTION_TYPE/STATE_TRANSITION', 7 | action: 'STATE_TRANSITION/ASSIGN', 8 | timestamp: '2016-11-25', 9 | }, 10 | { 11 | pin_id: '579334c75563625d6281b6f7', 12 | organization: 'Chulalongkorn', 13 | department: 'Engineering', 14 | actionType: 'ACTION_TYPE/STATE_TRANSITION', 15 | action: 'STATE_TRANSITION/ASSIGN', 16 | timestamp: '2016-11-25', 17 | }, 18 | { 19 | pin_id: '579334c75563625d6281b6f8', 20 | organization: 'Chulalongkorn', 21 | department: 'Engineering', 22 | actionType: 'ACTION_TYPE/STATE_TRANSITION', 23 | action: 'STATE_TRANSITION/PROCESS', 24 | timestamp: '2016-11-25', 25 | }, 26 | { 27 | pin_id: '579334c75563625d6281b6f9', 28 | organization: 'Chulalongkorn', 29 | department: 'Medicine', 30 | actionType: 'ACTION_TYPE/STATE_TRANSITION', 31 | action: 'STATE_TRANSITION/PROCESS', 32 | timestamp: '2016-11-25', 33 | }, 34 | { 35 | pin_id: '579334c75563625d6281b616', 36 | organization: 'Chulalongkorn', 37 | department: 'Engineering', 38 | actionType: 'ACTION_TYPE/STATE_TRANSITION', 39 | action: 'STATE_TRANSITION/PROCESS', 40 | timestamp: '2016-11-26', 41 | }, 42 | { 43 | pin_id: '579334c75563625d6281b636', 44 | organization: 'Chulalongkorn', 45 | department: 'Engineering', 46 | actionType: 'ACTION_TYPE/STATE_TRANSITION', 47 | action: 'STATE_TRANSITION/ASSIGN', 48 | timestamp: '2016-11-26', 49 | }, 50 | { 51 | pin_id: '579334c75563625d6281b626', 52 | organization: 'Chulalongkorn', 53 | department: 'Engineering', 54 | actionType: 'ACTION_TYPE/STATE_TRANSITION', 55 | action: 'STATE_TRANSITION/RESOLVE', 56 | timestamp: '2016-11-26', 57 | }, 58 | { 59 | pin_id: '579334c75563625d6281b316', 60 | organization: 'Chulalongkorn', 61 | department: 'Engineering', 62 | actionType: 'ACTION_TYPE/STATE_TRANSITION', 63 | action: 'STATE_TRANSITION/ASSIGN', 64 | timestamp: '2016-11-26', 65 | }, 66 | { 67 | pin_id: '579334c75563625d6281b5f6', 68 | organization: 'Chulalongkorn', 69 | department: 'Engineering', 70 | actionType: 'ACTION_TYPE/STATE_TRANSITION', 71 | action: 'STATE_TRANSITION/ASSIGN', 72 | timestamp: '2016-11-27', 73 | }, 74 | { 75 | pin_id: '579334c75563625d6281b9f6', 76 | organization: 'Chulalongkorn', 77 | department: 'Engineering', 78 | actionType: 'ACTION_TYPE/STATE_TRANSITION', 79 | action: 'STATE_TRANSITION/REJECT', 80 | timestamp: '2016-11-28', 81 | }, 82 | ]; 83 | -------------------------------------------------------------------------------- /src/services/video/index.js: -------------------------------------------------------------------------------- 1 | const errors = require('feathers-errors'); 2 | const Promise = require('bluebird'); 3 | 4 | const hooks = require('./hooks'); 5 | const GCSUploader = require('../../utils/gcs-uploader'); 6 | const Video = require('./video-model'); 7 | 8 | // Middleware for handling file upload 9 | const VIDEO_SIZE = 20 * 1024 * 1024; 10 | const prepareMultipart = require('../../middleware/prepare-multipart')('video', VIDEO_SIZE); 11 | const attachFileToFeathers = require('../../middleware/attach-file-to-feathers')(); 12 | 13 | // Save video metadata to database 14 | function saveVideoMetadata(file) { 15 | return new Promise((resolve, reject) => { 16 | const video = new Video({ 17 | url: file.cloudStoragePublicUrl, 18 | mimetype: file.mimetype, 19 | size: file.size, 20 | }); 21 | 22 | video.save((err, videoDoc) => { 23 | if (err) return reject(err); 24 | 25 | return resolve(videoDoc); 26 | }); 27 | }); 28 | } 29 | 30 | function respondWithVideoMetadata(videoDocument) { 31 | return new Promise((resolve, reject) => { 32 | if (!videoDocument) { 33 | return reject(new errors.GeneralError('No video provided')); 34 | } 35 | 36 | if (!videoDocument.url) { 37 | return reject(new errors.GeneralError('No video URL provided')); 38 | } 39 | 40 | if (!videoDocument.mimetype) { 41 | return reject(new errors.GeneralError('No video MIME type provided')); 42 | } 43 | 44 | if (!videoDocument.size) { 45 | return reject(new errors.GeneralError('No video size provided')); 46 | } 47 | 48 | return resolve({ 49 | id: videoDocument._id, // eslint-disable-line no-underscore-dangle 50 | url: videoDocument.url, 51 | mimetype: videoDocument.mimetype, 52 | size: videoDocument.size, 53 | }); 54 | }); 55 | } 56 | 57 | class VideosService { 58 | setup(app) { 59 | this.app = app; 60 | } 61 | 62 | get(id) { 63 | return Video.findById(id, (err, video) => { 64 | if (err) { 65 | return Promise.reject(err); 66 | } 67 | 68 | return Promise.resolve(video); 69 | }); 70 | } 71 | 72 | create(data, params) { 73 | const gcsConfig = this.app.get('gcs'); 74 | const gcsUploader = new GCSUploader(gcsConfig); 75 | 76 | return gcsUploader.upload(params.file) 77 | .then((file) => saveVideoMetadata(file)) 78 | .then((videoDoc) => respondWithVideoMetadata(videoDoc)) 79 | .catch((err) => Promise.reject(err)); 80 | } 81 | } 82 | 83 | module.exports = function () { // eslint-disable-line func-names 84 | const app = this; 85 | 86 | app.use('/videos', prepareMultipart, attachFileToFeathers, new VideosService()); 87 | const videosService = app.service('/videos'); 88 | videosService.before(hooks.before); 89 | videosService.after(hooks.after); 90 | }; 91 | -------------------------------------------------------------------------------- /src/services/pin-merging/index.js: -------------------------------------------------------------------------------- 1 | const errors = require('feathers-errors'); 2 | 3 | const hooks = require('./hooks'); 4 | const PinModel = require('../pin/pin-model'); 5 | 6 | class PinMergingService { 7 | create(data, params) { 8 | const pinId = params.pinId; // child pin 9 | const mergedParentPin = data.mergedParentPin; // parent pin 10 | 11 | if (!pinId) { 12 | throw new errors.BadRequest('Pin ID is not specified'); 13 | } 14 | 15 | if (!mergedParentPin) { 16 | throw new errors.BadRequest('Require `mergedParentPin` in body data'); 17 | } 18 | 19 | let updatedChildPin; 20 | let mergedChildrenPins; 21 | 22 | // Find child pin first to check if it does exist and has not been merged yet 23 | return PinModel.find({ _id: pinId }) 24 | .then((childPin) => { 25 | if (!childPin) { 26 | return Promise.reject(`Pin ${pinId} does not exist`); 27 | } 28 | 29 | if (childPin.is_merged) { 30 | return Promise.reject( 31 | `Pin has previously been merged with parent pin ${childPin.mergedParentPin}` 32 | ); 33 | } 34 | // Find parent pin to check if it does exist 35 | return PinModel.findOne({ _id: mergedParentPin }); 36 | }) 37 | .then((parentPin) => { 38 | // Save current merged children of the parent pin to be used in later promise 39 | mergedChildrenPins = parentPin.merged_children_pins || []; 40 | 41 | // Set child pin properties to be merged with parent pin 42 | const updatingChildPinProperties = { 43 | is_merged: true, 44 | merged_parent_pin: mergedParentPin, 45 | }; 46 | 47 | // TODO: Should make it as atomic transaction to update both pins 48 | // Update child pin first 49 | return PinModel.update( 50 | { _id: pinId }, 51 | { $set: updatingChildPinProperties } 52 | ); 53 | }) 54 | .then((updatedPin) => { 55 | // Save updated child pin to be returned in last promise 56 | updatedChildPin = updatedPin; 57 | 58 | // Set parent pin properties to include new child pin 59 | mergedChildrenPins.push(pinId); 60 | const updatingParentPinProperties = { 61 | merged_children_pins: mergedChildrenPins, 62 | }; 63 | // Update parent pin 64 | return PinModel.update( 65 | { _id: mergedParentPin }, 66 | { $set: updatingParentPinProperties } 67 | ); 68 | }) 69 | .then((updatedParentPin) => Promise.resolve([updatedChildPin, updatedParentPin])) 70 | .catch(err => Promise.reject(err)); 71 | } 72 | } 73 | 74 | module.exports = function registerPinMergingService() { 75 | const app = this; 76 | app.use('/pins/:pinId/merging', new PinMergingService()); 77 | 78 | const pinMergingService = app.service('/pins/:pinId/merging'); 79 | pinMergingService.before(hooks.before); 80 | pinMergingService.after(hooks.after); 81 | }; 82 | -------------------------------------------------------------------------------- /src/services/pin-merging/hooks/prepare-activity-log.js: -------------------------------------------------------------------------------- 1 | const errors = require('feathers-errors'); 2 | const mongoose = require('mongoose'); 3 | 4 | const Pin = require('../../pin/pin-model'); 5 | 6 | const MERGE_PIN = require('../../../constants/actions').MERGE_PIN; 7 | const MERGING = require('../../../constants/actions').types.MERGING; 8 | 9 | const safetyCheck = (hook) => { 10 | // hook.params.user must be populated by auth.populateUser before hook 11 | if (!hook.params.user) { 12 | throw new errors.GeneralError('Internal error: User is not populated'); 13 | } 14 | 15 | // hook.params.pinId must be provided via request URL 16 | if (!hook.params.pinId || !mongoose.Types.ObjectId.isValid(hook.params.pinId)) { 17 | throw new errors.NotFound(`No pin found for id '${hook.params.pinId}'`); 18 | } 19 | 20 | // hook.data.mergedParent must be provided via body data 21 | if (!hook.data.mergedParentPin || !mongoose.Types.ObjectId.isValid(hook.data.mergedParentPin)) { 22 | throw new errors.BadRequest(`No parent pin found for id '${hook.data.mergedParentPin}'`); 23 | } 24 | }; 25 | 26 | // For before hook to prepare activity log and will be used by after hook 27 | // Note: we can't do this in after hook because we need previous pin's properties before updated 28 | const prepareActivityLog = () => (hook) => { 29 | // throw error if hook is invalid 30 | safetyCheck(hook); 31 | 32 | const pinId = hook.params.pinId; 33 | const nameOfUser = hook.params.user.name; 34 | const department = hook.params.user.department; 35 | const mergedParentPin = hook.data.mergedParentPin; 36 | 37 | return Promise.all([ 38 | Pin.findById(pinId), // child pin 39 | Pin.findById(mergedParentPin), // parent pin 40 | ]) 41 | .then(pins => { 42 | const childPin = pins[0]; 43 | const parentPin = pins[1]; 44 | const changedFields = ['is_merged', 'merged_parent_pin']; 45 | const previousValues = [childPin.is_merged, childPin.merged_parent_pin]; 46 | const updatedValues = [true, mergedParentPin]; 47 | /* eslint-disable no-underscore-dangle */ 48 | const description = `${nameOfUser} merged pin #${childPin._id} ` + 49 | `into #${parentPin._id}`; 50 | /* eslint-enable */ 51 | // Pass logInfo object to after hook by attaching to hook.data 52 | const logInfo = { 53 | user: nameOfUser, 54 | organization: childPin.organization, 55 | department, 56 | actionType: MERGING, 57 | action: MERGE_PIN, 58 | pin_id: pinId, 59 | changed_fields: changedFields, 60 | previous_values: previousValues, 61 | updated_values: updatedValues, 62 | description, 63 | timestamp: Date.now(), 64 | }; 65 | 66 | // Attach data for log-activity hook 67 | hook.data.logInfo = logInfo; // eslint-disable-line no-param-reassign 68 | 69 | return Promise.resolve(hook); 70 | }) 71 | .catch(err => { 72 | throw new Error(err); 73 | }); 74 | }; 75 | 76 | module.exports = prepareActivityLog; 77 | -------------------------------------------------------------------------------- /src/header.md: -------------------------------------------------------------------------------- 1 | Welcome to YouPin API documents. 2 | 3 | This document explains how you can create/retrive/modify/delete information in YouPin database. 4 | 5 | To retrieve information from YouPin API, you are ready to go. 6 | But if you want to create/modify/delete data, you need to ask YouPin team to create user account for you. 7 | The team will provide you a password that you will use to access YouPin API. 8 | 9 | YouPin API is using Basic Authentication with an exchange of JWT token. 10 | This means you need to send one request with your username & password to `/auth/local`. 11 | YouPin API will then return you with a JWT token that you will use to create/modiy/delete data in YouPin API. 12 | 13 | Note that JWT token has one-day lifetime. 14 | This means that after you request for the JWT token, it can be used only within a day. 15 | You need to put a mechanism to refresh and get a new token after the old token is expired. 16 | 17 | An example of curl command to create Pin: 18 | 19 | 1. Request token 20 | 21 | Curl command: 22 | ``` 23 | curl -X POST https://dev.api.youpin.city/auth/local \ 24 | -H "Content-Type: application/json" \ 25 | -d '{ "email":"a@youpin.city", "password":"a"}' 26 | ``` 27 | 28 | Response with JWT token returning: 29 | ``` 30 | { 31 | "token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJfaWQiOiI1NzlhZDcwNzVjMWE4ODU5ZWNlNGE0YTciLCJpYXQiOjE0Njk4NDU3NTEsImV4cCI6MTQ2OTkzMjE1MSwiaXNzIjoiZmVhdGhlcnMifQ.U_2iQqTgQH3wqV-PyW-iyFFfzYNfkLOkuQefu6KSKtQ", 32 | "data":{ 33 | "_id":"579ad7075c1a8859ece4a4a7", 34 | "name":"A", 35 | "phone":"081-000-0000", 36 | "email":"a@youpin.city", 37 | "role":"admin", 38 | "__v":0, 39 | "owner_app_id":[], 40 | "customer_app_id":[], 41 | "updated_time":"2016-07-29T04:09:43.430Z", 42 | "created_time":"2016-07-29T04:09:43.430Z" 43 | } 44 | } 45 | ``` 46 | 47 | 2. Take token and use it in any following request. For example to post Pin: 48 | 49 | Note that to post Pin, owner id and provider id are required. 50 | Please use _id from the previous step and fill them in. 51 | 52 | Curl command: 53 | ``` 54 | curl -X POST https://dev.api.youpin.city/pins \ 55 | -H "Content-Type: application/json" \ 56 | -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJfaWQiOiI1NzlhZDcwNzVjMWE4ODU5ZWNlNGE0YTciLCJpYXQiOjE0Njk3OTI5MTMsImV4cCI6MTQ2OTg3OTMxMywiaXNzIjoiZmVhdGhlcnMifQ.2uC9BkzylgaYVE889eL8qU9glWgHwFJqZHBTmllsHl0' \ 57 | -d '{"detail":"example pin", "owner": "579ad7075c1a8859ece4a4a7", "provider": "579ad7075c1a8859ece4a4a7"}' 58 | ``` 59 | 60 | Success Response: 61 | ``` 62 | { 63 | "__v":0, 64 | "detail":"example pin", 65 | "owner":"579ad7075c1a8859ece4a4a7", 66 | "provider":"579ad7075c1a8859ece4a4a7", 67 | "_id":"579c1265520824dc88eb4ad8", 68 | "videos":[], 69 | "voters":[], 70 | "comments":[], 71 | "tags":[], 72 | "location":{ 73 | "coordinates":[100.5018549,13.756727], 74 | "type":"Point" 75 | }, 76 | "photos":[], 77 | "neighborhood":[], 78 | "mentions":[], 79 | "followers":[], 80 | "updated_time":"2016-07-30T02:35:17.831Z", 81 | "created_time":"2016-07-30T02:35:17.831Z", 82 | "categories":[] 83 | } 84 | ``` 85 | -------------------------------------------------------------------------------- /src/services/summary/hooks/trigger-calculation.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const moment = require('moment'); 3 | 4 | const actions = require('../../../constants/actions'); 5 | const ActivityLog = require('../../activity-log/activity-log-model'); 6 | const Summary = require('../../summary/summary-model'); 7 | 8 | // triggerCalculation is a before hook function to trigger a summary 9 | // calculation on specified dates and organization. 10 | // If no date specifies, it will calculate today's summary. 11 | const triggerCalculation = () => (hook) => { 12 | const startDate = hook.params.query.start_date; 13 | const endDate = hook.params.query.end_date; 14 | const organizationName = hook.params.query.organization; 15 | const trigger = hook.params.query.trigger; 16 | if (!trigger) { 17 | return Promise.resolve(hook); 18 | } 19 | let timestamp = {}; 20 | // Change start_date and end_date to mongoose query format. 21 | if (startDate) { 22 | timestamp.$gte = startDate; 23 | } 24 | if (endDate) { 25 | timestamp.$lte = endDate; 26 | } 27 | if (_.isEmpty(timestamp)) { 28 | timestamp = moment().utc().format('YYYY-MM-DD'); 29 | } 30 | // Start a calculation 31 | const query = { 32 | organization: organizationName, 33 | timestamp, 34 | }; 35 | // Initialise summary with zeros. 36 | const initialSummary = { 37 | [actions.VERIFY]: 0, 38 | [actions.UNVERIFY]: 0, 39 | [actions.ASSIGN]: 0, 40 | [actions.DENY]: 0, 41 | [actions.PROCESS]: 0, 42 | [actions.RESOLVE]: 0, 43 | [actions.REJECT]: 0, 44 | }; 45 | return ActivityLog.find(query) 46 | .then(logs => { 47 | // Loop through activity log and summarize each day into table. 48 | const summaryTable = {}; 49 | for (let i = 0; i < logs.length; ++i) { 50 | const date = moment(logs[i].timestamp).utc().format('YYYY-MM-DD'); 51 | if (!(date in summaryTable)) { 52 | summaryTable[date] = {}; 53 | } 54 | const department = logs[i].department; 55 | if (!(department in summaryTable[date])) { 56 | summaryTable[date][department] = _.cloneDeep(initialSummary); 57 | } 58 | summaryTable[date][department][logs[i].action]++; 59 | } 60 | // Reformat to legitimate input for Restful /summaries service. 61 | const allDateSummaries = []; 62 | for (const date of Object.keys(summaryTable)) { 63 | allDateSummaries.push({ 64 | date, 65 | organization: organizationName, 66 | by_department: summaryTable[date], 67 | }); 68 | } 69 | // Create a promise to update a single daily summary. 70 | const updateOrCreateOneSummary = (one) => 71 | Summary.findOneAndUpdate({ date: one.date, organization: one.organization }, 72 | one, { upsert: true }); 73 | // Utilize the above promise with all daily summaries. 74 | const updateOrCreateAllSummaries = allDateSummaries.map(updateOrCreateOneSummary); 75 | return Promise.all(updateOrCreateAllSummaries) 76 | .then(() => Promise.resolve(hook)); 77 | }).catch(err => { 78 | throw new Error(err); 79 | }); 80 | }; 81 | 82 | module.exports = triggerCalculation; 83 | -------------------------------------------------------------------------------- /src/services/pin/pin-model.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | const PENDING = require('../../constants/pin-states').PENDING; 4 | 5 | const Schema = mongoose.Schema; 6 | 7 | const voteSchema = new Schema({ 8 | user_id: { type: Schema.Types.ObjectId, required: true, ref: 'User' }, 9 | vote_type: { type: String, required: true }, 10 | }); 11 | 12 | const commentSchema = new Schema({ 13 | created_time: { type: Date, required: true, default: Date.now }, 14 | detail: { type: String, required: true }, 15 | owner: { type: Schema.Types.ObjectId, ref: 'User', required: true }, 16 | mentions: [{ type: Schema.Types.ObjectId, ref: 'User' }], 17 | photos: [String], 18 | tags: [String], 19 | updated_time: { type: Date, required: true, default: Date.now }, 20 | videos: [String], 21 | voters: [voteSchema], 22 | }); 23 | 24 | const pinSchema = new Schema({ 25 | assigned_department: { type: Schema.Types.ObjectId, ref: 'Department' }, 26 | assigned_time: { type: Date }, 27 | assigned_users: [{ type: Schema.Types.ObjectId, ref: 'User' }], 28 | categories: [String], 29 | closed_reason: { type: String }, 30 | comments: [commentSchema], 31 | progresses: [commentSchema], 32 | created_time: { type: Date, required: true, default: Date.now }, 33 | detail: { type: String, required: true }, 34 | followers: [{ type: Schema.Types.ObjectId, ref: 'User' }], 35 | is_archived: { type: Boolean, default: false }, 36 | is_featured: { type: Boolean, default: false }, 37 | is_merged: { type: Boolean, default: false }, 38 | level: String, 39 | location: { 40 | type: { type: String }, 41 | coordinates: { 42 | type: [], 43 | index: '2dsphere', 44 | }, 45 | }, 46 | mentions: [{ type: Schema.Types.ObjectId, ref: 'User' }], 47 | merged_children_pins: [{ type: Schema.Types.ObjectId, ref: 'Pin' }], 48 | merged_parent_pin: { type: Schema.Types.ObjectId, ref: 'Pin' }, 49 | neighborhood: [String], 50 | organization: { 51 | type: Schema.Types.ObjectId, 52 | ref: 'Organization', 53 | required: true, 54 | }, 55 | owner: { type: Schema.Types.ObjectId, ref: 'User', required: true }, 56 | photos: [String], 57 | provider: { type: Schema.Types.ObjectId, ref: 'User', required: true }, 58 | processed_by: { type: Schema.Types.ObjectId, ref: 'User' }, 59 | processing_time: { type: Date }, 60 | rejected_time: { type: Date }, 61 | resolved_time: { type: Date }, 62 | status: { type: String, required: true, default: PENDING }, 63 | tags: [String], 64 | updated_time: { type: Date, required: true, default: Date.now }, 65 | videos: [Schema.Types.ObjectId], 66 | voters: [voteSchema], 67 | }); 68 | 69 | pinSchema.index({ location: '2dsphere' }); 70 | 71 | pinSchema.pre('find', function populateFields(next) { 72 | this.populate('assigned_users') 73 | .populate('assigned_department') 74 | .populate('owner') 75 | .populate('progresses.owner'); 76 | next(); 77 | }); 78 | 79 | pinSchema.pre('findOne', function populateFields(next) { 80 | this.populate('assigned_users') 81 | .populate('assigned_department') 82 | .populate('owner') 83 | .populate('progresses.owner'); 84 | next(); 85 | }); 86 | 87 | const Pin = mongoose.model('Pin', pinSchema); 88 | 89 | module.exports = Pin; 90 | -------------------------------------------------------------------------------- /src/services/summarize-state/index.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const hooks = require('./hooks'); 3 | 4 | const pinStates = require('../../constants/pin-states'); 5 | 6 | // Models 7 | const Pin = require('../pin/pin-model'); 8 | 9 | class Service { 10 | constructor(options) { 11 | this.options = options || {}; 12 | } 13 | 14 | find(params) { 15 | const startDate = params.query.start_date; 16 | const endDate = params.query.end_date; 17 | if (!startDate || !endDate) { 18 | return Promise.reject(new Error('Please specified both start_date and end_date')); 19 | } 20 | // TODO(parnurzeal): Support multiple organizations. Currently, we are focusing 21 | // solely on having just one organization per YouPin system. 22 | // Put params into a mongoose query format. 23 | const query = { 24 | created_time: { 25 | $gte: startDate, 26 | $lte: endDate, 27 | }, 28 | }; 29 | // Find pins that belong to the organization 30 | return Pin.find(query).select('status assigned_department assigned_users') 31 | .then(populatedPins => { 32 | // Initialise summary with zeros. 33 | const initialSummary = { 34 | [pinStates.PENDING]: 0, 35 | [pinStates.ASSIGNED]: 0, 36 | [pinStates.PROCESSING]: 0, 37 | [pinStates.RESOLVED]: 0, 38 | [pinStates.REJECTED]: 0, 39 | }; 40 | // Summarize all pins into a table of department-states 41 | const summaryTable = {}; 42 | for (let i = 0; i < populatedPins.length; ++i) { 43 | const department = populatedPins[i].assigned_department; 44 | const pinStatus = populatedPins[i].status; 45 | let departmentName = 'None'; 46 | if (department) { 47 | departmentName = department.name; 48 | } 49 | if (!(departmentName in summaryTable)) { 50 | summaryTable[departmentName] = { total: _.cloneDeep(initialSummary) }; 51 | } 52 | summaryTable[departmentName].total[pinStatus]++; 53 | 54 | // Also populate summary table for each assigned user. 55 | let assignedUsers = populatedPins[i].assigned_users; 56 | if (assignedUsers.length === 0) { 57 | // Assign to 'unassigned' user if pin is not assigned to anyone. 58 | assignedUsers = [{ name: 'unassigned' }]; 59 | } 60 | for (let j = 0; j < assignedUsers.length; ++j) { 61 | const userName = assignedUsers[j].name; 62 | if (!(userName in summaryTable[departmentName])) { 63 | summaryTable[departmentName][userName] = _.cloneDeep(initialSummary); 64 | } 65 | summaryTable[departmentName][userName][pinStatus]++; 66 | } 67 | } 68 | return Promise.resolve(summaryTable); 69 | }) 70 | .catch(err => Promise.reject(new Error(err))); 71 | } 72 | } 73 | 74 | module.exports = function () { // eslint-disable-line func-names 75 | const app = this; 76 | app.use('/summarize-states', new Service()); 77 | const summarizeStateService = app.service('/summarize-states'); 78 | summarizeStateService.before(hooks.before); 79 | summarizeStateService.after(hooks.after); 80 | }; 81 | 82 | module.exports.Service = Service; 83 | -------------------------------------------------------------------------------- /test/services/pin-merging/hooks/prepare-activity-log.test.js: -------------------------------------------------------------------------------- 1 | // Test helper functions 2 | const assertTestEnv = require('../../../test_helper').assertTestEnv; 3 | const expect = require('../../../test_helper').expect; 4 | const loadFixture = require('../../../test_helper').loadFixture; 5 | const stub = require('../../../test_helper').stub; 6 | 7 | // Models 8 | const Department = require('../../../../src/services/department/department-model'); 9 | const Pin = require('../../../../src/services/pin/pin-model'); 10 | 11 | // Fixtures 12 | const departments = require('../../../fixtures/departments'); 13 | const pins = require('../../../fixtures/pins'); 14 | const DEPARTMENT_GENERAL_ID = require('../../../fixtures/constants').DEPARTMENT_GENERAL_ID; 15 | const ORGANIZATION_ID = require('../../../fixtures/constants').ORGANIZATION_ID; 16 | const PIN_ASSIGNED_ID = require('../../../fixtures/constants').PIN_ASSIGNED_ID; 17 | const PIN_PENDING_ID = require('../../../fixtures/constants').PIN_PENDING_ID; 18 | 19 | // App stuff 20 | const mongoose = require('mongoose'); 21 | const actions = require('../../../../src/constants/actions'); 22 | const prepareActivityLog = require('../../../../src/services/pin-merging/hooks/prepare-activity-log'); // eslint-disable-line max-len 23 | 24 | // Exit test if NODE_ENV is not equal `test` 25 | assertTestEnv(); 26 | 27 | describe('Prepare Activity Log Hook for Pin Merging', () => { 28 | let mockHook; 29 | 30 | before((done) => { 31 | Promise.all([ 32 | loadFixture(Department, departments), 33 | loadFixture(Pin, pins), 34 | ]) 35 | .then(() => done()) 36 | .catch(err => done(err)); 37 | }); 38 | 39 | after((done) => { 40 | Promise.all([ 41 | Pin.remove({}), 42 | Department.remove({}), 43 | ]) 44 | .then(() => done()) 45 | .catch(err => done(err)); 46 | }); 47 | 48 | beforeEach(() => { 49 | mockHook = { 50 | type: 'before', 51 | app: {}, 52 | params: { 53 | pinId: PIN_PENDING_ID, 54 | user: { 55 | name: 'Aunt You-pin', 56 | department: mongoose.Types.ObjectId(DEPARTMENT_GENERAL_ID), // eslint-disable-line new-cap,max-len 57 | }, 58 | }, 59 | result: {}, 60 | data: { 61 | mergedParentPin: PIN_ASSIGNED_ID, 62 | }, 63 | }; 64 | }); 65 | 66 | it('attaches logInfo to hook.data', (done) => { 67 | const dateStub = stub(Date, 'now', () => '2016-12-24'); 68 | 69 | prepareActivityLog()(mockHook) 70 | .then(() => { 71 | const expectedLogInfo = { 72 | user: 'Aunt You-pin', 73 | organization: mongoose.Types.ObjectId(ORGANIZATION_ID), // eslint-disable-line new-cap 74 | department: mongoose.Types.ObjectId(DEPARTMENT_GENERAL_ID), // eslint-disable-line new-cap 75 | actionType: actions.types.MERGING, 76 | action: actions.MERGE_PIN, 77 | pin_id: PIN_PENDING_ID, 78 | changed_fields: ['is_merged', 'merged_parent_pin'], 79 | previous_values: [false, undefined], 80 | updated_values: [true, PIN_ASSIGNED_ID], 81 | description: `Aunt You-pin merged pin #${PIN_PENDING_ID} ` + 82 | `into #${PIN_ASSIGNED_ID}`, 83 | timestamp: Date.now(), 84 | }; 85 | 86 | expect(mockHook.data.logInfo).to.deep.equal(expectedLogInfo); 87 | 88 | dateStub.restore(); 89 | done(); 90 | }); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | const bodyParser = require('body-parser'); 2 | const compress = require('compression'); 3 | const configuration = require('feathers-configuration'); 4 | const cookieParser = require('cookie-parser'); 5 | const cors = require('cors'); 6 | const favicon = require('serve-favicon'); 7 | const feathers = require('feathers'); 8 | const hooks = require('feathers-hooks'); 9 | const path = require('path'); 10 | const rest = require('feathers-rest'); 11 | const serveStatic = require('feathers').static; 12 | const session = require('express-session'); 13 | const socketio = require('feathers-socketio'); 14 | 15 | const init = require('./init'); 16 | const middleware = require('./middleware'); 17 | 18 | init(); 19 | 20 | const app = feathers(); 21 | 22 | app.configure(configuration(path.join(__dirname, '..'))); 23 | 24 | const views = require('./views'); 25 | const services = require('./services'); 26 | 27 | app.use(compress()) 28 | .options('*', cors()) 29 | .enable('strict routing') 30 | .use(cors()) 31 | .use(favicon(path.join(app.get('public'), 'favicon.ico'))) 32 | .set('views', './src/views') 33 | .set('view engine', 'pug') 34 | .use('/static', serveStatic(app.get('public'))) 35 | // set bodyParser to not strict so that API can recieve bare url string 36 | .use(bodyParser.json({ strict: false })) 37 | .use(bodyParser.urlencoded({ extended: true })) 38 | .use(cookieParser()) 39 | .use(session({ 40 | secret: 'sssshhh', 41 | resave: false, 42 | saveUninitialized: true, 43 | })) 44 | .configure(hooks()) 45 | .configure(rest()) 46 | .configure(socketio()) 47 | // set X-YOUPIN-3-APP-KEY for app authentication hook 48 | .use((req, res, next) => { 49 | const youpinAppKeyName = 'X-YOUPIN-3-APP-KEY'; 50 | 51 | if (req.get(youpinAppKeyName)) { 52 | req.feathers.youpinAppKey = // eslint-disable-line no-param-reassign 53 | req.get(youpinAppKeyName); 54 | } 55 | next(); 56 | }) 57 | // An endpoint to setting client successRedirect and failureRedirect into config 58 | // before forwarding to normal facebook login at /auth/facebook 59 | .use('/auth/facebook/login', (req, res) => { 60 | const config = app.get('auth'); 61 | 62 | if (req.query.successRedirect) { 63 | config.clientSuccessRedirect = req.query.successRedirect; 64 | } 65 | if (req.query.failureRedirect) { 66 | config.clientFailureRedirect = req.query.failureRedirect; 67 | } 68 | app.set('auth', config); 69 | res.redirect('/auth/facebook'); 70 | }) 71 | .use('/auth/facebook/success', (req, res) => { 72 | // Facebook site will set jwt in cookie 73 | const jwt = req.cookies['feathers-jwt']; 74 | const clientSuccessRedirect = app.get('auth').clientSuccessRedirect; 75 | 76 | if (!clientSuccessRedirect) { 77 | res.status(400).send({ error: 'No `successRedirect` provided in querystring' }); 78 | } else { 79 | res.redirect(`${clientSuccessRedirect}?token=${jwt}`); 80 | } 81 | }) 82 | .use('/auth/facebook/failure', (req, res) => { 83 | const clientFailreRedirect = app.get('auth').clientFailureRedirect; 84 | 85 | if (!clientFailreRedirect) { 86 | res.status(400).send({ error: 'No `failureRedirect` provided in querystring' }); 87 | } else { 88 | res.redirect(clientFailreRedirect); 89 | } 90 | }) 91 | .configure(services) 92 | .configure(views) 93 | .configure(middleware); 94 | 95 | module.exports = app; 96 | -------------------------------------------------------------------------------- /test/services/pin/hooks/prepare-activity-log.test.js: -------------------------------------------------------------------------------- 1 | // Test helper functions 2 | const assertTestEnv = require('../../../test_helper').assertTestEnv; 3 | const expect = require('../../../test_helper').expect; 4 | const loadFixture = require('../../../test_helper').loadFixture; 5 | const stub = require('../../../test_helper').stub; 6 | 7 | // Models 8 | const PinModel = require('../../../../src/services/pin/pin-model'); 9 | 10 | // Fixtures 11 | const pins = require('../../../fixtures/pins'); 12 | const adminUser = require('../../../fixtures/admin_user'); 13 | 14 | // App stuff 15 | const mongoose = require('mongoose'); 16 | const actions = require('../../../../src/constants/actions'); 17 | const prepareActivityLog = require('../../../../src/services/pin/hooks/prepare-activity-log'); // eslint-disable-line max-len 18 | 19 | // Exit test if NODE_ENV is not equal `test` 20 | assertTestEnv(); 21 | 22 | describe('Prepare Activity Log Hook', () => { 23 | let mockHook; 24 | 25 | before((done) => { 26 | loadFixture(PinModel, pins) 27 | .then(() => done()) 28 | .catch(err => done(err)); 29 | }); 30 | 31 | after((done) => { 32 | PinModel.remove({}) 33 | .then(() => done()) 34 | .catch(err => done(err)); 35 | }); 36 | 37 | beforeEach(() => { 38 | mockHook = { 39 | type: 'before', 40 | app: {}, 41 | id: pins[0]._id, // eslint-disable-line no-underscore-dangle 42 | params: { 43 | user: { 44 | name: adminUser.name, 45 | }, 46 | }, 47 | result: {}, 48 | data: { 49 | $push: { 50 | progresses: { 51 | photos: ['New progress photo url'], 52 | detail: 'New progress', 53 | }, 54 | }, 55 | owner: adminUser._id, // eslint-disable-line no-underscore-dangle 56 | detail: 'Updated pin detail', 57 | location: { 58 | coordinates: [15, 15.9], 59 | type: ['Point'], 60 | }, 61 | }, 62 | }; 63 | }); 64 | 65 | it('attaches logInfo to hook.data', (done) => { 66 | const pinId = pins[0]._id; // eslint-disable-line no-underscore-dangle 67 | 68 | const dateStub = stub(Date, 'now', () => '2016-11-25'); 69 | 70 | prepareActivityLog()(mockHook) 71 | .then(() => { 72 | // location 73 | const previousLocation = pins[0].location.coordinates.toString(); 74 | const newLocation = mockHook.data.location.coordinates.toString(); 75 | // detail 76 | const previousDetail = pins[0].detail; 77 | const newDetail = mockHook.data.detail; 78 | // progress detail 79 | const newProgressDetail = mockHook.data.$push.progresses.detail; 80 | // description 81 | const expectedDescription = `${adminUser.name} edited pin #${pinId}`; 82 | const expectedLogInfo = { 83 | user: adminUser.name, 84 | organization: mongoose.Types.ObjectId(pins[0].organization), // eslint-disable-line new-cap,max-len 85 | department: undefined, 86 | actionType: actions.types.METADATA, 87 | action: actions.UPDATE_PIN, 88 | pin_id: pinId, 89 | changed_fields: ['detail', 'location', 'progresses'], 90 | previous_values: [previousDetail, previousLocation, ''], 91 | updated_values: [newDetail, newLocation, newProgressDetail], 92 | description: expectedDescription, 93 | timestamp: Date.now(), 94 | }; 95 | expect(mockHook.data.logInfo).to.deep.equal(expectedLogInfo); 96 | 97 | dateStub.restore(); 98 | done(); 99 | }); 100 | }); 101 | }); 102 | 103 | -------------------------------------------------------------------------------- /src/views/index.js: -------------------------------------------------------------------------------- 1 | const authentication = require('feathers-authentication/client'); 2 | const feathers = require('feathers/client'); 3 | const hooks = require('feathers-hooks'); 4 | const request = require('superagent'); 5 | const rest = require('feathers-rest/client'); 6 | 7 | module.exports = function () { // eslint-disable-line func-names 8 | const app = this; 9 | const client = feathers() 10 | .configure(hooks()) 11 | .configure(rest(`${app.get('host')}:${app.get('port')}`).superagent(request)) 12 | .configure(authentication()); 13 | 14 | app.get('/', (req, res) => { 15 | res.render('index', {}); 16 | }); 17 | // Login Routing for an old user 18 | app.get('/login', (req, res) => { 19 | const query = req.query; 20 | res.render('login', { 21 | redirect_uri: query.redirect_uri, 22 | info: req.session.loginInfoMessage, 23 | error: req.session.loginErrorMessage, 24 | }); 25 | }); 26 | app.post('/login', (req, res) => { 27 | const input = req.body; 28 | if (!input.email && !input.password) { 29 | req.session.loginErrorMessage = // eslint-disable-line no-param-reassign 30 | 'Please fill both username & password.'; 31 | res.redirect(`/login?redirect_uri=${input.redirect_uri}`); 32 | } else { 33 | client.authenticate({ 34 | type: 'local', 35 | email: input.email, 36 | password: input.password, 37 | }) 38 | .then(result => { 39 | console.log('Successfully logging in:', result); 40 | req.session.user = result.data; // eslint-disable-line no-param-reassign 41 | res.redirect( 42 | `${input.redirect_uri}?` + 43 | `_id=${result.data._id}` + // eslint-disable-line no-underscore-dangle 44 | `&access_token=${result.token}`); 45 | }) 46 | .catch(err => { 47 | // redirect to login with flash message 48 | if (err) { 49 | req.session.loginErrorMessage = 'Invalid Login'; // eslint-disable-line no-param-reassign 50 | } 51 | res.redirect(`/login?redirect_uri=${input.redirect_uri}`); 52 | }); 53 | } 54 | }); 55 | // signup routing for a new user 56 | app.get('/signup', (req, res) => { 57 | const query = req.query; 58 | res.render('signup', { 59 | redirect_uri: query.redirect_uri, 60 | error: req.session.signupErrorMessage, 61 | }); 62 | }); 63 | app.post('/signup', (req, res) => { 64 | const input = req.body; 65 | if (!input.name && !input.email && !input.password) { 66 | req.session.signupErrorMessage = // eslint-disable-line no-param-reassign 67 | '*Please fill all information.'; 68 | res.redirect(`/signup?redirect_uri=${input.redirect_uri}`); 69 | } else { 70 | app.service('users') 71 | .create({ 72 | name: input.name, 73 | email: input.email, 74 | password: input.password, 75 | role: 'user', 76 | }) 77 | .then(result => { 78 | console.log('Successfully signing up user:', result); 79 | req.session.loginInfoMessage = // eslint-disable-line no-param-reassign 80 | 'You have successful signed up! Please log in.'; 81 | res.redirect(`/login?redirect_uri=${input.redirect_uri}`); 82 | }) 83 | .catch(err => { 84 | if (err && err.code === 409) { 85 | req.session.signupErrorMessage = // eslint-disable-line no-param-reassign 86 | '*It seems you already have YouPin account. ' + 87 | 'Please log in using your email and password.'; 88 | } 89 | res.redirect(`/signup?redirect_uri=${input.redirect_uri}`); 90 | }); 91 | } 92 | }); 93 | }; 94 | -------------------------------------------------------------------------------- /test/services/summary/index.test.js: -------------------------------------------------------------------------------- 1 | // Test helper functions 2 | const _ = require('lodash'); 3 | const assertTestEnv = require('../../test_helper').assertTestEnv; 4 | const expect = require('../../test_helper').expect; 5 | const loadFixture = require('../../test_helper').loadFixture; 6 | const request = require('supertest-as-promised'); 7 | 8 | // Models 9 | const ActivityLog = require('../../../src/services/activity-log/activity-log-model'); 10 | 11 | // Fixture 12 | const activityLogs = require('../../fixtures/activity_logs'); 13 | 14 | // App staff 15 | const actions = require('../../../src/constants/actions'); 16 | const app = require('../../../src/app'); 17 | 18 | // Exit test if NODE_ENV is not equal `test` 19 | assertTestEnv(); 20 | 21 | describe('summary service', () => { 22 | let server; 23 | 24 | before((done) => { 25 | server = app.listen(app.get('port')); 26 | server.once('listening', () => { 27 | // Load activity log fixture 28 | loadFixture(ActivityLog, activityLogs) 29 | .then(() => { 30 | done(); 31 | }) 32 | .catch((err) => { 33 | done(err); 34 | }); 35 | }); 36 | }); 37 | 38 | after((done) => { 39 | // Clear collections after finishing all tests. 40 | ActivityLog.remove({}) 41 | .then(() => { 42 | server.close((err) => { 43 | if (err) return done(err); 44 | 45 | return done(); 46 | }); 47 | }); 48 | }); 49 | 50 | it('registered the summaries service', () => { 51 | expect(app.service('summaries')).to.be.ok(); 52 | }); 53 | 54 | it('end-to-end triggered the calculation and returned the correct summary', 55 | (done) => request(app) 56 | .get('/summaries') 57 | .query({ trigger: true }) 58 | .query({ organization: 'Chulalongkorn' }) 59 | .query({ start_date: '2016-11-25' }) 60 | .query({ end_date: '2016-11-26' }) 61 | .expect(200) 62 | .then((summaryResponse) => { 63 | const summaries = summaryResponse.body; 64 | // Expect number to be equal to number of date in this organization data. 65 | expect(summaries.total).to.equal(2); 66 | // Change array to dictionary (key is a date). 67 | const summaryListByDate = _.keyBy(summaries.data, 'date'); 68 | const expectedResultNov25ChulaEngineering = { 69 | [actions.VERIFY]: 0, 70 | [actions.UNVERIFY]: 0, 71 | [actions.ASSIGN]: 2, 72 | [actions.DENY]: 0, 73 | [actions.PROCESS]: 1, 74 | [actions.RESOLVE]: 0, 75 | [actions.REJECT]: 0, 76 | }; 77 | expect(summaryListByDate['2016-11-25'].by_department.Engineering) 78 | .to.deep.equal(expectedResultNov25ChulaEngineering); 79 | const expectedResultNov25ChulaMedicine = { 80 | [actions.VERIFY]: 0, 81 | [actions.UNVERIFY]: 0, 82 | [actions.ASSIGN]: 0, 83 | [actions.DENY]: 0, 84 | [actions.PROCESS]: 1, 85 | [actions.RESOLVE]: 0, 86 | [actions.REJECT]: 0, 87 | }; 88 | expect(summaryListByDate['2016-11-25'].by_department.Medicine) 89 | .to.deep.equal(expectedResultNov25ChulaMedicine); 90 | const expectedResultNov26ChulaEngineering = { 91 | [actions.VERIFY]: 0, 92 | [actions.UNVERIFY]: 0, 93 | [actions.ASSIGN]: 2, 94 | [actions.DENY]: 0, 95 | [actions.PROCESS]: 1, 96 | [actions.RESOLVE]: 1, 97 | [actions.REJECT]: 0, 98 | }; 99 | expect(summaryListByDate['2016-11-26'].by_department.Engineering) 100 | .to.deep.equal(expectedResultNov26ChulaEngineering); 101 | done(); 102 | }) 103 | ); 104 | }); 105 | -------------------------------------------------------------------------------- /src/utils/gcs-uploader.js: -------------------------------------------------------------------------------- 1 | const gcloud = require('google-cloud'); 2 | const request = require('superagent'); 3 | const urlparser = require('url'); 4 | 5 | function getGCSBucketFile(gcsFileName, gcsConfig) { 6 | const gcs = gcloud.storage({ 7 | projectId: gcsConfig.projectId, 8 | keyFilename: gcsConfig.keyFile, 9 | }); 10 | const bucket = gcs.bucket(gcsConfig.bucket); 11 | const bucketFile = bucket.file(gcsFileName); 12 | 13 | return bucketFile; 14 | } 15 | 16 | function getMetadataFromUrl(url) { 17 | return new Promise((resolve, reject) => { 18 | request 19 | .head(url) 20 | .end((err, photoHeaderResp) => { 21 | if (err) return reject(err); 22 | // Get metadata 23 | const pathArray = urlparser.parse(url).pathname.split('/'); 24 | const filename = pathArray[pathArray.length - 1]; 25 | const mimetype = photoHeaderResp.header['content-type']; 26 | const size = photoHeaderResp.header['content-length']; 27 | 28 | return resolve({ 29 | filename, 30 | mimetype, 31 | size, 32 | }); 33 | }); 34 | }); 35 | } 36 | 37 | // Google Cloud Storage Uploader 38 | class GCSUploader { 39 | constructor(gcsConfig) { 40 | this.gcsConfig = gcsConfig; 41 | } 42 | 43 | getGCSPublicUrl(gcsFileName) { 44 | return `${this.gcsConfig.gcsUrl}/${this.gcsConfig.bucket}/${gcsFileName}`; 45 | } 46 | 47 | /* 48 | * Upload a file 49 | * @param reqFile A file attached with the request object 50 | */ 51 | upload(reqFile) { 52 | return new Promise((resolve, reject) => { 53 | if (!reqFile) return reject(new Error('No file provided')); 54 | 55 | const gcsFileName = `${Date.now()}_${reqFile.originalname}`; 56 | const bucketFile = getGCSBucketFile(gcsFileName, this.gcsConfig); 57 | const stream = bucketFile.createWriteStream(); 58 | 59 | stream.on('error', (err) => { 60 | reqFile.cloudStorageError = err; // eslint-disable-line no-param-reassign 61 | 62 | return reject(err); 63 | }); 64 | 65 | stream.on('finish', () => { 66 | const publicUrl = this.getGCSPublicUrl(gcsFileName, this.gcsConfig); 67 | 68 | /* eslint-disable no-param-reassign */ 69 | reqFile.cloudStorageObject = gcsFileName; 70 | reqFile.cloudStoragePublicUrl = publicUrl; 71 | /* eslint-enable no-param-reassign */ 72 | 73 | return resolve(reqFile); 74 | }); 75 | 76 | return stream.end(reqFile.buffer); 77 | }); 78 | } 79 | 80 | /* 81 | * Get metadata and download a file from URL, then, upload it to GCS 82 | * @param url URL to download file and upload to GCS 83 | */ 84 | uploadFromUrl(url) { 85 | return getMetadataFromUrl(url) 86 | .then((metadata) => new Promise((resolve, reject) => { 87 | const gcsFileName = `${Date.now()}_${metadata.filename}`; 88 | const bucketFile = getGCSBucketFile(gcsFileName, this.gcsConfig); 89 | const filePublicUrl = this.getGCSPublicUrl(gcsFileName, this.gcsConfig); 90 | 91 | console.log('Downloading photo...'); 92 | console.log(`Name: ${metadata.filename}`); 93 | console.log(`Mimetype: ${metadata.mimetype}`); 94 | console.log(`Size: ${metadata.size}`); 95 | console.log(`To: ${filePublicUrl}`); 96 | 97 | // Download and pipe it to GCS 98 | const uploadPipe = request.get(url).pipe(bucketFile.createWriteStream()); 99 | 100 | uploadPipe.on('error', (err) => reject(err)); 101 | 102 | uploadPipe.on('finish', () => { 103 | const file = { 104 | cloudStoragePublicUrl: filePublicUrl, 105 | mimetype: metadata.mimetype, 106 | size: metadata.size, 107 | }; 108 | return resolve(file); 109 | }); 110 | })) 111 | .catch((error) => Promise.reject(error)); 112 | } 113 | } 114 | 115 | module.exports = GCSUploader; 116 | -------------------------------------------------------------------------------- /test/services/pin-state-transition/hooks/prepare-activity-log.test.js: -------------------------------------------------------------------------------- 1 | // Test helper functions 2 | const assertTestEnv = require('../../../test_helper').assertTestEnv; 3 | const expect = require('../../../test_helper').expect; 4 | const loadFixture = require('../../../test_helper').loadFixture; 5 | const stub = require('../../../test_helper').stub; 6 | 7 | // Models 8 | const Department = require('../../../../src/services/department/department-model'); 9 | const Pin = require('../../../../src/services/pin/pin-model'); 10 | 11 | // Fixtures 12 | const departments = require('../../../fixtures/departments'); 13 | const pins = require('../../../fixtures/pins'); 14 | 15 | // Constants 16 | const { 17 | DEPARTMENT_GENERAL_ID, 18 | ORGANIZATION_ID, 19 | PIN_PENDING_ID, 20 | } = require('../../../fixtures/constants'); 21 | 22 | // App stuff 23 | const ObjectId = require('mongoose').Types.ObjectId; 24 | const actions = require('../../../../src/constants/actions'); 25 | const prepareActivityLog = require('../../../../src/services/pin-state-transition/hooks/prepare-activity-log'); // eslint-disable-line max-len 26 | // Constants 27 | const { EMAIL_NOTI_NON_ASSIGNED_TEXT } = require('../../../../src/constants/strings'); 28 | 29 | // Exit test if NODE_ENV is not equal `test` 30 | assertTestEnv(); 31 | 32 | describe('Prepare Activity Log Hook for State Transition', () => { 33 | let mockHook; 34 | 35 | before((done) => { 36 | Promise.all([ 37 | loadFixture(Department, departments), 38 | loadFixture(Pin, pins), 39 | ]) 40 | .then(() => done()) 41 | .catch(err => done(err)); 42 | }); 43 | 44 | after((done) => { 45 | Promise.all([ 46 | Pin.remove({}), 47 | Department.remove({}), 48 | ]) 49 | .then(() => done()) 50 | .catch(err => done(err)); 51 | }); 52 | 53 | beforeEach(() => { 54 | mockHook = { 55 | type: 'before', 56 | app: {}, 57 | params: { 58 | // pins[0] is in pending state 59 | pinId: ObjectId(PIN_PENDING_ID), // eslint-disable-line new-cap 60 | user: { 61 | name: 'Aunt You-pin', 62 | department: ObjectId(DEPARTMENT_GENERAL_ID), // eslint-disable-line new-cap,max-len 63 | }, 64 | }, 65 | result: {}, 66 | data: { 67 | state: 'assigned', 68 | assigned_department: DEPARTMENT_GENERAL_ID, 69 | }, 70 | }; 71 | }); 72 | 73 | it('attaches logInfo to hook.data', (done) => { 74 | const pinId = ObjectId(PIN_PENDING_ID); // eslint-disable-line new-cap 75 | 76 | const dateStub = stub(Date, 'now', () => '2016-11-25'); 77 | 78 | prepareActivityLog()(mockHook) 79 | .then(() => { 80 | const expectedLogInfo = { 81 | user: 'Aunt You-pin', 82 | organization: ObjectId(ORGANIZATION_ID), // eslint-disable-line new-cap,max-len 83 | department: ObjectId(DEPARTMENT_GENERAL_ID), // eslint-disable-line new-cap,max-len 84 | actionType: actions.types.STATE_TRANSITION, 85 | action: actions.ASSIGN, 86 | pin_id: pinId, 87 | changed_fields: ['status', 'assigned_department'], 88 | previous_values: ['pending', EMAIL_NOTI_NON_ASSIGNED_TEXT], 89 | updated_values: ['assigned', ObjectId(DEPARTMENT_GENERAL_ID)], // eslint-disable-line new-cap,max-len 90 | description: `Aunt You-pin assigned pin #${PIN_PENDING_ID} ` + 91 | 'to department Department of Nerds', 92 | timestamp: Date.now(), 93 | }; 94 | expect(mockHook.data.logInfo).to.deep.equal(expectedLogInfo); 95 | 96 | dateStub.restore(); 97 | done(); 98 | }) 99 | .catch(err => done(err)); 100 | }); 101 | 102 | it('attaches previous status to hook.data', (done) => { 103 | prepareActivityLog()(mockHook) 104 | .then(() => { 105 | expect(mockHook.data.previousState).to.equal('pending'); 106 | 107 | done(); 108 | }) 109 | .catch(err => done(err)); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /test/services/organization/index.test.js: -------------------------------------------------------------------------------- 1 | // Test helper functions 2 | const request = require('supertest-as-promised'); 3 | const { 4 | assertTestEnv, 5 | expect, 6 | loadFixture, 7 | login, 8 | } = require('../../test_helper'); 9 | 10 | // Models 11 | const User = require('../../../src/services/user/user-model'); 12 | const Organization = require('../../../src/services/organization/organization-model'); 13 | 14 | // Fixtures 15 | const adminUser = require('../../fixtures/admin_user'); 16 | const organizations = require('../../fixtures/organizations'); 17 | const superAdminUser = require('../../fixtures/super_admin_user'); 18 | 19 | // App stuff 20 | const app = require('../../../src/app'); 21 | 22 | // Exit test if NODE_ENV is not equal `test` 23 | assertTestEnv(); 24 | 25 | describe('organization service', () => { 26 | let server; 27 | 28 | before((done) => { 29 | server = app.listen(app.get('port')); 30 | server.once('listening', () => { 31 | // Create admin user and app3rd for admin 32 | Promise.all([ 33 | loadFixture(User, superAdminUser), 34 | loadFixture(User, adminUser), 35 | loadFixture(Organization, organizations), 36 | ]) 37 | .then(() => { 38 | done(); 39 | }) 40 | .catch((err) => { 41 | done(err); 42 | }); 43 | }); 44 | }); 45 | 46 | after((done) => { 47 | // Clear collections after finishing all tests. 48 | Promise.all([ 49 | User.remove({}), 50 | Organization.remove({}), 51 | ]) 52 | .then(() => { 53 | server.close((err) => { 54 | if (err) return done(err); 55 | 56 | return done(); 57 | }); 58 | }); 59 | }); 60 | 61 | it('registered the organizations service', () => { 62 | expect(app.service('organizations')).to.be.ok(); 63 | }); 64 | 65 | describe('POST', () => { 66 | it('return 401 (unauthorized) if user is not authenticated', (done) => { 67 | const newOrganization = { 68 | name: 'newOrganization', 69 | users: [superAdminUser._id, adminUser._id], // eslint-disable-line no-underscore-dangle 70 | detail: 'An awesome organization', // eslint-disable-line no-underscore-dangle 71 | }; 72 | 73 | request(app) 74 | .post('/organizations') 75 | .set('X-YOUPIN-3-APP-KEY', 76 | '579b04ac516706156da5bba1:ed545297-4024-4a75-89b4-c95fed1df436') 77 | .send(newOrganization) 78 | .expect(401) 79 | .then((res) => { 80 | const error = res.body; 81 | 82 | expect(error.code).to.equal(401); 83 | expect(error.name).to.equal('NotAuthenticated'); 84 | expect(error.message).to.equal('Authentication token missing.'); 85 | 86 | done(); 87 | }); 88 | }); 89 | 90 | it('return 201 when posting by super admin user', (done) => { 91 | const newOrganization = { 92 | name: 'newOrganization', 93 | users: [superAdminUser._id, adminUser._id], // eslint-disable-line no-underscore-dangle 94 | detail: 'An awesome organization', 95 | }; 96 | 97 | login(app, 'super_admin@youpin.city', 'youpin_admin') 98 | .then((tokenResp) => { 99 | const token = tokenResp.body.token; 100 | 101 | if (!token) { 102 | done(new Error('No token returns')); 103 | } 104 | 105 | request(app) 106 | .post('/organizations') 107 | .set('X-YOUPIN-3-APP-KEY', 108 | '579b04ac516706156da5bba1:ed545297-4024-4a75-89b4-c95fed1df436') 109 | .send(newOrganization) 110 | .set('Authorization', `Bearer ${token}`) 111 | .expect(201) 112 | .then((res) => { 113 | const createdOrganization = res.body; 114 | expect(createdOrganization).to.contain.keys( 115 | ['_id', 'name', 'detail', 'users', 116 | 'updated_time', 'created_time']); 117 | done(); 118 | }); 119 | }); 120 | }); 121 | }); 122 | }); 123 | -------------------------------------------------------------------------------- /src/utils/hooks/send-notif-to-related-users.js: -------------------------------------------------------------------------------- 1 | const sendMessage = require('../send-bot-notification'); 2 | const sendMail = require('../send-mail-notification'); 3 | 4 | const User = require('../../services/user/user-model'); 5 | 6 | // Roles 7 | const ORGANIZATION_ADMIN = require('../../constants/roles').ORGANIZATION_ADMIN; 8 | const PUBLIC_RELATIONS = require('../../constants/roles').PUBLIC_RELATIONS; 9 | 10 | // Assume that a before hook attach logInfo in proper format already 11 | const sendNotifToRelatedUsers = () => (hook) => { // eslint-disable-line consistent-return 12 | // If no bot/mail config, just ignore this hook entirely. 13 | const botConfig = hook.app.get('bot'); 14 | const mailServiceConfig = hook.app.get('mailService'); 15 | if (!botConfig && !mailServiceConfig) { 16 | console.log('No bot/mail config. The notification will not be sent.'); 17 | return hook; 18 | } 19 | if (!hook.data.logInfo || !hook.data.logInfo.description) { 20 | console.log('No proper loginfo. The notification will not be sent.'); 21 | return hook; 22 | } 23 | // Find all related users' bot ids. 24 | let relatedUsers = hook.data.toBeNotifiedUsers || []; 25 | const relatedDepartments = hook.data.toBeNotifiedDepartments || []; 26 | const relatedRoles = hook.data.toBeNotifiedRoles || []; 27 | if (relatedUsers.length === 0 28 | && relatedDepartments.length === 0 && relatedRoles.length === 0) { 29 | console.log('No assigned user/department/role. ' + 30 | 'The notification will not be sent.'); 31 | return hook; 32 | } 33 | const findUserPromises = []; 34 | for (let i = 0; i < relatedRoles.length; ++i) { 35 | // Since organization admin does not have to depend on related department, 36 | // we can just find it globally. 37 | if (relatedRoles[i] === ORGANIZATION_ADMIN 38 | || relatedRoles[i] === PUBLIC_RELATIONS) { 39 | findUserPromises.push(User.find({ role: relatedRoles[i] })); 40 | } else { 41 | // Find users in other roles of related departments. 42 | findUserPromises.push( 43 | User.find({ department: { $in: relatedDepartments }, role: relatedRoles[i] })); 44 | } 45 | } 46 | Promise.all(findUserPromises) 47 | .then(results => { 48 | // TODO(A): Using correct facebookId (now using fb login's id instead of bot's id.) 49 | for (let i = 0; i < results.length; ++i) { 50 | const userList = results[i].map((user) => ({ botId: user.facebookId, email: user.email })); 51 | relatedUsers = relatedUsers.concat(userList); 52 | } 53 | // TODO(A): Add pin owner to relatedUsers list. 54 | // Use message from logInfo. 55 | let message = hook.data.logInfo.description; 56 | // Also, add a link to a pin in issue list. 57 | const adminConfig = hook.app.get('admin'); 58 | let adminIssueBaseUrl; 59 | if (adminConfig && adminConfig.adminUrl && hook.data.logInfo.pin_id) { 60 | adminIssueBaseUrl = `${adminConfig.adminUrl}/issue/`; 61 | message += 62 | `\nPin link - ${adminIssueBaseUrl}${hook.data.logInfo.pin_id}`; 63 | } 64 | // Send message to all relatedUsers. 65 | const allNotificationPromises = []; 66 | for (let i = 0; i < relatedUsers.length; ++i) { 67 | const user = relatedUsers[i]; 68 | if (botConfig && botConfig.botUrl && botConfig.notificationToken && user && user.botId) { 69 | allNotificationPromises.push( 70 | sendMessage(botConfig.botUrl, botConfig.notificationToken, user.botId, message)); 71 | } 72 | if (mailServiceConfig && user && user.email) { 73 | allNotificationPromises.push( 74 | sendMail(mailServiceConfig, adminIssueBaseUrl, user.email, hook.data.logInfo)); 75 | } 76 | } 77 | // TODO(A): Remove after ensuring the notif works fine. 78 | // This is for only testing in PROD to monitor that every notif mail has come. 79 | if (mailServiceConfig) { 80 | allNotificationPromises.push( 81 | sendMail(mailServiceConfig, adminIssueBaseUrl, 82 | 'parnurzeal@gmail.com', hook.data.logInfo)); 83 | } 84 | if (allNotificationPromises.length === 0) { 85 | console.log('No legit notification. Nothing will be sent.'); 86 | return []; 87 | } 88 | return Promise.all(allNotificationPromises); 89 | }) 90 | .then((results) => { 91 | console.log('Successfully send notification messages -'); 92 | console.log(results); 93 | }) 94 | .catch(error => { 95 | console.log(error); 96 | }); 97 | }; 98 | 99 | module.exports = sendNotifToRelatedUsers; 100 | -------------------------------------------------------------------------------- /test/services/summarize-state/index.test.js: -------------------------------------------------------------------------------- 1 | // Test helper functions 2 | const assertTestEnv = require('../../test_helper').assertTestEnv; 3 | const expect = require('../../test_helper').expect; 4 | const loadFixture = require('../../test_helper').loadFixture; 5 | const request = require('supertest-as-promised'); 6 | 7 | // Models 8 | const Department = require('../../../src/services/department/department-model'); 9 | const Organization = require('../../../src/services/organization/organization-model'); 10 | const Pin = require('../../../src/services/pin/pin-model'); 11 | const User = require('../../../src/services/user/user-model'); 12 | 13 | // Fixture 14 | const departmentHeadUser = require('../../fixtures/department_head_user'); 15 | const departments = require('../../fixtures/departments'); 16 | const organizations = require('../../fixtures/organizations'); 17 | const pins = require('../../fixtures/pins'); 18 | 19 | // App staff 20 | const app = require('../../../src/app'); 21 | const pinStates = require('../../../src/constants/pin-states'); 22 | 23 | // Exit test if NODE_ENV is not equal `test` 24 | assertTestEnv(); 25 | 26 | describe('state summary service', () => { 27 | let server; 28 | 29 | before((done) => { 30 | server = app.listen(app.get('port')); 31 | server.once('listening', () => { 32 | // Load activity log fixture 33 | Promise.all([ 34 | loadFixture(Organization, organizations), 35 | loadFixture(Department, departments), 36 | loadFixture(User, departmentHeadUser), 37 | loadFixture(Pin, pins), 38 | ]) 39 | .then(() => { 40 | done(); 41 | }) 42 | .catch((err) => { 43 | done(err); 44 | }); 45 | }); 46 | }); 47 | 48 | after((done) => { 49 | // Clear collections after finishing all tests. 50 | Promise.all([ 51 | Pin.remove({}), 52 | User.remove({}), 53 | Department.remove({}), 54 | Organization.remove({}), 55 | ]) 56 | .then(() => { 57 | server.close((err) => { 58 | if (err) return done(err); 59 | 60 | return done(); 61 | }); 62 | }); 63 | }); 64 | 65 | it('registered the summaries service', () => { 66 | expect(app.service('summaries')).to.be.ok(); 67 | }); 68 | 69 | it('summarizes states for Chula within 1 week (2016-12-01 -> 2016-12-07)', 70 | (done) => request(app) 71 | .get('/summarize-states') 72 | .query({ organization: 'YouPin' }) 73 | .query({ start_date: '2016-12-01' }) 74 | .query({ end_date: '2016-12-07' }) 75 | .expect(200) 76 | .then((summaryResponse) => { 77 | const summaries = summaryResponse.body; 78 | // Change array to dictionary (key is a department). 79 | const expectedResult = { 80 | 'Department of Nerds': { 81 | total: { 82 | [pinStates.PENDING]: 0, 83 | [pinStates.ASSIGNED]: 1, 84 | [pinStates.PROCESSING]: 0, 85 | [pinStates.RESOLVED]: 0, 86 | [pinStates.REJECTED]: 0, 87 | }, 88 | [departmentHeadUser.name]: { 89 | [pinStates.PENDING]: 0, 90 | [pinStates.ASSIGNED]: 1, 91 | [pinStates.PROCESSING]: 0, 92 | [pinStates.RESOLVED]: 0, 93 | [pinStates.REJECTED]: 0, 94 | }, 95 | }, 96 | 'Admin Department': { 97 | total: { 98 | [pinStates.PENDING]: 0, 99 | [pinStates.ASSIGNED]: 0, 100 | [pinStates.PROCESSING]: 1, 101 | [pinStates.RESOLVED]: 1, 102 | [pinStates.REJECTED]: 0, 103 | }, 104 | 'YouPin Department Head': { 105 | [pinStates.PENDING]: 0, 106 | [pinStates.ASSIGNED]: 0, 107 | [pinStates.PROCESSING]: 1, 108 | [pinStates.RESOLVED]: 1, 109 | [pinStates.REJECTED]: 0, 110 | }, 111 | }, 112 | None: { 113 | total: { 114 | [pinStates.PENDING]: 1, 115 | [pinStates.ASSIGNED]: 0, 116 | [pinStates.PROCESSING]: 0, 117 | [pinStates.RESOLVED]: 0, 118 | [pinStates.REJECTED]: 1, 119 | }, 120 | unassigned: { 121 | [pinStates.PENDING]: 1, 122 | [pinStates.ASSIGNED]: 0, 123 | [pinStates.PROCESSING]: 0, 124 | [pinStates.RESOLVED]: 0, 125 | [pinStates.REJECTED]: 1, 126 | }, 127 | }, 128 | }; 129 | expect(summaries).to.deep.equal(expectedResult); 130 | done(); 131 | }) 132 | ); 133 | }); 134 | -------------------------------------------------------------------------------- /test/services/searchnearby/index.test.js: -------------------------------------------------------------------------------- 1 | // Test helper functions 2 | const assertTestEnv = require('../../test_helper').assertTestEnv; 3 | const expect = require('../../test_helper').expect; 4 | const loadFixture = require('../../test_helper').loadFixture; 5 | 6 | // Models 7 | const User = require('../../../src/services/user/user-model'); 8 | const Pin = require('../../../src/services/pin/pin-model'); 9 | const request = require('supertest-as-promised'); 10 | 11 | // Fixtures 12 | const adminUser = require('../../fixtures/admin_user'); 13 | const pins = require('../../fixtures/pins'); 14 | 15 | // App stuff 16 | const app = require('../../../src/app'); 17 | 18 | // Exit test if NODE_ENV is not equal `test` 19 | assertTestEnv(); 20 | 21 | describe('searchnearby service', () => { 22 | let server; 23 | 24 | before((done) => { 25 | server = app.listen(app.get('port')); 26 | server.once('listening', () => { 27 | Promise.all([ 28 | loadFixture(User, adminUser), 29 | loadFixture(Pin, pins), 30 | ]) 31 | .then(() => { 32 | done(); 33 | }); 34 | }); 35 | }); 36 | 37 | after((done) => { 38 | // Clears collection after finishing all tests. 39 | Promise.all([ 40 | Pin.remove({}), 41 | User.remove({}), 42 | ]) 43 | .then(() => { 44 | // Close the server 45 | server.close((err) => { 46 | if (err) return done(err); 47 | 48 | return done(); 49 | }); 50 | }) 51 | .catch((err) => { 52 | done(err); 53 | }); 54 | }); 55 | 56 | it('registered the searchnearby service', () => { 57 | expect(app.service('searchnearby')).to.be.ok(); 58 | }); 59 | 60 | it('returns coordinates in [lat, long] format', (done) => { 61 | request(app) 62 | // request in [lat, long] format 63 | .get('/searchnearby?$center=[13.730537951109,100.56983534303]&$radius=0&limit=3') 64 | .expect(200) 65 | .then((res) => { 66 | if (!res || !res.body.data || res.body.data.length <= 0) { 67 | return done(new Error('No data return')); 68 | } 69 | 70 | const expectedCoordinates = [ 71 | [13.730537951109, 100.56983534303], 72 | [13.730537951108, 100.56983534304], 73 | [13.730537951107, 100.56983534305], 74 | ]; 75 | 76 | res.body.data.forEach((pin) => { 77 | expect(expectedCoordinates).to.deep.include.members([pin.location.coordinates]); 78 | }); 79 | 80 | return done(); 81 | }) 82 | .catch(done); 83 | }); 84 | 85 | it('limits a number of results', (done) => { 86 | request(app) 87 | // request in [lat, long] format 88 | .get('/searchnearby?$center=[13.730537954909,100.56983580503]&$radius=1000&limit=2') 89 | .expect(200) 90 | .then((res) => { 91 | if (!res || !res.body.data || res.body.data.length <= 0) { 92 | return done(new Error('No data return')); 93 | } 94 | 95 | expect(res.body.data.length).to.equal(2); 96 | 97 | return done(); 98 | }); 99 | }); 100 | 101 | it('does not allow limit as a string', (done) => { 102 | request(app) 103 | // request in [lat, long] format 104 | .get('/searchnearby?$center=[13.730537954909,100.56983580503]&$radius=1000&limit=abc') 105 | .expect(400) 106 | .then((res) => { 107 | const error = res.body; 108 | 109 | expect(error.code).to.equal(400); 110 | expect(error.name).to.equal('BadRequest'); 111 | expect(error.message).to.equal('`limit` must be integer'); 112 | 113 | done(); 114 | }); 115 | }); 116 | 117 | it('does not allow limit as a float number', (done) => { 118 | request(app) 119 | // request in [lat, long] format 120 | .get('/searchnearby?$center=[13.730537954909,100.56983580503]&$radius=1000&limit=1.1') 121 | .expect(400) 122 | .then((res) => { 123 | const error = res.body; 124 | 125 | expect(error.code).to.equal(400); 126 | expect(error.name).to.equal('BadRequest'); 127 | expect(error.message).to.equal('`limit` must be integer'); 128 | 129 | done(); 130 | }); 131 | }); 132 | 133 | it('does not allow $radius as a string', (done) => { 134 | request(app) 135 | // request in [lat, long] format 136 | .get('/searchnearby?$center=[13.730537954909,100.56983580503]&$radius=abc&limit=2') 137 | .expect(400) 138 | .then((res) => { 139 | const error = res.body; 140 | 141 | expect(error.code).to.equal(400); 142 | expect(error.name).to.equal('BadRequest'); 143 | expect(error.message).to.equal('`$radius` must be numeric'); 144 | 145 | done(); 146 | }); 147 | }); 148 | }); 149 | -------------------------------------------------------------------------------- /src/services/pin/hooks/prepare-activity-log.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | 3 | const actions = require('../../../constants/actions'); 4 | const Pin = require('../../pin/pin-model'); 5 | 6 | // States 7 | const PROCESSING = require('../../../constants/pin-states').PROCESSING; 8 | 9 | // Roles 10 | const DEPARTMENT_HEAD = require('../../../constants/roles').DEPARTMENT_HEAD; 11 | 12 | // For before hook to prepare activity log and will be used by after hook 13 | // Note: we can't do this in after hook because we need previous pin's properties before updated 14 | const prepareActivityLog = () => (hook) => { 15 | const nameOfUser = hook.params.user.name; 16 | const pinId = hook.id; 17 | return Pin.findById(pinId) 18 | .then(pin => { 19 | // Separate updated/added fields 20 | const updatedFieldObjects = _.omit(hook.data, ['$push', 'owner']); 21 | const addedFieldObjects = hook.data.$push; 22 | 23 | let description = ''; 24 | const allChangedFields = []; 25 | const allPreviousValues = []; 26 | const allNewValues = []; 27 | // Add description if there is any changed field. 28 | if (updatedFieldObjects.length !== 0 || addedFieldObjects.length !== 0) { 29 | description += `${nameOfUser} edited pin #${pinId}`; 30 | } 31 | if (updatedFieldObjects) { 32 | // Get all updated fields 33 | _.forEach(updatedFieldObjects, (value, key) => { 34 | let previousValue = pin[key]; 35 | let newValue = value; 36 | // Add special case for location's coordinates. 37 | if (key === 'location') { 38 | previousValue = pin[key].coordinates.toString(); 39 | newValue = value.coordinates.toString(); 40 | } 41 | allChangedFields.push(key); 42 | allPreviousValues.push(previousValue); 43 | allNewValues.push(newValue); 44 | }); 45 | } 46 | if (addedFieldObjects) { 47 | // Get all added fields 48 | _.forEach(addedFieldObjects, (value, key) => { 49 | allChangedFields.push(key); 50 | allPreviousValues.push(''); 51 | // Extract detail field if it is a comment object. 52 | const newValue = _.isObject(value) ? value.detail : value; 53 | allNewValues.push(newValue); 54 | }); 55 | } 56 | // Pass logInfo object to after hook by attaching to hook.data 57 | const logInfo = { 58 | user: nameOfUser, 59 | organization: pin.organization, 60 | department: pin.assigned_department, 61 | actionType: actions.types.METADATA, 62 | action: actions.UPDATE_PIN, 63 | pin_id: pinId, 64 | changed_fields: allChangedFields, 65 | previous_values: allPreviousValues, 66 | updated_values: allNewValues, 67 | description, 68 | timestamp: Date.now(), 69 | }; 70 | // Attach data for log-activity hook 71 | hook.data.logInfo = logInfo; // eslint-disable-line no-param-reassign 72 | // For newly assigning (null -> assigned dept.), because the pin status will 73 | // be changed, the notification will be handled by pin-transition service. 74 | // For already assigned pin, if there is a change of assigned department, 75 | // we need to inform both of departments' heads. 76 | /* eslint-disable no-underscore-dangle,no-param-reassign */ 77 | const prevDeptId = pin.assigned_department ? 78 | pin.assigned_department._id : ''; 79 | const newDeptId = updatedFieldObjects.assigned_department ? 80 | updatedFieldObjects.assigned_department._id : ''; 81 | if (prevDeptId !== '' && newDeptId !== '' && prevDeptId !== newDeptId) { 82 | hook.data.toBeNotifiedDepartments = [prevDeptId, newDeptId]; 83 | hook.data.toBeNotifiedRoles = [DEPARTMENT_HEAD]; 84 | } 85 | // Add users to be notified by bot & email 86 | if (pin.status === PROCESSING && pin.assigned_users) { 87 | // If assigned_users change, we also need to inform newly assigned_users. 88 | const newAssignedUsers = updatedFieldObjects.assigned_users || []; 89 | hook.data.toBeNotifiedUsers = _.concat(newAssignedUsers, pin.assigned_users); 90 | // Exclude out the user who updates this pin. 91 | // Since he/she is the one who updates, notification is unnecessary. 92 | hook.data.toBeNotifiedUsers = 93 | _.differenceBy(hook.data.toBeNotifiedUsers, 94 | [{ _id: hook.params.user._id }], '_id'); 95 | } 96 | /* eslint-enable no-underscore-dangle,no-param-reassign */ 97 | return Promise.resolve(hook); 98 | }) 99 | .catch(err => { 100 | throw new Error(err); 101 | }); 102 | }; 103 | 104 | module.exports = prepareActivityLog; 105 | -------------------------------------------------------------------------------- /test/fixtures/pins.js: -------------------------------------------------------------------------------- 1 | const ObjectId = require('mongoose').Types.ObjectId; 2 | 3 | const state = require('../../src/constants/pin-states'); 4 | const DEPARTMENT_GENERAL_ID = require('./constants').DEPARTMENT_GENERAL_ID; 5 | const DEPARTMENT_SUPER_ADMIN_ID = require('./constants').DEPARTMENT_SUPER_ADMIN_ID; 6 | const ORGANIZATION_ID = require('./constants').ORGANIZATION_ID; 7 | const PIN_ASSIGNED_ID = require('./constants').PIN_ASSIGNED_ID; 8 | const PIN_ASSIGNED_DETAIL = require('./constants').PIN_ASSIGNED_DETAIL; 9 | const PIN_PENDING_ID = require('./constants').PIN_PENDING_ID; 10 | const PIN_PENDING_DETAIL = require('./constants').PIN_PENDING_DETAIL; 11 | const PIN_PROCESSING_ID = require('./constants').PIN_PROCESSING_ID; 12 | const PIN_PROCESSING_DETAIL = require('./constants').PIN_PROCESSING_DETAIL; 13 | const PIN_RESOLVED_ID = require('./constants').PIN_RESOLVED_ID; 14 | const PIN_RESOLVED_DETAIL = require('./constants').PIN_RESOLVED_DETAIL; 15 | const PIN_REJECTED_ID = require('./constants').PIN_REJECTED_ID; 16 | const PIN_REJECTED_DETAIL = require('./constants').PIN_REJECTED_DETAIL; 17 | const PROGRESS_DETAIL = require('./constants').PROGRESS_DETAIL; 18 | const USER_ADMIN_ID = require('./constants').USER_ADMIN_ID; 19 | const USER_DEPARTMENT_HEAD_ID = require('./constants').USER_DEPARTMENT_HEAD_ID; 20 | 21 | module.exports = [ 22 | { 23 | _id: ObjectId(PIN_PENDING_ID), // eslint-disable-line new-cap 24 | created_time: '2016-12-01', 25 | detail: PIN_PENDING_DETAIL, 26 | organization: ORGANIZATION_ID, // organization ObjectId 27 | owner: USER_ADMIN_ID, // adminUser ObjectId 28 | provider: USER_ADMIN_ID, // adminUser ObjectId 29 | location: { 30 | type: 'Point', 31 | coordinates: [100.56983534303, 13.730537951109], 32 | }, 33 | status: state.PENDING, 34 | is_archived: false, 35 | }, 36 | { 37 | _id: ObjectId(PIN_ASSIGNED_ID), // eslint-disable-line new-cap 38 | assigned_department: DEPARTMENT_GENERAL_ID, // department ObjectId 39 | assigned_time: '2015-12-04', 40 | created_time: '2016-12-03', 41 | detail: PIN_ASSIGNED_DETAIL, 42 | organization: ORGANIZATION_ID, // organization ObjectId 43 | owner: USER_ADMIN_ID, // adminUser ObjectId 44 | provider: USER_ADMIN_ID, // adminUser ObjectId 45 | location: { 46 | type: 'Point', 47 | coordinates: [100.56983534305, 13.730537951107], 48 | }, 49 | assigned_users: [USER_DEPARTMENT_HEAD_ID], 50 | status: state.ASSIGNED, 51 | is_archived: false, 52 | }, 53 | { 54 | _id: ObjectId(PIN_PROCESSING_ID), // eslint-disable-line new-cap 55 | assigned_department: DEPARTMENT_SUPER_ADMIN_ID, // department ObjectId 56 | assigned_time: '2015-12-04', 57 | created_time: '2016-12-03', 58 | detail: PIN_PROCESSING_DETAIL, 59 | organization: ORGANIZATION_ID, // organization ObjectId 60 | owner: USER_ADMIN_ID, // adminUser ObjectId 61 | provider: USER_ADMIN_ID, // adminUser ObjectId 62 | location: { 63 | type: 'Point', 64 | coordinates: [100.56983534305, 13.730537951107], 65 | }, 66 | processing_time: '2015-12-04', 67 | progresses: [ 68 | { 69 | created_time: '2016-12-04', 70 | owner: USER_ADMIN_ID, // adminUser ObjectId 71 | detail: PROGRESS_DETAIL, 72 | }, 73 | ], 74 | assigned_users: [USER_DEPARTMENT_HEAD_ID], 75 | status: state.PROCESSING, 76 | is_archived: false, 77 | }, 78 | { 79 | _id: ObjectId(PIN_RESOLVED_ID), // eslint-disable-line new-cap 80 | assigned_department: DEPARTMENT_SUPER_ADMIN_ID, // department ObjectId 81 | assigned_time: '2015-12-04', 82 | created_time: '2016-12-03', 83 | detail: PIN_RESOLVED_DETAIL, 84 | organization: ORGANIZATION_ID, // organization ObjectId 85 | owner: USER_ADMIN_ID, // adminUser ObjectId 86 | provider: USER_ADMIN_ID, // adminUser ObjectId 87 | location: { 88 | type: 'Point', 89 | coordinates: [100.56983534305, 13.730537951107], 90 | }, 91 | processing_time: '2015-12-04', 92 | progresses: [ 93 | { 94 | created_time: '2016-12-03', 95 | owner: USER_ADMIN_ID, // adminUser ObjectId 96 | detail: PROGRESS_DETAIL, 97 | }, 98 | ], 99 | assigned_users: [USER_DEPARTMENT_HEAD_ID], 100 | status: state.RESOLVED, 101 | resolved_time: '2016-12-04', 102 | is_archived: false, 103 | }, 104 | { 105 | _id: ObjectId(PIN_REJECTED_ID), // eslint-disable-line new-cap 106 | created_time: '2016-12-03', 107 | detail: PIN_REJECTED_DETAIL, 108 | organization: ORGANIZATION_ID, // organization ObjectId 109 | owner: USER_ADMIN_ID, // adminUser ObjectId 110 | provider: USER_ADMIN_ID, // adminUser ObjectId 111 | location: { 112 | type: 'Point', 113 | coordinates: [100.56983534305, 13.730537951107], 114 | }, 115 | status: state.REJECTED, 116 | rejected_time: '2016-12-04', 117 | is_archived: false, 118 | }, 119 | ]; 120 | -------------------------------------------------------------------------------- /test/services/department/index.test.js: -------------------------------------------------------------------------------- 1 | // Test helper functions 2 | const request = require('supertest-as-promised'); 3 | const { 4 | assertTestEnv, 5 | expect, 6 | loadFixture, 7 | login, 8 | } = require('../../test_helper'); 9 | 10 | // Models 11 | const Department = require('../../../src/services/department/department-model'); 12 | const User = require('../../../src/services/user/user-model'); 13 | 14 | // Fixtures 15 | const departments = require('../../fixtures/departments'); 16 | const organizationAdminUser = require('../../fixtures/organization_admin_user'); 17 | const superAdminUser = require('../../fixtures/super_admin_user'); 18 | 19 | // App stuff 20 | const app = require('../../../src/app'); 21 | 22 | // Exit test if NODE_ENV is not equal `test` 23 | assertTestEnv(); 24 | 25 | describe('department service', () => { 26 | let server; 27 | 28 | before((done) => { 29 | server = app.listen(app.get('port')); 30 | server.once('listening', () => { 31 | // Create admin user and app3rd for admin 32 | Promise.all([ 33 | loadFixture(User, superAdminUser), 34 | loadFixture(User, organizationAdminUser), 35 | loadFixture(Department, departments), 36 | ]) 37 | .then(() => { 38 | done(); 39 | }) 40 | .catch((err) => { 41 | done(err); 42 | }); 43 | }); 44 | }); 45 | 46 | after((done) => { 47 | // Clear collections after finishing all tests. 48 | Promise.all([ 49 | User.remove({}), 50 | Department.remove({}), 51 | ]) 52 | .then(() => { 53 | server.close((err) => { 54 | if (err) return done(err); 55 | 56 | return done(); 57 | }); 58 | }); 59 | }); 60 | 61 | it('registered the departments service', () => { 62 | expect(app.service('departments')).to.be.ok(); 63 | }); 64 | 65 | describe('POST', () => { 66 | it('return 401 (unauthorized) if user is not authenticated', (done) => { 67 | const newDepartment = { 68 | name: 'YouPin', 69 | detail: 'An awesome department', // eslint-disable-line no-underscore-dangle 70 | }; 71 | 72 | request(app) 73 | .post('/departments') 74 | .set('X-YOUPIN-3-APP-KEY', 75 | '579b04ac516706156da5bba1:ed545297-4024-4a75-89b4-c95fed1df436') 76 | .send(newDepartment) 77 | .expect(401) 78 | .then((res) => { 79 | const error = res.body; 80 | 81 | expect(error.code).to.equal(401); 82 | expect(error.name).to.equal('NotAuthenticated'); 83 | expect(error.message).to.equal('Authentication token missing.'); 84 | 85 | done(); 86 | }); 87 | }); 88 | 89 | it('return 201 when posting by super admin user', (done) => { 90 | const newDepartment = { 91 | name: 'YouPin', 92 | detail: 'An awesome department', // eslint-disable-line no-underscore-dangle 93 | }; 94 | 95 | login(app, 'super_admin@youpin.city', 'youpin_admin') 96 | .then((tokenResp) => { 97 | const token = tokenResp.body.token; 98 | 99 | if (!token) { 100 | done(new Error('No token returns')); 101 | } 102 | 103 | request(app) 104 | .post('/departments') 105 | .set('X-YOUPIN-3-APP-KEY', 106 | '579b04ac516706156da5bba1:ed545297-4024-4a75-89b4-c95fed1df436') 107 | .send(newDepartment) 108 | .set('Authorization', `Bearer ${token}`) 109 | .expect(201) 110 | .then((res) => { 111 | const createddepartment = res.body; 112 | expect(createddepartment).to.contain.keys( 113 | ['_id', 'name', 'detail', 114 | 'updated_time', 'created_time']); 115 | done(); 116 | }); 117 | }); 118 | }); 119 | 120 | it('return 201 when posting by organization admin user', (done) => { 121 | const newDepartment = { 122 | name: 'YouPin', 123 | detail: 'An awesome department', // eslint-disable-line no-underscore-dangle 124 | }; 125 | 126 | login(app, 'organization_admin@youpin.city', 'youpin_admin') 127 | .then((tokenResp) => { 128 | const token = tokenResp.body.token; 129 | 130 | if (!token) { 131 | done(new Error('No token returns')); 132 | } 133 | 134 | request(app) 135 | .post('/departments') 136 | .set('X-YOUPIN-3-APP-KEY', 137 | '579b04ac516706156da5bba1:ed545297-4024-4a75-89b4-c95fed1df436') 138 | .send(newDepartment) 139 | .set('Authorization', `Bearer ${token}`) 140 | .expect(201) 141 | .then((res) => { 142 | const createddepartment = res.body; 143 | expect(createddepartment).to.contain.keys( 144 | ['_id', 'name', 'detail', 145 | 'updated_time', 'created_time']); 146 | done(); 147 | }); 148 | }); 149 | }); 150 | }); 151 | }); 152 | -------------------------------------------------------------------------------- /test/services/photo/index.test.js: -------------------------------------------------------------------------------- 1 | // Test helper functions 2 | const assertTestEnv = require('../../test_helper').assertTestEnv; 3 | const expect = require('../../test_helper').expect; 4 | const loadFixture = require('../../test_helper').loadFixture; 5 | const request = require('supertest-as-promised'); 6 | const stub = require('../../test_helper').stub; 7 | 8 | // Models 9 | const Photo = require('../../../src/services/photo/photo-model'); 10 | const User = require('../../../src/services/user/user-model'); 11 | 12 | // Fixtures 13 | const adminUser = require('../../fixtures/admin_user'); 14 | const photos = require('../../fixtures/photos'); 15 | 16 | // App stuff 17 | const app = require('../../../src/app'); 18 | const mongoose = require('mongoose'); 19 | const GCSUploader = require('../../../src/utils/gcs-uploader'); 20 | 21 | // Exit test if NODE_ENV is not equal `test` 22 | assertTestEnv(); 23 | 24 | describe('photo service', () => { 25 | let server; 26 | 27 | before((done) => { 28 | server = app.listen(app.get('port')); 29 | server.once('listening', () => { 30 | // Create admin user and app3rd for admin 31 | Promise.all([ 32 | loadFixture(User, adminUser), 33 | loadFixture(Photo, photos), 34 | ]) 35 | .then(() => { 36 | done(); 37 | }) 38 | .catch((err) => { 39 | done(err); 40 | }); 41 | }); 42 | }); 43 | 44 | after((done) => { 45 | // Clear collections after finishing all tests. 46 | Promise.all([ 47 | User.remove({}), 48 | Photo.remove({}), 49 | ]) 50 | .then(() => { 51 | server.close((err) => { 52 | if (err) return done(err); 53 | 54 | return done(); 55 | }); 56 | }); 57 | }); 58 | 59 | it('registered the photos service', () => { 60 | expect(app.service('photos')).to.be.ok(); 61 | }); 62 | 63 | describe('GET', () => { 64 | it('returns 404 Not Found when id is not ObjectId', (done) => { 65 | // Test with invalid object id 66 | const id = '1234'; 67 | expect(mongoose.Types.ObjectId.isValid(id)).to.equal(false); 68 | 69 | request(app) 70 | .get('/photos/1234') 71 | .expect(404) 72 | .then((res) => { 73 | const error = res.body; 74 | 75 | expect(error.code).to.equal(404); 76 | expect(error.name).to.equal('NotFound'); 77 | expect(error.message).to.equal('No record found for id \'1234\''); 78 | 79 | done(); 80 | }); 81 | }); 82 | 83 | it('returns correct photo metadata', (done) => { 84 | request(app) 85 | .get('/photos/579331115563625d6281b111') 86 | .expect(200) 87 | .then((res) => { 88 | const data = res.body; 89 | 90 | expect(data.url).to.equal('https://youpin.city/logo.png'); 91 | expect(data.mimetype).to.equal('image/png'); 92 | expect(data.size).to.equal(5000); 93 | 94 | done(); 95 | }); 96 | }); 97 | }); 98 | 99 | describe('POST', () => { 100 | let dateStub; 101 | let uploaderStub; 102 | 103 | before(() => { 104 | dateStub = stub(Date, 'now', () => '1470415027347'); 105 | 106 | // Stub upload function to avoid real uploading 107 | uploaderStub = stub(GCSUploader.prototype, 'upload', function (reqFile) { // eslint-disable-line 108 | return new Promise((resolve) => { 109 | const gcsFileName = `${Date.now()}_${reqFile.originalname}`; 110 | 111 | /* eslint-disable no-param-reassign */ 112 | reqFile.cloudStorageObject = gcsFileName; 113 | reqFile.cloudStoragePublicUrl = this.getGCSPublicUrl(gcsFileName); 114 | /* eslint-enable no-param-reassign */ 115 | 116 | resolve(reqFile); 117 | }); 118 | }); 119 | }); 120 | 121 | after(() => { 122 | dateStub.restore(); 123 | uploaderStub.restore(); 124 | }); 125 | 126 | it('saves file metadata to database and responds with that metadata', (done) => { 127 | const gcsConfig = app.get('gcs'); 128 | 129 | request(app) 130 | .post('/photos') 131 | .attach('image', 'test/fixtures/logo.png') 132 | .expect(201) 133 | .end((err, res) => { // eslint-disable-line consistent-return 134 | if (err) return done(err); 135 | 136 | const body = res.body; 137 | const expectedUrl = encodeURI( 138 | `${gcsConfig.gcsUrl}/${gcsConfig.bucket}/1470415027347_logo.png`); 139 | 140 | // Check correct response 141 | expect(body.url).to.equal(expectedUrl); 142 | expect(body.mimetype).to.equal('image/png'); 143 | expect(body.size).to.equal(4434); 144 | 145 | // Check file metadata is inserted into database 146 | Photo.findById(body.id, (error, photo) => { 147 | if (error) return done(error); 148 | 149 | expect(photo).to.be.ok(); 150 | expect(photo.url).to.equal(expectedUrl); 151 | expect(photo.mimetype).to.equal('image/png'); 152 | expect(photo.size).to.equal(4434); 153 | 154 | return done(); 155 | }); 156 | }); 157 | }); 158 | }); 159 | }); 160 | -------------------------------------------------------------------------------- /test/services/video/index.test.js: -------------------------------------------------------------------------------- 1 | // Test helper functions 2 | const assertTestEnv = require('../../test_helper').assertTestEnv; 3 | const expect = require('../../test_helper').expect; 4 | const loadFixture = require('../../test_helper').loadFixture; 5 | const request = require('supertest-as-promised'); 6 | const stub = require('../../test_helper').stub; 7 | 8 | // Models 9 | const Video = require('../../../src/services/video/video-model'); 10 | const User = require('../../../src/services/user/user-model'); 11 | 12 | // Fixtures 13 | const adminUser = require('../../fixtures/admin_user'); 14 | const videos = require('../../fixtures/videos'); 15 | 16 | // App stuff 17 | const app = require('../../../src/app'); 18 | const mongoose = require('mongoose'); 19 | const GCSUploader = require('../../../src/utils/gcs-uploader'); 20 | 21 | // Exit test if NODE_ENV is not equal `test` 22 | assertTestEnv(); 23 | 24 | describe('Video service', () => { 25 | let server; 26 | 27 | before((done) => { 28 | server = app.listen(app.get('port')); 29 | server.once('listening', () => { 30 | // Create admin user and app3rd for admin 31 | Promise.all([ 32 | loadFixture(User, adminUser), 33 | loadFixture(Video, videos), 34 | ]) 35 | .then(() => { 36 | done(); 37 | }) 38 | .catch((err) => { 39 | done(err); 40 | }); 41 | }); 42 | }); 43 | 44 | after((done) => { 45 | // Clear collections after finishing all tests. 46 | Promise.all([ 47 | User.remove({}), 48 | Video.remove({}), 49 | ]) 50 | .then(() => { 51 | server.close((err) => { 52 | if (err) return done(err); 53 | 54 | return done(); 55 | }); 56 | }); 57 | }); 58 | 59 | it('registered the Videos service', () => { 60 | expect(app.service('videos')).to.be.ok(); 61 | }); 62 | 63 | describe('GET', () => { 64 | it('returns 404 Not Found when id is not ObjectId', (done) => { 65 | // Test with invalid object id 66 | const id = '1234'; 67 | expect(mongoose.Types.ObjectId.isValid(id)).to.equal(false); 68 | 69 | request(app) 70 | .get('/videos/1234') 71 | .expect(404) 72 | .then((res) => { 73 | const error = res.body; 74 | 75 | expect(error.code).to.equal(404); 76 | expect(error.name).to.equal('NotFound'); 77 | expect(error.message).to.equal('No record found for id \'1234\''); 78 | 79 | done(); 80 | }); 81 | }); 82 | 83 | it('returns correct Video metadata', (done) => { 84 | request(app) 85 | .get('/videos/57933111556362511181b111') 86 | .expect(200) 87 | .then((res) => { 88 | const data = res.body; 89 | 90 | expect(data.url).to.equal('https://youpin.city/intro.mp4'); 91 | expect(data.mimetype).to.equal('video/mp4'); 92 | expect(data.size).to.equal(5008820); 93 | 94 | done(); 95 | }); 96 | }); 97 | }); 98 | 99 | describe('POST', () => { 100 | let dateStub; 101 | let uploaderStub; 102 | 103 | before(() => { 104 | dateStub = stub(Date, 'now', () => '1470415027347'); 105 | 106 | // Stub upload function to avoid real uploading 107 | uploaderStub = stub(GCSUploader.prototype, 'upload', function (reqFile) { // eslint-disable-line 108 | return new Promise((resolve) => { 109 | const gcsFileName = `${Date.now()}_${reqFile.originalname}`; 110 | 111 | /* eslint-disable no-param-reassign */ 112 | reqFile.cloudStorageObject = gcsFileName; 113 | reqFile.cloudStoragePublicUrl = this.getGCSPublicUrl(gcsFileName); 114 | /* eslint-enable no-param-reassign */ 115 | 116 | resolve(reqFile); 117 | }); 118 | }); 119 | }); 120 | 121 | after(() => { 122 | dateStub.restore(); 123 | uploaderStub.restore(); 124 | }); 125 | 126 | it('saves file metadata to database and responds with that metadata', (done) => { 127 | const gcsConfig = app.get('gcs'); 128 | 129 | request(app) 130 | .post('/videos') 131 | .attach('video', 'test/fixtures/loop.mp4') 132 | .expect(201) 133 | .end((err, res) => { // eslint-disable-line consistent-return 134 | if (err) return done(err); 135 | 136 | const body = res.body; 137 | const expectedUrl = encodeURI( 138 | `${gcsConfig.gcsUrl}/${gcsConfig.bucket}/1470415027347_loop.mp4`); 139 | 140 | // Check correct response 141 | expect(body.url).to.equal(expectedUrl); 142 | expect(body.mimetype).to.equal('video/mp4'); 143 | expect(body.size).to.equal(349612); 144 | 145 | // Check file metadata is inserted into database 146 | Video.findById(body.id, (error, video) => { 147 | if (error) return done(error); 148 | 149 | expect(video).to.be.ok(); 150 | expect(video.url).to.equal(expectedUrl); 151 | expect(video.mimetype).to.equal('video/mp4'); 152 | expect(video.size).to.equal(349612); 153 | 154 | return done(); 155 | }); 156 | }); 157 | }); 158 | }); 159 | }); 160 | -------------------------------------------------------------------------------- /test/services/pin/get.test.js: -------------------------------------------------------------------------------- 1 | // Test helper functions 2 | const assertTestEnv = require('../../test_helper').assertTestEnv; 3 | const expect = require('../../test_helper').expect; 4 | const loadFixture = require('../../test_helper').loadFixture; 5 | const request = require('supertest-as-promised'); 6 | 7 | // Models 8 | const App3rd = require('../../../src/services/app3rd/app3rd-model'); 9 | const Department = require('../../../src/services/department/department-model'); 10 | const Pin = require('../../../src/services/pin/pin-model'); 11 | const User = require('../../../src/services/user/user-model'); 12 | 13 | // Fixtures 14 | const adminApp3rd = require('../../fixtures/admin_app3rd'); 15 | const adminUser = require('../../fixtures/admin_user'); 16 | const departmentHeadUser = require('../../fixtures/department_head_user'); 17 | const departments = require('../../fixtures/departments'); 18 | const normalUser = require('../../fixtures/normal_user'); 19 | const orgnizationAdminUser = require('../../fixtures/organization_admin_user'); 20 | const publicRelationsUser = require('../../fixtures/public_relations_user'); 21 | const superAdminUser = require('../../fixtures/super_admin_user'); 22 | const pins = require('../../fixtures/pins'); 23 | 24 | // Constants 25 | const PIN_PENDING_ID = require('../../fixtures/constants').PIN_PENDING_ID; 26 | const PIN_PROCESSING_ID = require('../../fixtures/constants').PIN_PROCESSING_ID; 27 | const PROGRESS_DETAIL = require('../../fixtures/constants').PROGRESS_DETAIL; 28 | 29 | // App stuff 30 | const app = require('../../../src/app'); 31 | const mongoose = require('mongoose'); 32 | 33 | // Exit test if NODE_ENV is not equal `test` 34 | assertTestEnv(); 35 | 36 | describe('Pin - GET', () => { 37 | let server; 38 | 39 | before((done) => { 40 | server = app.listen(app.get('port')); 41 | server.once('listening', () => { 42 | // Create admin user and app3rd for admin 43 | Promise.all([ 44 | loadFixture(User, adminUser), 45 | loadFixture(User, departmentHeadUser), 46 | loadFixture(User, normalUser), 47 | loadFixture(User, orgnizationAdminUser), 48 | loadFixture(User, publicRelationsUser), 49 | loadFixture(User, superAdminUser), 50 | loadFixture(App3rd, adminApp3rd), 51 | loadFixture(Department, departments), 52 | loadFixture(Pin, pins), 53 | ]) 54 | .then(() => { 55 | done(); 56 | }) 57 | .catch((err) => { 58 | done(err); 59 | }); 60 | }); 61 | }); 62 | 63 | after((done) => { 64 | // Clear collections after finishing all tests. 65 | Promise.all([ 66 | User.remove({}), 67 | Pin.remove({}), 68 | Department.remove({}), 69 | App3rd.remove({}), 70 | ]) 71 | .then(() => { 72 | server.close((err) => { 73 | if (err) return done(err); 74 | 75 | return done(); 76 | }); 77 | }); 78 | }); 79 | 80 | it('returns 404 Not Found when id is not ObjectId', (done) => { 81 | // Test with invalid object id 82 | const id = '1234'; 83 | expect(mongoose.Types.ObjectId.isValid(id)).to.equal(false); 84 | 85 | request(app) 86 | .get('/pins/1234') 87 | .set('X-YOUPIN-3-APP-KEY', 88 | '579b04ac516706156da5bba1:ed545297-4024-4a75-89b4-c95fed1df436') 89 | .expect(404) 90 | .then((res) => { 91 | const error = res.body; 92 | 93 | expect(error.code).to.equal(404); 94 | expect(error.name).to.equal('NotFound'); 95 | expect(error.message).to.equal('No record found for id \'1234\''); 96 | 97 | done(); 98 | }); 99 | }); 100 | 101 | it('returns 200 w/ swapped lat-long by requesting using the correct id', (done) => { 102 | request(app) 103 | .get(`/pins/${PIN_PENDING_ID}`) 104 | .set('X-YOUPIN-3-APP-KEY', 105 | '579b04ac516706156da5bba1:ed545297-4024-4a75-89b4-c95fed1df436') 106 | .expect(200) 107 | .then((res) => { 108 | if (!res.body) { 109 | return done(new Error('No data return')); 110 | } 111 | const foundCoordinates = res.body.location.coordinates; 112 | 113 | expect(foundCoordinates).to.deep.equal([13.730537951109, 100.56983534303]); 114 | 115 | return done(); 116 | }); 117 | }); 118 | 119 | it('returns 200 + progresses and progresses.owner fields should be populated', (done) => { 120 | request(app) 121 | .get(`/pins/${PIN_PROCESSING_ID}`) 122 | .set('X-YOUPIN-3-APP-KEY', 123 | '579b04ac516706156da5bba1:ed545297-4024-4a75-89b4-c95fed1df436') 124 | .expect(200) 125 | .then((res) => { 126 | if (!res.body) { 127 | return done(new Error('No data return')); 128 | } 129 | const pin = res.body; 130 | expect(pin.progresses).to.have.lengthOf(1); 131 | expect(pin.progresses[0].detail).to.equal(PROGRESS_DETAIL); 132 | /* eslint-disable no-underscore-dangle */ 133 | // Make sure the owner is populated by checking its _id. 134 | expect(pin.progresses[0].owner._id).to.equal(adminUser._id.toString()); 135 | // Make sure the department under owner is populated by checking its _id. 136 | expect(pin.progresses[0].owner.department._id).to.equal(adminUser.department.toString()); 137 | /* eslint-enable */ 138 | return done(); 139 | }); 140 | }); 141 | }); 142 | -------------------------------------------------------------------------------- /src/services/searchnearby/index.js: -------------------------------------------------------------------------------- 1 | const errors = require('feathers-errors'); 2 | const Promise = require('bluebird'); 3 | 4 | const hooks = require('./hooks'); 5 | const NumberHelper = require('../../utils/number'); 6 | const Pin = require('../pin/pin-model.js'); 7 | 8 | class Service { 9 | constructor(options) { 10 | this.options = options || {}; 11 | } 12 | /* eslint-disable max-len */ 13 | /** 14 | * @api {get} /searchnearby Search nearby 15 | * @apiDescription Get a list of pins within a circle area 16 | * @apiVersion 0.1.0 17 | * @apiName GetSearchNearby 18 | * @apiGroup Pin 19 | * 20 | * @apiExample Example usage: 21 | * curl -i https://api.youpin.city/searchnearby?$center=[13.730537954909,100.56983580503]&$radius=1000&limit=10 22 | * 23 | * @apiParam {Number[]} center Center of an area to search in [lat, long] format (Note: prepended by a dollar sign). 24 | * @apiParam {Number} radius Circle radius to search around the center point (Note: prepended by a dollar sign). 25 | * @apiParam {Number} limit Limit of a number of result pins 26 | * 27 | * @apiSuccess {Number} limit Limit of a number of result pins 28 | * @apiSuccess {Object[]} data An array of found pins within area specified 29 | * 30 | * @apiSuccessExample Success Response: 31 | * HTTP/1.1 200 OK 32 | * { 33 | * limit: 10, 34 | * data: [ 35 | * { 36 | * _id: '579b8c113dd2dec509318c47', 37 | * detail: 'Dolore dolor tempora magni officiis nisi. Fuga qui cum sint temporibus quos quo repudiandae. Sit rerum et quis vitae. Sapiente architecto totam maiores dolor.', 38 | * owner: '579334c75563625d6281b6f1', 39 | * provider: '579334c75563625d6281b6f1', 40 | * __v: 0, 41 | * videos: [], 42 | * voters: [], 43 | * comments: [], 44 | * tags: [], 45 | * location: [Object], 46 | * photos: [], 47 | * neighborhood: [], 48 | * mentions: [], 49 | * followers: [], 50 | * updated_time: '2016-07-29T17:02:09.265Z', 51 | * created_time: '2016-07-29T17:02:09.265Z', 52 | * categories: [] 53 | * }, 54 | * { 55 | * _id: '979b8c1131d2de4509118c47', 56 | * detail: 'Dolore dolor tempora magni officiis nisi. Fuga qui cum sint temporibus quos quo repudiandae. Sit rerum et quis vitae. Sapiente architecto totam maiores dolor.', 57 | * owner: '579334c75563625d6281b6f1', 58 | * provider: '579334c75563625d6281b6f1', 59 | * __v: 0, 60 | * videos: [], 61 | * voters: [], 62 | * comments: [], 63 | * tags: [], 64 | * location: [Object], 65 | * photos: [], 66 | * neighborhood: [], 67 | * mentions: [], 68 | * followers: [], 69 | * updated_time: '2016-06-22T13:12:49.265Z', 70 | * created_time: '2016-06-22T13:12:49.265Z', 71 | * categories: [] 72 | * } 73 | * ] 74 | * } 75 | * 76 | * @apiError BadRequest Do not specify $center param. 77 | * 78 | * @apiErrorExample Error Response 79 | * HTTP/1.1 400 Bad Request 80 | * { 81 | * "name":"BadRequest", 82 | * "message":"Please assign the value to $center.", 83 | * "code":400, 84 | * "className":"bad-request", 85 | * "errors":{} 86 | * } 87 | */ 88 | /* eslint-enable max-len */ 89 | find(params) { 90 | if (!params.query.$center) { 91 | return Promise.reject(new errors.BadRequest('Please assign the value to $center.')); 92 | } 93 | 94 | if (params.query.limit && !NumberHelper.isIntegerString(params.query.limit)) { 95 | return Promise.reject(new errors.BadRequest('`limit` must be integer')); 96 | } 97 | 98 | if (params.query.$radius && !NumberHelper.isNumericString(params.query.$radius)) { 99 | return Promise.reject(new errors.BadRequest('`$radius` must be numeric')); 100 | } 101 | 102 | // Default limit: 10 103 | const limit = parseInt(params.query.limit, 10) || 10; 104 | // Default maxDistance: 1 km 105 | const maxDistance = parseFloat(params.query.$radius) || 1000; 106 | // TODO(A): Check if string is correct array format, if not, return meaningful error. 107 | let coordinate = JSON.parse(params.query.$center); 108 | // We get [lat, long] but mongo need [long, lat]. So, swap them. 109 | coordinate = [coordinate[1], coordinate[0]]; 110 | return Pin.find({ 111 | location: { 112 | $near: { 113 | $geometry: { 114 | type: 'Point', 115 | coordinates: coordinate, 116 | }, 117 | $maxDistance: maxDistance, 118 | }, 119 | }, 120 | }) 121 | .limit(limit) 122 | .exec() 123 | .then((results) => Promise.resolve({ 124 | limit, 125 | data: results, 126 | })) 127 | .catch((err) => Promise.resolve(new errors.GeneralError(err))); 128 | } 129 | } 130 | 131 | module.exports = function () { // eslint-disable-line func-names 132 | const app = this; 133 | 134 | // Initialize our service with any options it requires 135 | app.use('/searchnearby', new Service()); 136 | 137 | // Get our initialize service to that we can bind hooks 138 | const searchnearbyService = app.service('/searchnearby'); 139 | 140 | // Set up our before hooks 141 | searchnearbyService.before(hooks.before); 142 | 143 | // Set up our after hooks 144 | searchnearbyService.after(hooks.after); 145 | }; 146 | 147 | module.exports.Service = Service; 148 | -------------------------------------------------------------------------------- /src/services/user/index.js: -------------------------------------------------------------------------------- 1 | const service = require('feathers-mongoose'); 2 | 3 | const hooks = require('./hooks'); 4 | const User = require('./user-model'); 5 | 6 | module.exports = function () { // eslint-disable-line func-names 7 | const app = this; 8 | 9 | const options = { 10 | Model: User, 11 | paginate: { 12 | default: 5, 13 | max: 50, 14 | }, 15 | // Convert mongoose document to plain object to fix facebook user patch service 16 | // Ref: https://github.com/feathersjs/feathers-mongoose/issues/110 17 | lean: true, 18 | }; 19 | /* eslint-disable max-len */ 20 | /** 21 | * @api {get} /users/:id Get info 22 | * @apiVersion 0.1.0 23 | * @apiName GetUsers 24 | * @apiGroup User 25 | * 26 | * @apiExample Example usage: 27 | * curl -i -X GET https://api.youpin.city/users/5798cc78652b2aab35fd663d \ 28 | * -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJfaWQiOiI1Nzk5MTBjZjE3ZmQwYjJiMmM5M2Q5NWEiLCJpYXQiOjE0Njk2NDkyMTMsImV4cCI6MTQ2OTczNTYxMywiaXNzIjoiZmVhdGhlcnMifQ.E3A4i49hI7t6VlH8b7SSKwUHyrAZfzQV8LHukOdxceU' 29 | * 30 | * @apiParam {String} id User unique ID. 31 | * 32 | * @apiSuccess {String} _id User unique ID. 33 | * @apiSuccess {String} name User's full name. 34 | * @apiSuccess {String} email User email. 35 | * @apiSuccess {String} phone User phone. 36 | * @apiSuccess {String[]} customer_app_id App id that user belongs to. 37 | * @apiSuccess {String[]} owner_app_id App id that user owns. 38 | * @apiSuccess {String} created_time Created time in ISO 8601 format. 39 | * @apiSuccess {String} updated_time Updated time in ISO 8601 format. 40 | * 41 | * @apiSuccessExample Success Response: 42 | * HTTP/1.1 200 OK 43 | * { 44 | * "_id": "5799d79113ff08ba274c1393", 45 | * "name": "Auntie YouPin", 46 | * "email": "auntie_youpin@youpin.city", 47 | * "role": "user", 48 | * "owner_app_id": ["578590a6b3e8a8e9adc78df9"], 49 | * "customer_app_id": ["578580a6b3e8a8e9adc78df1"], 50 | * "updated_time": "2016-07-28T09:59:45.059Z", 51 | * "created_time": "2016-07-21T09:39:46.648Z" 52 | * } 53 | * 54 | * @apiError NotFound The id of the User was not found. 55 | * @apiError Forbidden Only the id owner is allowed to access. 56 | * @apiErrorExample Error Response: 57 | * HTTP/1.1 404 Not Found 58 | * { 59 | * "name":"NotFound", 60 | * "message":"No record found for id '1'", 61 | * "code":404, 62 | * "className":"not-found", 63 | * "errors":{} 64 | * } 65 | */ 66 | 67 | /** 68 | * @api {post} /users Add user 69 | * @apiVersion 0.1.0 70 | * @apiName PostUsers 71 | * @apiGroup User 72 | * 73 | * @apiExample Example usage: 74 | * curl -i -X POST https://api.youpin.city/users \ 75 | * -H 'Content-type: application/json' \ 76 | * -d @- << EOF 77 | * { 78 | * "name": "Auntie YouPin", 79 | * "email": "auntie_youpin@youpin.city", 80 | * "role": "user", 81 | * "owner_app_id": ["578590a6b3e8a8e9adc78df9"], 82 | * "customer_app_id": ["578580a6b3e8a8e9adc78df1"], 83 | * } 84 | * EOF 85 | * 86 | * @apiHeader Content-type=application/json 87 | * 88 | * @apiParam {String} name User's full name. 89 | * @apiParam {String} email User email. 90 | * @apiParam {String} phone User phone. 91 | * @apiParam {String[]} customer_app_id App id that user belongs to. 92 | * @apiParam {String[]} owner_app_id App id that user owns. 93 | * @apiParam {String} created_time Created time in ISO 8601 format. 94 | * @apiParam {String} updated_time Updated time in ISO 8601 format. 95 | * 96 | * @apiSuccess (Created 201) {String} _id User unique ID. 97 | * @apiSuccess (Created 201) {String} name User's full name. 98 | * @apiSuccess (Created 201) {String} email User email. 99 | * @apiSuccess (Created 201) {String} phone User phone. 100 | * @apiSuccess (Created 201) {String[]} customer_app_id App id that user belongs to. 101 | * @apiSuccess (Created 201) {String[]} owner_app_id App id that user owns. 102 | * @apiSuccess (Created 201) {String} created_time Created time in ISO 8601 format. 103 | * @apiSuccess (Created 201) {String} updated_time Updated time in ISO 8601 format. 104 | * 105 | * @apiSuccessExample Success Response: 106 | * HTTP/1.1 201 Created 107 | * { 108 | * "_id": "5799d79113ff08ba274c1393", 109 | * "name": "Auntie YouPin", 110 | * "email": "auntie_youpin@youpin.city", 111 | * "role": "user", 112 | * "owner_app_id": ["578590a6b3e8a8e9adc78df9"], 113 | * "customer_app_id": ["578580a6b3e8a8e9adc78df1"], 114 | * "updated_time": "2016-07-28T09:59:45.059Z", 115 | * "created_time": "2016-07-21T09:39:46.648Z" 116 | * } 117 | * 118 | * @apiError NotAuthenticated Authentication token missing 119 | * @apiError Conflict Duplicate username (email) 120 | * @apiError BadRequest Name/password/email field is required, email validation error 121 | * 122 | * @apiErrorExample Error Response 123 | * HTTP/1.1 401 Unauthorized 124 | * { 125 | * "name":"NotAuthenticated", 126 | * "message":"Authentication token missing", 127 | * "code":401, 128 | * "className":"not-authenticated", 129 | * "errors":{} 130 | * } 131 | */ 132 | /* eslint-enable max-len */ 133 | 134 | // Initialize our service with any options it requires 135 | app.use('/users', service(options)); 136 | // Get our initialize service to that we can bind hooks 137 | const userService = app.service('/users'); 138 | // Set up our before hooks 139 | userService.before(hooks.before); 140 | // Set up our after hooks 141 | userService.after(hooks.after); 142 | }; 143 | -------------------------------------------------------------------------------- /src/utils/send-mail-notification.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const nodemailer = require('nodemailer'); 3 | const path = require('path'); 4 | 5 | const EmailTemplate = require('email-templates').EmailTemplate; 6 | const ObjectId = require('mongoose').Types.ObjectId; 7 | 8 | // Model 9 | const Department = require('../services/department/department-model'); 10 | const User = require('../services/user/user-model'); 11 | 12 | // Trigger a mail service to send structural message from 'logInfo' 13 | // to the specified user 'id'. 14 | const sendMailNotification = (mailServiceConfig, issueBaseUrl, email, logInfo) => { 15 | if (!mailServiceConfig.providerConfig 16 | || !mailServiceConfig.content 17 | || !mailServiceConfig.content.title 18 | || !mailServiceConfig.content.logoUrl 19 | || !issueBaseUrl) { 20 | return Promise.reject('No proper mail service config. The notification will not be sent.'); 21 | } 22 | // Only send mail during test if a user forces it. 23 | if (process.env.NODE_ENV === 'test' && process.env.TEST_MAIL_NOTIF !== 'true') { 24 | return Promise.reject('To send mail during testing, please set TEST_MAIL_NOTIF=true.'); 25 | } 26 | const transporter = nodemailer.createTransport(mailServiceConfig.providerConfig); 27 | const message = logInfo.description; 28 | const fixedContent = mailServiceConfig.content; 29 | const context = { 30 | title: fixedContent.title, 31 | logoUrl: fixedContent.logoUrl, 32 | pinLink: `${issueBaseUrl}${logInfo.pin_id}`, 33 | changedFields: logInfo.changed_fields || [], 34 | previousValues: logInfo.previous_values || [], 35 | updatedValues: logInfo.updated_values || [], 36 | message, 37 | }; 38 | 39 | // Extract name field for better & meaningful email content. 40 | const decorateDataFields = (ctx) => new Promise((resolve) => { 41 | const newPreviousValues = []; 42 | const newUpdatedValues = []; 43 | 44 | for (const [idx, key] of ctx.changedFields.entries()) { 45 | let previousValue = ctx.previousValues[idx]; 46 | let updatedValue = ctx.updatedValues[idx]; 47 | 48 | switch (key) { 49 | case 'assigned_department': { 50 | const extractDepartmentName = (input) => { 51 | // If it is an object, we will utilise name instead of id. 52 | if (_.isObject(input) && input.name) { 53 | return input.name; 54 | } else if (ObjectId.isValid(input)) { 55 | // if it is an object id, we query for its name. 56 | return Department.findById(input) 57 | .then(obj => (obj ? obj.name : 'Unknown department')); 58 | } 59 | // otherwise just return original info. 60 | return input; 61 | }; 62 | previousValue = extractDepartmentName(previousValue); 63 | updatedValue = extractDepartmentName(updatedValue); 64 | break; 65 | } 66 | case 'assigned_users': { 67 | const extractAssignedUsersName = (input) => { 68 | // Since assigned_users is an array, it is necessary to use Promise.all 69 | // to populate all data. 70 | const mapper = input.map(elem => { 71 | // If it is an object, we will utilise name instead of id. 72 | if (_.isObject(elem) && elem.name) { 73 | return elem.name; 74 | } else if (ObjectId.isValid(elem)) { 75 | // if it is an object id, we query for its name. 76 | return User.findById(elem) 77 | .then(obj => (obj ? obj.name : 'Unknown user')); 78 | } 79 | // otherwise just return original info. 80 | return elem; 81 | }); 82 | return Promise.all(mapper); 83 | }; 84 | previousValue = extractAssignedUsersName(previousValue); 85 | updatedValue = extractAssignedUsersName(updatedValue); 86 | break; 87 | } 88 | case 'processed_by': { 89 | const extractProcessedByName = (input) => { 90 | // If it is an object, we will utilise name instead of id. 91 | if (_.isObject(input) && input.name) { 92 | return input.name; 93 | } else if (ObjectId.isValid(input)) { 94 | // if it is an object id, we query for its name. 95 | return User.findById(input) 96 | .then(obj => (obj ? obj.name : 'Unknown user')); 97 | } 98 | // otherwise just return original info. 99 | return input; 100 | }; 101 | previousValue = extractProcessedByName(previousValue); 102 | updatedValue = extractProcessedByName(updatedValue); 103 | break; 104 | } 105 | default: { 106 | // Return whatever it is for other fields. 107 | } 108 | } 109 | newPreviousValues.push(previousValue); 110 | newUpdatedValues.push(updatedValue); 111 | } 112 | ctx.previousValues = newPreviousValues; // eslint-disable-line no-param-reassign 113 | ctx.updatedValues = newUpdatedValues; // eslint-disable-line no-param-reassign 114 | return Promise.all(ctx.previousValues).then((results) => { 115 | ctx.previousValues = results; // eslint-disable-line no-param-reassign 116 | return Promise.all(ctx.updatedValues); 117 | }).then((results) => { 118 | ctx.updatedValues = results; // eslint-disable-line no-param-reassign 119 | resolve(ctx); 120 | }); 121 | }); 122 | // TODO(A): Send to multiple emails at once and use a better html text format. 123 | const templateDir = path.join(__dirname, 'email-templates', 'notification'); 124 | const template = new EmailTemplate(templateDir); 125 | 126 | return decorateDataFields(context) 127 | .then(ctx => template.render(ctx)) 128 | .then(result => { 129 | const mailOptions = { 130 | from: mailServiceConfig.content.from, 131 | to: email, 132 | subject: fixedContent.title, 133 | text: result.text, 134 | html: result.html, 135 | }; 136 | return new Promise((resolve, reject) => { 137 | transporter.sendMail(mailOptions, (error, info) => { 138 | if (error) { 139 | return reject(error); 140 | } 141 | return resolve(info); 142 | }); 143 | }); 144 | }); 145 | }; 146 | 147 | module.exports = sendMailNotification; 148 | -------------------------------------------------------------------------------- /src/services/pin-state-transition/index.js: -------------------------------------------------------------------------------- 1 | const errors = require('feathers-errors'); 2 | 3 | const hooks = require('./hooks'); 4 | const Pin = require('../pin/pin-model'); 5 | 6 | // States 7 | const { 8 | ASSIGNED, 9 | PENDING, 10 | PROCESSING, 11 | REJECTED, 12 | RESOLVED, 13 | } = require('../../constants/pin-states'); 14 | 15 | // Roles 16 | const { 17 | DEPARTMENT_HEAD, 18 | DEPARTMENT_OFFICER, 19 | EXECUTIVE_ADMIN, 20 | ORGANIZATION_ADMIN, 21 | SUPER_ADMIN, 22 | USER, 23 | } = require('../../constants/roles'); 24 | 25 | class PinTransitionService { 26 | static isValidStateTransition(prevState, nextState, role) { 27 | // Map previous state and role to a list of possible next states 28 | const possibleNextStates = { 29 | [PENDING]: { 30 | [SUPER_ADMIN]: [ASSIGNED, REJECTED], 31 | [EXECUTIVE_ADMIN]: [ASSIGNED, REJECTED], 32 | [ORGANIZATION_ADMIN]: [ASSIGNED, REJECTED], 33 | [DEPARTMENT_HEAD]: [ASSIGNED], 34 | [DEPARTMENT_OFFICER]: [ASSIGNED], 35 | [USER]: [], 36 | }, 37 | [ASSIGNED]: { 38 | [SUPER_ADMIN]: [PENDING, PROCESSING], 39 | [EXECUTIVE_ADMIN]: [PENDING], 40 | [ORGANIZATION_ADMIN]: [PENDING], 41 | [DEPARTMENT_HEAD]: [PENDING, PROCESSING], 42 | [DEPARTMENT_OFFICER]: [PENDING], 43 | [USER]: [], 44 | }, 45 | [PROCESSING]: { 46 | [SUPER_ADMIN]: [RESOLVED], 47 | [EXECUTIVE_ADMIN]: [], 48 | [ORGANIZATION_ADMIN]: [], 49 | [DEPARTMENT_HEAD]: [RESOLVED], 50 | [DEPARTMENT_OFFICER]: [], 51 | [USER]: [], 52 | }, 53 | [RESOLVED]: { 54 | [SUPER_ADMIN]: [PENDING, PROCESSING], 55 | [EXECUTIVE_ADMIN]: [PENDING, PROCESSING], 56 | [ORGANIZATION_ADMIN]: [PENDING, PROCESSING], 57 | [DEPARTMENT_HEAD]: [PENDING], 58 | [DEPARTMENT_OFFICER]: [], 59 | [USER]: [], 60 | }, 61 | [REJECTED]: { 62 | [SUPER_ADMIN]: [PENDING], 63 | [EXECUTIVE_ADMIN]: [PENDING], 64 | [ORGANIZATION_ADMIN]: [PENDING], 65 | [DEPARTMENT_HEAD]: [PENDING], 66 | [DEPARTMENT_OFFICER]: [], 67 | [USER]: [], 68 | }, 69 | }; 70 | 71 | // Valid if we can find nextState in a list of possible next states 72 | return possibleNextStates[prevState][role].indexOf(nextState) !== -1; 73 | } 74 | 75 | // Initializes app to get config values 76 | setup(app) { 77 | this.app = app; 78 | } 79 | // Create new state transition (i.e. change state) 80 | create(data, params) { 81 | const pinId = params.pinId; 82 | const nextState = data.state; 83 | const previousState = data.previousState; 84 | const role = params.user.role; 85 | 86 | if (!pinId) { 87 | throw new errors.BadRequest('Pin ID is not specified'); 88 | } 89 | if (!nextState) { 90 | throw new errors.BadRequest('Need `state` in body data to change state'); 91 | } 92 | 93 | if (!previousState) { 94 | throw new errors.GeneralError( 95 | 'Internal error: Pin has no previous state' 96 | ); 97 | } 98 | 99 | if (!role) { 100 | throw new errors.GeneralError('Internal error: User has no role'); 101 | } 102 | const enableStateTransitionCheck = this.app.get('enableStateTransitionCheck'); 103 | if ( 104 | enableStateTransitionCheck && !PinTransitionService.isValidStateTransition( 105 | previousState, 106 | nextState, 107 | role 108 | ) 109 | ) { 110 | throw new errors.BadRequest( 111 | `Cannot change state from ${previousState} to ${nextState} with role ${role}` 112 | ); 113 | } 114 | 115 | // Pin properties to be updated 116 | const updatingProperties = { 117 | status: nextState, 118 | }; 119 | 120 | /* eslint-disable no-param-reassign */ 121 | // Need additional property for ASSIGNED and PROCESSING states 122 | if (nextState === ASSIGNED) { 123 | if (!data.assigned_department) { 124 | throw new errors.BadRequest( 125 | 'Need `assigned_department` in body data to change to `assigned` state' 126 | ); 127 | } 128 | // pending -> assigned | Notify DEPARTMENT_HEAD to assign pin 129 | // to choose a person in his/her department. 130 | data.toBeNotifiedDepartments = [data.assigned_department]; 131 | data.toBeNotifiedRoles = [DEPARTMENT_HEAD]; 132 | // Also notify pin owner (see issue #257); 133 | data.toBeNotifiedUsers = [data.pinOwner]; 134 | updatingProperties.assigned_department = data.assigned_department; 135 | updatingProperties.assigned_time = Date.now(); 136 | } else if (nextState === PROCESSING) { 137 | // Need to specify `processed_by` and `assigned_users` if the previousState is ASSIGNED. 138 | // If the previousState is RESOLVED, the pin already has those values. 139 | if (previousState === ASSIGNED) { 140 | if (!data.processed_by || !data.assigned_users) { 141 | throw new errors.BadRequest( 142 | 'Need `processed_by` and `assigned_users` ' + 143 | 'in body data to change to `processing` state' 144 | ); 145 | } 146 | // assigned -> processing 147 | // Notify to DEPARTMENT_OFFICER or DEPARTMENT_HEAD who gets assigned. 148 | // Notify pin owner 149 | data.toBeNotifiedUsers = [data.assigned_users, data.pinOwner]; 150 | updatingProperties.processed_by = data.processed_by; 151 | updatingProperties.assigned_users = data.assigned_users; 152 | updatingProperties.processing_time = Date.now(); 153 | } else if (previousState === RESOLVED) { 154 | // resolved -> processing 155 | // Notify assigned DEPARTMENT_OFFICER and DEPARTMENT_HEAD 156 | data.toBeNotifiedDepartments = [data.previousAssignedDepartment]; 157 | data.toBeNotifiedRoles = [DEPARTMENT_OFFICER, DEPARTMENT_HEAD]; 158 | updatingProperties.resolved_time = null; 159 | } 160 | } else if (nextState === PENDING) { 161 | if (previousState === ASSIGNED) { 162 | // assigned/ -> pending 163 | // Notify back to ORGANIZATION_ADMIN to manage pin's assignment again. 164 | data.toBeNotifiedRoles = [ORGANIZATION_ADMIN]; 165 | updatingProperties.assigned_department = null; 166 | } else if (previousState === RESOLVED) { 167 | // resolved -> pending 168 | // Notify ORGANIZATION_ADMIN to manage pin's assignment again. 169 | // Notify DEPARTMENT_HEAD that the resolved pin might be needed to re-fix again. 170 | data.toBeNotifiedRoles = [ORGANIZATION_ADMIN, DEPARTMENT_HEAD]; 171 | updatingProperties.resolved_time = null; 172 | updatingProperties.assigned_department = null; 173 | updatingProperties.assigned_users = null; 174 | } else if (previousState === REJECTED) { 175 | // rejected -> pending 176 | // Notify ORGANIZATION_ADMIN to manage pin's assignment again. 177 | data.toBeNotifiedRoles = [ORGANIZATION_ADMIN]; 178 | updatingProperties.rejected_time = null; 179 | } 180 | } else if (nextState === RESOLVED) { 181 | // processing -> resolved 182 | // Notify ORGANIZATION_ADMIN and DEPARTMENT_HEAD 183 | // Notify assigned_user 184 | data.toBeNotifiedUsers = [data.assigned_users]; 185 | data.toBeNotifiedRoles = [ORGANIZATION_ADMIN, DEPARTMENT_HEAD]; 186 | updatingProperties.resolved_time = Date.now(); 187 | } else if (nextState === REJECTED) { 188 | updatingProperties.rejected_time = Date.now(); 189 | } 190 | /* eslint-enable */ 191 | 192 | return Pin.update({ _id: pinId }, { $set: updatingProperties }) 193 | .then(() => Promise.resolve(Object.assign(updatingProperties, { pinId }))) 194 | .catch(err => Promise.reject(err)); 195 | } 196 | } 197 | 198 | module.exports = function registerStateTransitionService() { 199 | const app = this; 200 | app.use('/pins/:pinId/state_transition', new PinTransitionService()); 201 | 202 | const pinTransitionService = app.service('/pins/:pinId/state_transition'); 203 | pinTransitionService.before(hooks.before); 204 | pinTransitionService.after(hooks.after); 205 | }; 206 | 207 | module.exports.PinTransitionService = PinTransitionService; 208 | -------------------------------------------------------------------------------- /src/services/pin-state-transition/hooks/prepare-activity-log.js: -------------------------------------------------------------------------------- 1 | const errors = require('feathers-errors'); 2 | const mongoose = require('mongoose'); 3 | 4 | const actions = require('../../../constants/actions'); 5 | const states = require('../../../constants/pin-states'); 6 | const Department = require('../../department/department-model'); 7 | const Pin = require('../../pin/pin-model'); 8 | // Constants 9 | const { EMAIL_NOTI_NON_ASSIGNED_TEXT } = require('../../../constants/strings'); 10 | 11 | const safetyCheck = (hook) => { 12 | // hook.params.user must be populated by auth.populateUser before hook 13 | if (!hook.params.user) { 14 | throw new errors.GeneralError('Internal error: User is not populated'); 15 | } 16 | 17 | // hook.params.pinId must be provided via request URL 18 | if (!hook.params.pinId || !mongoose.Types.ObjectId.isValid(hook.params.pinId)) { 19 | throw new errors.NotFound(`No pin found for id '${hook.params.pinId}'`); 20 | } 21 | 22 | // hook.data.state must be provided via body data 23 | if (!hook.data.state) { 24 | throw new errors.BadRequest('Need `state` body data for state transtion'); 25 | } 26 | 27 | // hook.data.assigned_department must be provided for `assigned` state transtion 28 | if (hook.data.state === states.ASSIGNED && !hook.data.assigned_department) { 29 | throw new errors.BadRequest('Need `assigned_department` body data for `assigned` state'); 30 | } 31 | }; 32 | 33 | // For before hook to prepare activity log and will be used by after hook 34 | // Note: we can't do this in after hook because we need previous pin's properties before updated 35 | const prepareActivityLog = () => (hook) => { 36 | // throw error if hook is invalid 37 | safetyCheck(hook); 38 | 39 | const pinId = hook.params.pinId; 40 | const nameOfUser = hook.params.user.name; 41 | const department = hook.params.user.department; 42 | const nextState = hook.data.state; 43 | let assignedDepartmentObject; 44 | 45 | return new Promise((resolve, reject) => { 46 | if (hook.data && hook.data.assigned_department) { 47 | Department.findById(hook.data.assigned_department) 48 | .then((foundDepartment) => { 49 | assignedDepartmentObject = foundDepartment; 50 | resolve(); 51 | }) 52 | .catch(err => reject(err)); 53 | } else { 54 | resolve(); 55 | } 56 | }) 57 | .then(() => Pin.findById(pinId)) 58 | .then(pin => { 59 | // Check next state, then, set corresponding action, changed fields, and description 60 | let action; 61 | let description; 62 | const previousState = pin.status; 63 | const changedFields = ['status']; 64 | const previousValues = [previousState]; 65 | const updatedValues = [nextState]; 66 | if (pin.assigned_department) { // eslint-disable-line no-underscore-dangle,max-len 67 | assignedDepartmentObject = pin.assigned_department; 68 | } 69 | /* eslint-disable no-underscore-dangle */ 70 | switch (nextState) { 71 | case states.REJECTED: 72 | action = actions.REJECT; 73 | description = `${nameOfUser} rejected pin #${pinId}`; 74 | break; 75 | case states.PENDING: 76 | if (previousState === states.ASSIGNED) { 77 | // If a pin has already been assigned to a department (in ASSIGNED state), 78 | // but the department denies that assignment, 79 | // the pin's next state will go back to PENDING (and need to re-assign). 80 | // So, we will remove `assigned_department` value from the pin. 81 | action = actions.DENY; 82 | changedFields.push('assigned_department'); 83 | previousValues.push(assignedDepartmentObject._id); 84 | updatedValues.push(EMAIL_NOTI_NON_ASSIGNED_TEXT); 85 | description = `${nameOfUser} denies pin #${pinId}`; 86 | } else if (previousState === states.RESOLVED) { 87 | // If a pin has already resolved but it is re-opened again. 88 | // Remove `assigned_department` value from the pin 89 | // and ORGANIZATION_ADMIN will do assignment again. 90 | action = actions.RE_OPEN; 91 | changedFields.push('assigned_department'); 92 | previousValues.push(assignedDepartmentObject._id); 93 | updatedValues.push(EMAIL_NOTI_NON_ASSIGNED_TEXT); 94 | description = `${nameOfUser} re-opens pin #${pinId}`; 95 | } else if (previousState === states.REJECTED) { 96 | // Re-open a rejected pin. No need to change any field. 97 | action = actions.RE_OPEN; 98 | description = `${nameOfUser} re-opens pin #${pinId}`; 99 | } 100 | break; 101 | case states.ASSIGNED: 102 | action = actions.ASSIGN; 103 | changedFields.push('assigned_department'); 104 | previousValues.push(EMAIL_NOTI_NON_ASSIGNED_TEXT); 105 | updatedValues.push(assignedDepartmentObject._id); 106 | description = `${nameOfUser} assigned pin #${pinId} ` + 107 | `to department ${assignedDepartmentObject.name}`; 108 | break; 109 | case states.PROCESSING: 110 | if (previousState === states.ASSIGNED) { 111 | if (!hook.data.processed_by) { 112 | throw new errors.BadRequest('Need `processed_by` body data for `processing` state'); 113 | } 114 | if (!hook.data.assigned_users) { 115 | throw new errors.BadRequest('Need `assigned_users` body data for `processing` state'); 116 | } 117 | action = actions.PROCESS; 118 | changedFields.push('processed_by'); 119 | previousValues.push(pin.processed_by); 120 | updatedValues.push(hook.data.processed_by); 121 | changedFields.push('assigned_users'); 122 | previousValues.push(pin.assigned_users); 123 | updatedValues.push(hook.data.assigned_users); 124 | description = `${nameOfUser} is processing pin #${pinId}`; 125 | } else if (previousState === states.RESOLVED) { 126 | // If a department marks a pin as resolved but it does not satisfy an organization admin, 127 | // the organization admin can send the pin back to be re-processed. 128 | action = actions.RE_PROCESS; 129 | changedFields.push('resolved_time'); 130 | previousValues.push(pin.resolved_time); 131 | updatedValues.push(EMAIL_NOTI_NON_ASSIGNED_TEXT); 132 | description = `${nameOfUser} sent pin #${pinId} back` + 133 | ` to be re-processed by ${assignedDepartmentObject.name}`; 134 | // Attach assigned department for later use. 135 | hook.data.previousAssignedDepartment = assignedDepartmentObject._id; // eslint-disable-line max-len,no-param-reassign 136 | } 137 | break; 138 | case states.RESOLVED: 139 | action = actions.RESOLVE; 140 | description = `${nameOfUser} marked pin #${pinId} as resolved`; 141 | break; 142 | default: 143 | action = null; 144 | } 145 | /* eslint-enable */ 146 | 147 | // Prevent invalid action 148 | if (!action) { 149 | throw new errors.BadRequest('Invalid next state'); 150 | } 151 | 152 | // TODO: Restructure/format all useful data that get utilised after this hook. 153 | // Bcoz lots of these data can be acquired by just passing the whole pin. 154 | // instead of passing small parts of the data here and there. 155 | // (pin.organization, pinId, pin..., etc.) 156 | 157 | // Pass logInfo object to after hook by attaching to hook.data 158 | const logInfo = { 159 | user: nameOfUser, 160 | organization: pin.organization, 161 | department, 162 | actionType: actions.types.STATE_TRANSITION, 163 | action, 164 | pin_id: pinId, 165 | changed_fields: changedFields, 166 | previous_values: previousValues, 167 | updated_values: updatedValues, 168 | description, 169 | timestamp: Date.now(), 170 | }; 171 | 172 | // Attach data for log-activity hook 173 | hook.data.logInfo = logInfo; // eslint-disable-line no-param-reassign 174 | 175 | // Attach data for PinTransitionService 176 | hook.data.previousState = previousState; // eslint-disable-line no-param-reassign 177 | 178 | // Attach pin owner for notify hook 179 | hook.data.pinOwner = pin.owner; // eslint-disable-line no-param-reassign 180 | 181 | return Promise.resolve(hook); 182 | }) 183 | .catch(err => { 184 | throw new Error(err); 185 | }); 186 | }; 187 | 188 | module.exports = prepareActivityLog; 189 | --------------------------------------------------------------------------------