Skip to content

Commit 0f4db68

Browse files
authored
Merge pull request #805 from topcoder-platform/pm-1168
feat(PM-1168): Added API to assign an opportunity with an applicant
2 parents c36ca64 + f181533 commit 0f4db68

File tree

12 files changed

+215
-20
lines changed

12 files changed

+215
-20
lines changed

.circleci/config.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ workflows:
149149
context : org-global
150150
filters:
151151
branches:
152-
only: ['develop', 'migration-setup']
152+
only: ['develop', 'migration-setup', 'pm-1168']
153153
- deployProd:
154154
context : org-global
155155
filters:

docs/swagger.yaml

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,37 @@ paths:
560560
description: "Internal Server Error"
561561
schema:
562562
$ref: "#/definitions/ErrorModel"
563+
"/projects/copilots/opportunity/{copilotOpportunityId}/assign":
564+
post:
565+
tags:
566+
- assign project copilot opportunity
567+
operationId: assignCopilotOpportunity
568+
security:
569+
- Bearer: []
570+
description: "Assign a copilot opportunity with copilot."
571+
parameters:
572+
- $ref: "#/parameters/copilotOpportunityIdParam"
573+
- in: body
574+
name: body
575+
schema:
576+
$ref: "#/definitions/AssignCopilotOpportunity"
577+
responses:
578+
"200":
579+
description: "The response after assigning an copilot opportunity"
580+
schema:
581+
$ref: "#/definitions/CopilotOpportunityAssignResponse"
582+
"401":
583+
description: "Unauthorized"
584+
schema:
585+
$ref: "#/definitions/ErrorModel"
586+
"403":
587+
description: "Forbidden - User does not have permission"
588+
schema:
589+
$ref: "#/definitions/ErrorModel"
590+
"500":
591+
description: "Internal Server Error"
592+
schema:
593+
$ref: "#/definitions/ErrorModel"
563594
"/projects/{projectId}/attachments":
564595
get:
565596
tags:
@@ -6081,6 +6112,13 @@ definitions:
60816112
notes:
60826113
description: notes regarding the application
60836114
type: string
6115+
status:
6116+
description: status of the application
6117+
type: string
6118+
enum:
6119+
- pending
6120+
- accepted
6121+
example: pending
60846122
opportunityId:
60856123
description: copilot request id
60866124
type: integer
@@ -6111,6 +6149,13 @@ definitions:
61116149
format: int64
61126150
description: READ-ONLY. User that deleted this task
61136151
readOnly: true
6152+
CopilotOpportunityAssignResponse:
6153+
type: object
6154+
properties:
6155+
id:
6156+
description: unique identifier
6157+
type: integer
6158+
format: int64
61146159
Project:
61156160
type: object
61166161
properties:
@@ -6321,12 +6366,19 @@ definitions:
63216366
- manager
63226367
- copilot
63236368
ApplyCopilotOpportunity:
6324-
title: Apply copilot CopilotOpportunity
6369+
title: Apply Copilot Opportunity
63256370
type: object
63266371
properties:
63276372
notes:
63286373
type: string
63296374
description: notes about applying copilot opportunity
6375+
AssignCopilotOpportunity:
6376+
title: Assign Copilot Opportunity
6377+
type: object
6378+
properties:
6379+
applicationId:
6380+
type: string
6381+
description: The ID of the application to be accepted for the copilot opportunity.
63306382
NewProjectAttachment:
63316383
title: Project attachment request
63326384
type: object

migrations/umzug/migrations/20250411182312-copilot_opportunity_apply.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
'use strict';
1+
22

33
module.exports = {
44
up: async (queryInterface, Sequelize) => {
@@ -56,5 +56,5 @@ module.exports = {
5656

5757
down: async (queryInterface) => {
5858
await queryInterface.dropTable('copilot_applications');
59-
}
59+
},
6060
};
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
module.exports = {
2+
up: async (queryInterface, Sequelize) => {
3+
await queryInterface.addColumn('copilot_applications', 'status', {
4+
type: Sequelize.STRING(16),
5+
allowNull: true,
6+
});
7+
8+
await queryInterface.sequelize.query(
9+
'UPDATE copilot_applications SET status = \'pending\' WHERE status IS NULL',
10+
);
11+
12+
await queryInterface.changeColumn('copilot_applications', 'status', {
13+
type: Sequelize.STRING(16),
14+
allowNull: false,
15+
});
16+
},
17+
18+
down: async (queryInterface) => {
19+
await queryInterface.removeColumn('copilot_applications', 'status');
20+
},
21+
};

src/constants.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ export const COPILOT_REQUEST_STATUS = {
1818
FULFILLED: 'fulfiled',
1919
};
2020

21+
export const COPILOT_APPLICATION_STATUS = {
22+
PENDING: 'pending',
23+
ACCEPTED: 'accepted',
24+
};
25+
2126
export const COPILOT_OPPORTUNITY_STATUS = {
2227
ACTIVE: 'active',
2328
COMPLETED: 'completed',

src/models/copilotApplication.js

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import _ from 'lodash';
2+
import { COPILOT_APPLICATION_STATUS } from '../constants';
23

34
module.exports = function defineCopilotOpportunity(sequelize, DataTypes) {
45
const CopilotApplication = sequelize.define('CopilotApplication', {
@@ -8,14 +9,22 @@ module.exports = function defineCopilotOpportunity(sequelize, DataTypes) {
89
allowNull: false,
910
references: {
1011
model: 'copilot_opportunities',
11-
key: 'id'
12+
key: 'id',
1213
},
1314
onUpdate: 'CASCADE',
14-
onDelete: 'CASCADE'
15+
onDelete: 'CASCADE',
1516
},
1617
notes: {
1718
type: DataTypes.TEXT,
18-
allowNull: true
19+
allowNull: true,
20+
},
21+
status: {
22+
type: DataTypes.STRING(16),
23+
defaultValue: 'pending',
24+
validate: {
25+
isIn: [_.values(COPILOT_APPLICATION_STATUS)],
26+
},
27+
allowNull: false,
1928
},
2029
userId: { type: DataTypes.BIGINT, allowNull: false },
2130
deletedAt: { type: DataTypes.DATE, allowNull: true },

src/permissions/constants.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,18 @@ export const PERMISSION = { // eslint-disable-line import/prefer-default-export
276276
],
277277
scopes: SCOPES_PROJECTS_WRITE,
278278
},
279+
ASSIGN_COPILOT_OPPORTUNITY: {
280+
meta: {
281+
title: 'Assign copilot to opportunity',
282+
group: 'Assign Copilot',
283+
description: 'Who can assign for copilot opportunity.',
284+
},
285+
topcoderRoles: [
286+
USER_ROLE.PROJECT_MANAGER,
287+
USER_ROLE.TOPCODER_ADMIN,
288+
],
289+
scopes: SCOPES_PROJECTS_WRITE,
290+
},
279291

280292
LIST_COPILOT_OPPORTUNITY: {
281293
meta: {
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import _ from 'lodash';
2+
import validate from 'express-validation';
3+
import Joi from 'joi';
4+
5+
import models from '../../models';
6+
import util from '../../util';
7+
import { PERMISSION } from '../../permissions/constants';
8+
import { COPILOT_APPLICATION_STATUS, COPILOT_OPPORTUNITY_STATUS, COPILOT_REQUEST_STATUS } from '../../constants';
9+
10+
const assignCopilotOpportunityValidations = {
11+
body: Joi.object().keys({
12+
applicationId: Joi.string(),
13+
}),
14+
};
15+
16+
module.exports = [
17+
validate(assignCopilotOpportunityValidations),
18+
async (req, res, next) => {
19+
const { applicationId } = req.body;
20+
const copilotOpportunityId = _.parseInt(req.params.id);
21+
if (!util.hasPermissionByReq(PERMISSION.ASSIGN_COPILOT_OPPORTUNITY, req)) {
22+
const err = new Error('Unable to assign copilot opportunity');
23+
_.assign(err, {
24+
details: JSON.stringify({ message: 'You do not have permission to assign a copilot opportunity' }),
25+
status: 403,
26+
});
27+
return next(err);
28+
}
29+
30+
return models.sequelize.transaction(async (t) => {
31+
const opportunity = await models.CopilotOpportunity.findOne({
32+
where: { id: copilotOpportunityId },
33+
transaction: t,
34+
});
35+
36+
if (!opportunity) {
37+
const err = new Error('No opportunity found');
38+
err.status = 404;
39+
throw err;
40+
}
41+
42+
if (opportunity.status !== COPILOT_OPPORTUNITY_STATUS.ACTIVE) {
43+
const err = new Error('Opportunity is not active');
44+
err.status = 400;
45+
throw err;
46+
}
47+
48+
const application = await models.CopilotApplication.findOne({
49+
where: { id: applicationId },
50+
transaction: t,
51+
});
52+
53+
if (!application) {
54+
const err = new Error('No such application available');
55+
err.status = 400;
56+
throw err;
57+
}
58+
59+
if (application.status === COPILOT_APPLICATION_STATUS.ACCEPTED) {
60+
const err = new Error('Application already accepted');
61+
err.status = 400;
62+
throw err;
63+
}
64+
65+
const projectId = opportunity.projectId;
66+
const userId = application.userId;
67+
const activeMembers = await models.ProjectMember.getActiveProjectMembers(projectId);
68+
69+
const existingUser = activeMembers.find(item => item.userId === userId);
70+
if (existingUser && existingUser.role === 'copilot') {
71+
const err = new Error(`User is already a copilot of this project`);
72+
err.status = 400;
73+
throw err;
74+
}
75+
76+
await models.CopilotRequest.update(
77+
{ status: COPILOT_REQUEST_STATUS.FULFILLED },
78+
{ where: { id: opportunity.copilotRequestId }, transaction: t },
79+
);
80+
81+
await opportunity.update(
82+
{ status: COPILOT_OPPORTUNITY_STATUS.COMPLETED },
83+
{ transaction: t },
84+
);
85+
86+
await models.CopilotApplication.update(
87+
{ status: COPILOT_APPLICATION_STATUS.ACCEPTED },
88+
{ where: { id: applicationId }, transaction: t },
89+
);
90+
91+
res.status(200).send({ id: applicationId });
92+
}).catch(err => next(err));
93+
},
94+
];

src/routes/copilotOpportunityApply/create.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,16 +63,16 @@ module.exports = [
6363
res.status(200).json(existingApplication);
6464
return Promise.resolve();
6565
}
66-
66+
6767
return models.CopilotApplication.create(data)
6868
.then((result) => {
6969
res.status(201).json(result);
7070
return Promise.resolve();
7171
})
7272
.catch((err) => {
73-
util.handleError('Error creating copilot application', err, req, next);
74-
return next(err);
75-
});
73+
util.handleError('Error creating copilot application', err, req, next);
74+
return next(err);
75+
});
7676
}).catch((e) => {
7777
util.handleError('Error applying for copilot opportunity', e, req, next);
7878
});

src/routes/copilotOpportunityApply/list.js

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ const permissions = tcMiddleware.permissions;
1010
module.exports = [
1111
permissions('copilotApplications.view'),
1212
(req, res, next) => {
13-
1413
const canAccessAllApplications = util.hasRoles(req, ADMIN_ROLES) || util.hasProjectManagerRole(req);
1514
const userId = req.authUser.userId;
1615
const opportunityId = _.parseInt(req.params.id);
@@ -29,7 +28,7 @@ module.exports = [
2928
const whereCondition = _.assign({
3029
opportunityId,
3130
},
32-
canAccessAllApplications ? {} : { createdBy: userId },
31+
canAccessAllApplications ? {} : { createdBy: userId },
3332
);
3433

3534
return models.CopilotApplication.findAll({

0 commit comments

Comments
 (0)