diff --git a/src/controllers/IssueController.js b/src/controllers/IssueController.js new file mode 100644 index 0000000..3ec85c0 --- /dev/null +++ b/src/controllers/IssueController.js @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2018 TopCoder, Inc. All rights reserved. + */ + +/** + * This controller exposes project endpoints. + * + * @author veshu + * @version 1.0 + */ +const helper = require('../common/helper'); +const IssueService = require('../services/IssueService'); + +/** + * search issues + * @param {Object} req the request + * @param {Object} res the response + * @returns {Object} the result + */ +async function search(req) { + return await IssueService.search(req.query, req.currentUser.handle); +} + +module.exports = { + search, +}; + +helper.buildController(module.exports); \ No newline at end of file diff --git a/src/front/src/app/app.js b/src/front/src/app/app.js index ce41c2b..0f6aeb4 100644 --- a/src/front/src/app/app.js +++ b/src/front/src/app/app.js @@ -68,6 +68,8 @@ angular.module('topcoderX', [ }) .state('app.main', { url: '/main', + controller: 'MainController', + controllerAs: 'vm', templateUrl: 'app/main/main.html', data: { pageTitle: 'Dashboard' }, resolve: { auth: authenticate } diff --git a/src/front/src/app/main/issue.service.js b/src/front/src/app/main/issue.service.js new file mode 100644 index 0000000..af75acf --- /dev/null +++ b/src/front/src/app/main/issue.service.js @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2018 TopCoder, Inc. All rights reserved. + * + * This is a service to access the backend api for issue related operation. + */ +'use strict'; + +angular.module('topcoderX') + .factory('IssueService', ['$http', 'Helper', function ($http, Helper) { + var baseUrl = Helper.baseUrl; + var service = {}; + + /** + * search for issues + */ + service.search = function (label, sortBy, sortDir, pageNo, pageSize) { + return $http.get(baseUrl + '/api/v1/issues?label=' + label + '&sortBy=' + sortBy + '&sortDir=' + sortDir + '&page=' + pageNo + '&perPage=' + pageSize).then(function (response) { + return response; + }); + }; + + return service; + }]); diff --git a/src/front/src/app/main/main.controller.js b/src/front/src/app/main/main.controller.js index afd4bf1..021a7c0 100644 --- a/src/front/src/app/main/main.controller.js +++ b/src/front/src/app/main/main.controller.js @@ -1,18 +1,141 @@ 'use strict'; angular.module('topcoderX') - .controller('MainController', ['$scope', '$rootScope', '$timeout', '$state', 'AuthService', - function ($scope, $rootScope, $timeout, $state, AuthService) { + .controller('MainController', ['$scope', '$rootScope', 'Alert', '$state', 'AuthService', 'IssueService', + function ($scope, $rootScope, Alert, $state, AuthService, IssueService) { + $scope.isLoaded = false; + $scope.tableConfig = { + readyForReview: { + pageNumber: 1, + pageSize: 20, + isLoading: false, + items: [], + label: 'Ready for review', + sortBy: 'updatedAt', + sortDir: 'desc', + totalPages: 1, + initialized: false, + }, + assigned: { + pageNumber: 1, + pageSize: 20, + isLoading: false, + items: [], + label: 'Assigned', + sortBy: 'updatedAt', + sortDir: 'desc', + totalPages: 1, + initialized: false, + }, + openForPickup: { + pageNumber: 1, + pageSize: 20, + isLoading: false, + items: [], + label: 'Open for pickup', + sortBy: 'updatedAt', + sortDir: 'desc', + totalPages: 1, + initialized: false, + }, + paid: { + pageNumber: 1, + pageSize: 20, + isLoading: false, + items: [], + label: 'Paid', + sortBy: 'updatedAt', + sortDir: 'desc', + totalPages: 1, + initialized: false, + }, + }; $rootScope.currentUser = AuthService.getCurrentUser(); $scope.logout = function () { AuthService.logout(); - $state.go('auth') + $state.go('auth'); }; // auth $scope.authorized = function () { return AuthService.isLoggedIn(); }; - } - ]); + + var _search = function (provider) { + var config = $scope.tableConfig[provider]; + config.isLoading = true; + IssueService.search(config.label, config.sortBy, config.sortDir, config.pageNumber, config.pageSize) + .then(function (res) { + config.items = res.data.docs; + config.pages = res.data.pages; + config.isLoading = false; + config.initialized = true; + }).catch(function (err) { + config.isLoading = false; + config.initialized = true; + _handleError(err, 'An error occurred while getting the data for ' + provider + '.'); + }); + }; + + _search('readyForReview'); + + // handle errors + function _handleError(error, defaultMsg) { + var errMsg = error.data ? error.data.message : defaultMsg; + Alert.error(errMsg, $scope); + } + + // change to a specific page + $scope.changePage = function (pageNumber, provider) { + if (pageNumber === 0 || pageNumber > $scope.tableConfig[provider].pages || + (pageNumber === $scope.tableConfig[provider].pages && + $scope.tableConfig[provider].pageNumber === pageNumber)) { + return false; + } + $scope.tableConfig[provider].pageNumber = pageNumber; + _search(provider); + }; + + $scope.tabChanged = function (provider) { + $scope.tableConfig[provider].sortBy = 'updatedAt'; + $scope.tableConfig[provider].sortDir = 'desc'; + $scope.tableConfig[provider].pageNumber = 1; + $scope.tableConfig[provider].initialized = false; + _search(provider); + }; + + // get the number array that shows the pagination bar + $scope.getPageArray = function (provider) { + var res = []; + + var pageNo = $scope.tableConfig[provider].pageNumber; + var i = pageNo - 5; + for (i; i <= pageNo; i++) { + if (i > 0) { + res.push(i); + } + } + var j = pageNo + 1; + for (j; j <= $scope.tableConfig[provider].pages && j <= pageNo + 5; j++) { + res.push(j); + } + return res; + }; + + // sort by criteria + $scope.sort = function (criteria, provider) { + if (criteria === $scope.tableConfig[provider].sortBy) { + if ($scope.tableConfig[provider].sortDir === 'asc') { + $scope.tableConfig[provider].sortDir = 'desc'; + } else { + $scope.tableConfig[provider].sortDir = 'asc'; + } + } else { + $scope.tableConfig[provider].sortDir = 'asc'; + } + $scope.tableConfig[provider].sortBy = criteria; + $scope.tableConfig[provider].pageNumber = 1; + _search(provider); + }; + }]); diff --git a/src/front/src/app/main/main.html b/src/front/src/app/main/main.html index 24f4853..8878246 100644 --- a/src/front/src/app/main/main.html +++ b/src/front/src/app/main/main.html @@ -2,14 +2,280 @@

Dashboard

-
+
+
-
+
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ Ticket update date + + + Ticket title + + + Ticket assignee + + + Project name + + Link to the ticket
{{item.updatedAt | date: 'MM.dd.yyyy hh:mm:ss a'}}[${{item.prizes[0]}}] {{item.title}}{{item.assignee}}{{item.projectId.title}} + {{item.number}} +
+ +
+
+
+ No tickets found +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ Ticket update date + + + Ticket title + + + Ticket assignee + + + Project name + + Link to the ticket
{{item.updatedAt | date: 'MM.dd.yyyy hh:mm:ss a'}}[${{item.prizes[0]}}] {{item.title}}{{item.assignee}}{{item.projectId.title}} + {{item.number}} +
+ +
+
+
+ No tickets found +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ Ticket update date + + + Ticket title + + + Ticket assignee + + + Project name + + Link to the ticket
{{item.updatedAt | date: 'MM.dd.yyyy hh:mm:ss a'}}[${{item.prizes[0]}}] {{item.title}}{{item.assignee}}{{item.projectId.title}} + {{item.number}} +
+ +
+
+
+ No tickets found +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ Ticket update date + + + Ticket title + + + Ticket assignee + + + Project name + + Link to the ticket
{{item.updatedAt | date: 'MM.dd.yyyy hh:mm:ss a'}}[${{item.prizes[0]}}] {{item.title}}{{item.assignee}}{{item.projectId.title}} + {{item.number}} +
+ +
+
+
+ No tickets found +
+
+
+
diff --git a/src/models/Issue.js b/src/models/Issue.js new file mode 100644 index 0000000..4c8168b --- /dev/null +++ b/src/models/Issue.js @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2017 TopCoder, Inc. All rights reserved. + */ + +/** + * Schema for Issue. + * @author TCSCODER + * @version 1.0 + */ +const mongoose = require('mongoose'); + +const schema = new mongoose.Schema({ + // From the receiver service + number: {type: Number, required: true}, + title: {type: String, required: true}, + body: String, + prizes: [{type: Number, required: true}], // extracted from title + provider: {type: String, required: true}, // github or gitlab + repositoryId: {type: Number, required: true}, + labels: [{type: String, required: true}], + assignee: {type: String, required: false}, + updatedAt: { + type: Date, + default: Date.now, + }, + // From topcoder api + challengeId: {type: Number, required: true, unique: true}, + projectId: {type: mongoose.Schema.Types.ObjectId, ref: 'Project'}, +}); + +// Issue number, provider, repositoryId must be unique +schema.index({number: 1, provider: 1, repositoryId: 1}, {unique: true}); +schema.index({labels: 1}); +schema.index({projectId: 1}); + +schema.pre('save', function preSave(next) { + this.updatedAt = Date.now(); // eslint-disable-line + return next(); +}); +module.exports = schema; diff --git a/src/routes.js b/src/routes.js index b8fcc02..5a1c2f4 100644 --- a/src/routes.js +++ b/src/routes.js @@ -95,6 +95,7 @@ module.exports = { get: { controller: 'TCUserController', method: 'login', + allowNormalUser: true, }, }, '/admin/tcuser': { @@ -148,4 +149,10 @@ module.exports = { allowNormalUser: true, }, }, + '/issues': { + get: { + controller: 'IssueController', + method: 'search', + }, + }, }; diff --git a/src/services/GithubService.js b/src/services/GithubService.js index 964962e..ad6605e 100644 --- a/src/services/GithubService.js +++ b/src/services/GithubService.js @@ -10,6 +10,7 @@ */ const GitHub = require('github-api'); const Joi = require('joi'); +const _ = require('lodash'); const config = require('../config'); const constants = require('../common/constants'); const helper = require('../common/helper'); @@ -173,22 +174,27 @@ getTeamRegistrationUrl.schema = Joi.object().keys({ * @returns {Promise} the promise result */ async function addTeamMember(teamId, ownerUserToken, normalUserToken) { + let username; + let id; try { // get normal user name const githubNormalUser = new GitHub({ token: normalUserToken }); const normalUser = await githubNormalUser.getUser().getProfile(); - const username = normalUser.data.login; - const id = normalUser.data.id; + username = normalUser.data.login; + id = normalUser.data.id; // add normal user to team const github = new GitHub({ token: ownerUserToken }); const team = github.getTeam(teamId); await team.addMembership(username); - // return github username - return { username, id }; } catch (err) { - throw helper.convertGitHubError(err, 'Failed to add team member'); + // if error is already exists discard + if (_.chain(err).get('body.errors').countBy({ code: 'already_exists' }).get('true').isUndefined().value()) { + throw helper.convertGitHubError(err, 'Failed to add team member'); + } } + // return github username + return {username, id}; } addTeamMember.schema = Joi.object().keys({ diff --git a/src/services/GitlabService.js b/src/services/GitlabService.js index 42488c4..e8635dd 100644 --- a/src/services/GitlabService.js +++ b/src/services/GitlabService.js @@ -11,6 +11,7 @@ const Joi = require('joi'); const superagent = require('superagent'); const superagentPromise = require('superagent-promise'); +const _ = require('lodash'); const config = require('../config'); const constants = require('../common/constants'); const helper = require('../common/helper'); @@ -167,13 +168,16 @@ getGroupRegistrationUrl.schema = Joi.object().keys({ * @returns {Promise} the promise result */ async function addGroupMember(groupId, ownerUserToken, normalUserToken) { + let username; + let userId; try { // get normal user id const res = await request .get(`${config.GITLAB_API_BASE_URL}/api/v4/user`) .set('Authorization', `Bearer ${normalUserToken}`) .end(); - const userId = res.body.id; + userId = res.body.id; + username = res.body.username; if (!userId) { throw new errors.UnauthorizedError('Can not get user id from the normal user access token.'); } @@ -187,10 +191,13 @@ async function addGroupMember(groupId, ownerUserToken, normalUserToken) { // return gitlab username return { username: res.body.username, id: res.body.id }; } catch (err) { - if (err instanceof errors.ApiError) { - throw err; + if (_.get(JSON.parse(err.response.text), 'message') !== 'Member already exists') { + if (err instanceof errors.ApiError) { + throw err; + } + throw helper.convertGitLabError(err, 'Failed to add group member'); } - throw helper.convertGitLabError(err, 'Failed to add group member'); + return {username, id: userId}; } } diff --git a/src/services/IssueService.js b/src/services/IssueService.js new file mode 100644 index 0000000..050a4eb --- /dev/null +++ b/src/services/IssueService.js @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2018 TopCoder, Inc. All rights reserved. + */ + +/** + * This service will provide project operations. + * + * @author TCSCODER + * @version 1.0 + */ +const Joi = require('joi'); +const _ = require('lodash'); +const helper = require('../common/helper'); +const models = require('../models'); + + +/** + * searches the issues + * @param {Object} criteria the search criteria + * @param {String} currentUserTopcoderHandle current user's topcoder handle + * @returns {Object} the search results + */ +async function search(criteria, currentUserTopcoderHandle) { + const query = {}; + if (criteria.label) { + query.labels = { $in: [criteria.label] }; + } + + // select projects for current user + const projects = await models.Project.find({ username: currentUserTopcoderHandle, archived: false }); + query.projectId = { + $in: projects.map((i) => i._id), + }; + + if (!criteria.sortBy) { + criteria.sortBy = 'updatedAt'; + criteria.sortDir = 'desc'; + } + const docs = await models.Issue.find(query) + .populate({ path: 'projectId', select: 'title repoUrl' }); + const offset = (criteria.page - 1) * criteria.perPage; + const result = { + pages: Math.ceil(docs.length / criteria.perPage) || 1, + docs: _(docs).orderBy(criteria.sortBy, criteria.sortDir) + .slice(offset).take(criteria.perPage) + .value(), + }; + return result; +} + +search.schema = Joi.object().keys({ + criteria: Joi.object().keys({ + label: Joi.string().required(), + sortBy: Joi.string().valid('title', 'projectId.title', 'updatedAt', 'assignee').default('updatedAt'), + sortDir: Joi.string().valid('asc', 'desc').default('asc'), + page: Joi.number().integer().min(1).required(), + perPage: Joi.number().integer().min(1).required(), + }), + currentUserTopcoderHandle: Joi.string().required(), +}); + +module.exports = { + search, +}; + +helper.buildService(module.exports);