diff --git a/migrations/20190526_project_estimation.sql b/migrations/20190526_project_estimation.sql new file mode 100644 index 00000000..11b3a725 --- /dev/null +++ b/migrations/20190526_project_estimation.sql @@ -0,0 +1,35 @@ +-- +-- CREATE NEW TABLE: +-- project_estimations +-- + +CREATE TABLE project_estimations +( + id bigint NOT NULL, + "buildingBlockKey" character varying(255) NOT NULL, + conditions character varying(512) NOT NULL, + price double precision NOT NULL, + "minTime" integer NOT NULL, + "maxTime" integer NOT NULL, + metadata json NOT NULL DEFAULT '{}'::json, + "projectId" bigint NOT NULL, + "deletedAt" timestamp with time zone, + "createdAt" timestamp with time zone, + "updatedAt" timestamp with time zone, + "deletedBy" bigint, + "createdBy" integer NOT NULL, + "updatedBy" integer NOT NULL, + CONSTRAINT project_estimations_pkey PRIMARY KEY (id) +); + +CREATE SEQUENCE project_estimations_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE project_estimations_id_seq OWNED BY project_estimations.id; + +ALTER TABLE project_estimations + ALTER COLUMN id SET DEFAULT nextval('project_estimations_id_seq'); diff --git a/src/models/projectEstimation.js b/src/models/projectEstimation.js new file mode 100644 index 00000000..241db456 --- /dev/null +++ b/src/models/projectEstimation.js @@ -0,0 +1,32 @@ +module.exports = function defineProjectHistory(sequelize, DataTypes) { + const ProjectEstimation = sequelize.define( + 'ProjectEstimation', + { + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + buildingBlockKey: { type: DataTypes.STRING, allowNull: false }, + conditions: { type: DataTypes.STRING, allowNull: false }, + price: { type: DataTypes.DOUBLE, allowNull: false }, + minTime: { type: DataTypes.INTEGER, allowNull: false }, + maxTime: { type: DataTypes.INTEGER, allowNull: false }, + metadata: { type: DataTypes.JSON, allowNull: false, defaultValue: {} }, + projectId: { type: DataTypes.BIGINT, allowNull: false }, + + deletedAt: { type: DataTypes.DATE, allowNull: true }, + createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + deletedBy: DataTypes.BIGINT, + createdBy: { type: DataTypes.INTEGER, allowNull: false }, + updatedBy: { type: DataTypes.INTEGER, allowNull: false }, + }, + { + tableName: 'project_estimations', + paranoid: true, + timestamps: true, + updatedAt: 'updatedAt', + createdAt: 'createdAt', + indexes: [], + }, + ); + + return ProjectEstimation; +}; diff --git a/src/routes/projects/create.js b/src/routes/projects/create.js index 61a9e299..a9235c01 100644 --- a/src/routes/projects/create.js +++ b/src/routes/projects/create.js @@ -57,6 +57,14 @@ const createProjectValdiations = { })).allow(null), templateId: Joi.number().integer().positive(), version: Joi.string(), + estimation: Joi.array().items(Joi.object().keys({ + conditions: Joi.string().required(), + price: Joi.number().required(), + minTime: Joi.number().integer().required(), + maxTime: Joi.number().integer().required(), + buildingBlockKey: Joi.string().required(), + metadata: Joi.object().optional(), + })).optional(), }).required(), }, }; @@ -81,6 +89,17 @@ function createProjectAndPhases(req, project, projectTemplate, productTemplates) model: models.ProjectMember, as: 'members', }], + }).then((newProject) => { + if (project.estimation && (project.estimation.length > 0)) { + req.log.debug('creating project estimation'); + const estimations = project.estimation.map(estimation => Object.assign({ + projectId: newProject.id, + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + }, estimation)); + return models.ProjectEstimation.bulkCreate(estimations).then(() => Promise.resolve(newProject)); + } + return Promise.resolve(newProject); }).then((newProject) => { result.newProject = newProject; @@ -212,7 +231,10 @@ module.exports = [ utm: null, }); traverse(project).forEach(function (x) { // eslint-disable-line func-names - if (this.isLeaf && typeof x === 'string') this.update(req.sanitize(x)); + // keep the raw '&&' string in conditions string in estimation + const isEstimationCondition = + (this.path.length === 3) && (this.path[0] === 'estimation') && (this.key === 'conditions'); + if (this.isLeaf && typeof x === 'string' && (!isEstimationCondition)) this.update(req.sanitize(x)); }); // override values _.assign(project, { diff --git a/src/routes/projects/create.spec.js b/src/routes/projects/create.spec.js index e8807e38..ef057d35 100644 --- a/src/routes/projects/create.spec.js +++ b/src/routes/projects/create.spec.js @@ -262,6 +262,23 @@ describe('Project create', () => { .expect(422, done); }); + it('should return 422 with wrong format estimation field', (done) => { + const invalidBody = _.cloneDeep(body); + invalidBody.param.estimation = [ + { + + }, + ]; + request(server) + .post('/v4/projects') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(invalidBody) + .expect('Content-Type', /json/) + .expect(422, done); + }); + it('should return 201 if error to create direct project', (done) => { const validBody = _.cloneDeep(body); validBody.param.templateId = 3; @@ -469,6 +486,143 @@ describe('Project create', () => { }); }); + it('should return 201 if valid user and data (with estimation)', (done) => { + const validBody = _.cloneDeep(body); + validBody.param.estimation = [ + { + conditions: '( HAS_DESIGN_DELIVERABLE && HAS_ZEPLIN_APP_ADDON && CA_NEEDED)', + price: 6, + minTime: 2, + maxTime: 2, + metadata: { + deliverable: 'design', + }, + buildingBlockKey: 'ZEPLIN_APP_ADDON_CA', + }, + { + conditions: '( HAS_DESIGN_DELIVERABLE && COMPREHENSIVE_DESIGN && TWO_TARGET_DEVICES' + + ' && SCREENS_COUNT_SMALL && CA_NEEDED )', + price: 95, + minTime: 14, + maxTime: 14, + metadata: { + deliverable: 'design', + }, + buildingBlockKey: 'SMALL_COMP_DESIGN_TWO_DEVICE_CA', + }, + { + conditions: '( HAS_DEV_DELIVERABLE && (ONLY_ONE_OS_MOBILE || ONLY_ONE_OS_DESKTOP' + + ' || ONLY_ONE_OS_PROGRESSIVE) && SCREENS_COUNT_SMALL && CA_NEEDED)', + price: 50, + minTime: 35, + maxTime: 35, + metadata: { + deliverable: 'dev-qa', + }, + buildingBlockKey: 'SMALL_DEV_ONE_OS_CA', + }, + { + conditions: '( HAS_DEV_DELIVERABLE && HAS_SSO_INTEGRATION_ADDON && CA_NEEDED)', + price: 80, + minTime: 5, + maxTime: 5, + metadata: { + deliverable: 'dev-qa', + }, + buildingBlockKey: 'HAS_SSO_INTEGRATION_ADDON_CA', + }, + { + conditions: '( HAS_DEV_DELIVERABLE && HAS_CHECKMARX_SCANNING_ADDON && CA_NEEDED)', + price: 4, + minTime: 10, + maxTime: 10, + metadata: { + deliverable: 'dev-qa', + }, + buildingBlockKey: 'HAS_CHECKMARX_SCANNING_ADDON_CA', + }, + { + conditions: '( HAS_DEV_DELIVERABLE && HAS_UNIT_TESTING_ADDON && CA_NEEDED)', + price: 90, + minTime: 12, + maxTime: 12, + metadata: { + deliverable: 'dev-qa', + }, + buildingBlockKey: 'HAS_UNIT_TESTING_ADDON_CA', + }, + ]; + validBody.param.templateId = 3; + const mockHttpClient = _.merge(testUtil.mockHttpClient, { + post: () => Promise.resolve({ + status: 200, + data: { + id: 'requesterId', + version: 'v3', + result: { + success: true, + status: 200, + content: { + projectId: 128, + }, + }, + }, + }), + }); + sandbox.stub(util, 'getHttpClient', () => mockHttpClient); + request(server) + .post('/v4/projects') + .set({ + Authorization: `Bearer ${testUtil.jwts.member}`, + }) + .send(validBody) + .expect('Content-Type', /json/) + .expect(201) + .end((err, res) => { + if (err) { + done(err); + } else { + const resJson = res.body.result.content; + should.exist(resJson); + should.exist(resJson.billingAccountId); + should.exist(resJson.name); + resJson.directProjectId.should.be.eql(128); + resJson.status.should.be.eql('draft'); + resJson.type.should.be.eql(body.param.type); + resJson.version.should.be.eql('v3'); + resJson.members.should.have.lengthOf(1); + resJson.members[0].role.should.be.eql('customer'); + resJson.members[0].userId.should.be.eql(40051331); + resJson.members[0].projectId.should.be.eql(resJson.id); + resJson.members[0].isPrimary.should.be.truthy; + resJson.bookmarks.should.have.lengthOf(1); + resJson.bookmarks[0].title.should.be.eql('title1'); + resJson.bookmarks[0].address.should.be.eql('http://www.address.com'); + // Check that activity fields are set + resJson.lastActivityUserId.should.be.eql('40051331'); + resJson.lastActivityAt.should.be.not.null; + server.services.pubsub.publish.calledWith('project.draft-created').should.be.true; + + // Check new ProjectEstimation records are created. + models.ProjectEstimation.findAll({ + where: { + projectId: resJson.id, + }, + }).then((projectEstimations) => { + projectEstimations.length.should.be.eql(6); + projectEstimations[0].conditions.should.be.eql( + '( HAS_DESIGN_DELIVERABLE && HAS_ZEPLIN_APP_ADDON && CA_NEEDED)'); + projectEstimations[0].price.should.be.eql(6); + projectEstimations[0].minTime.should.be.eql(2); + projectEstimations[0].maxTime.should.be.eql(2); + projectEstimations[0].metadata.deliverable.should.be.eql('design'); + projectEstimations[0].buildingBlockKey.should.be.eql('ZEPLIN_APP_ADDON_CA'); + done(); + }); + } + }); + }); + xit('should return 201 if valid user and data (using Bearer userId_)', (done) => { const mockHttpClient = _.merge(testUtil.mockHttpClient, { post: () => Promise.resolve({ diff --git a/swagger.yaml b/swagger.yaml index a8e5aa18..27d458c1 100644 --- a/swagger.yaml +++ b/swagger.yaml @@ -3161,6 +3161,32 @@ definitions: data: type: string description: 300 Char length text blob for customer provided data + estimation: + type: array + items: + type: object + required: + - conditions + - price + - maxTime + - minTime + - buildingBlockKey + properties: + conditions: + type: string + price: + type: number + format: float + maxTime: + type: number + format: integer + minTime: + type: integer + format: integer + metadata: + type: object + buildingBlockKey: + type: string type: type: string description: project type