diff --git a/.circleci/config.yml b/.circleci/config.yml index 4e6f3414..1acd4a4c 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,7 +1,7 @@ version: 2.1 python_env: &python_env docker: - - image: cimg/python:3.11.7-browsers + - image: cimg/python:3.11.11-browsers install_awscli: &install_awscli name: "Install awscli" command: | @@ -32,13 +32,13 @@ deploy_steps: &deploy_steps source awsenvconf ./buildenv.sh -e $DEPLOY_ENV -b ${LOGICAL_ENV}-${APPNAME}-deployvar source buildenvvar - ./master_deploy.sh -d ECS -e $DEPLOY_ENV -t latest -s ${LOGICAL_ENV}-global-appvar,${LOGICAL_ENV}-${APPNAME}-appvar -i ${APPNAME} + ./master_deploy.sh -d ECS -e $DEPLOY_ENV -t latest -s ${LOGICAL_ENV}-global-appvar,${LOGICAL_ENV}-${APPNAME}-appvar -i ${APPNAME} -p FARGATE echo "======= Running Masterscript - deploy projects-api-consumers ===========" if [ -e ${LOGICAL_ENV}-${APPNAME}-appvar.json ]; then sudo rm -vf ${LOGICAL_ENV}-${APPNAME}-appvar.json; fi ./buildenv.sh -e $DEPLOY_ENV -b ${LOGICAL_ENV}-${APPNAME}-consumers-deployvar source buildenvvar - ./master_deploy.sh -d ECS -e $DEPLOY_ENV -t latest -s ${LOGICAL_ENV}-global-appvar,${LOGICAL_ENV}-${APPNAME}-appvar -i ${APPNAME} + ./master_deploy.sh -d ECS -e $DEPLOY_ENV -t latest -s ${LOGICAL_ENV}-global-appvar,${LOGICAL_ENV}-${APPNAME}-appvar -i ${APPNAME} -p FARGATE jobs: UnitTests: @@ -149,7 +149,7 @@ workflows: context : org-global filters: branches: - only: ['develop', 'connect-performance-testing', 'feature/new-milestone-concept', 'permission_fixes'] + only: ['develop', 'migration-setup'] - deployProd: context : org-global filters: diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 76edf443..42173766 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -24,7 +24,7 @@ securityDefinitions: type: apiKey name: Authorization in: header -paths: +paths: "/projects": get: tags: @@ -244,6 +244,38 @@ paths: description: Internal Server Error schema: $ref: "#/definitions/ErrorModel" + "/projects/{projectId}/copilots/request": + post: + tags: + - projects copilot request + operationId: createCopilotRequest + security: + - Bearer: [] + description: "create copilot request" + responses: + "200": + description: Created copilot request + schema: + $ref: "#/definitions/Copilot" + "401": + description: Unauthorized + schema: + $ref: "#/definitions/ErrorModel" + "400": + description: Bad request + schema: + $ref: "#/definitions/ErrorModel" + "500": + description: Internal Server Error + schema: + $ref: "#/definitions/ErrorModel" + parameters: + - $ref: "#/parameters/projectIdParam" + - name: body + in: body + required: true + schema: + $ref: "#/definitions/NewCopilotRequest" "/projects/{projectId}/attachments": get: tags: @@ -5459,6 +5491,14 @@ definitions: type: string address: type: string + NewCopilotRequest: + type: object + required: + - data + properties: + data: + type: object + description: copilot request data NewProject: type: object required: @@ -5581,6 +5621,37 @@ definitions: type: integer format: int64 + Copilot: + type: object + properties: + id: + description: unique identifier + type: integer + format: int64 + data: + description: copilot data + type: object + status: + description: status of the copilot request + type: string + createdAt: + type: string + description: Datetime (GMT) when task was created + readOnly: true + createdBy: + type: integer + format: int64 + description: READ-ONLY. User who created this task + readOnly: true + updatedAt: + type: string + description: READ-ONLY. Datetime (GMT) when task was updated + readOnly: true + updatedBy: + type: integer + format: int64 + description: READ-ONLY. User that last updated this task + readOnly: true Project: type: object properties: diff --git a/migrations/20212902_copilot_requests.sql b/migrations/20212902_copilot_requests.sql new file mode 100644 index 00000000..654ac0a9 --- /dev/null +++ b/migrations/20212902_copilot_requests.sql @@ -0,0 +1,34 @@ +-- +-- CREATE NEW TABLE: +-- copilot_requests +-- +CREATE TABLE copilot_requests ( + id bigint NOT NULL, + "data" json NOT NULL, + "status" character varying(16) NOT NULL, + "projectId" bigint NOT NULL, + "deletedAt" timestamp with time zone, + "createdAt" timestamp with time zone, + "updatedAt" timestamp with time zone, + "deletedBy" bigint, + "createdBy" bigint NOT NULL, + "updatedBy" bigint NOT NULL +); + +CREATE SEQUENCE copilot_requests_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE copilot_requests_id_seq OWNED BY copilot_requests.id; + +ALTER TABLE copilot_requests + ALTER COLUMN id SET DEFAULT nextval('copilot_requests_id_seq'); + +ALTER TABLE ONLY copilot_requests + ADD CONSTRAINT "copilot_requests_pkey" PRIMARY KEY (id); + +ALTER TABLE ONLY copilot_requests + ADD CONSTRAINT "copilot_requests_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES projects(id) ON UPDATE CASCADE ON DELETE SET NULL; \ No newline at end of file diff --git a/migrations/umzug/README.md b/migrations/umzug/README.md new file mode 100644 index 00000000..faca1dfd --- /dev/null +++ b/migrations/umzug/README.md @@ -0,0 +1,89 @@ +# Migration Guide + +This project uses **Sequelize** with **Umzug** for managing database migrations. + +## **📌 How to Add a New Migration** + +1. **Generate a new migration file** + + cd into `migrations/umzug/` directory and run: + + ```sh + npx sequelize-cli migration:generate --name your_migration_name + ``` + + This will create a new migration file inside `umzug/migrations/`. + +2. **Modify the generated migration file** + + - Open the file inside `umzug/migrations/`. + - Define the required table changes inside the `up` method. + - Define how to revert the changes in the `down` method. + + **Example:** Creating a `users` table + + ```javascript + module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.createTable("users", { + id: { + type: Sequelize.BIGINT, + allowNull: false, + primaryKey: true, + autoIncrement: true, + }, + name: { + type: Sequelize.STRING, + allowNull: false, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: true, + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: true, + } + }); + }, + down: async (queryInterface, Sequelize) => { + await queryInterface.dropTable("users"); + } + }; + ``` + +3. **Test Migrations** + + ```sh + npm run migrate + ``` + + This will apply all pending migrations. + +4. **Rollback Migrations (If Needed)** + + ```sh + npm run migrate:down + ``` + + This will revert the last applied migration. + +5. **Revert All Migrations (If Needed)** + + If you need to revert all applied migrations, run: + + ```sh + npm run migrate:reset + ``` + +This will undo all migrations in reverse order. + +--- + +## **📌 How Migrations Work in This Project** + +- All migration files are stored in `umzug/migrations/`. +- The migration runner is inside `umzug/index.js`. +- After installing dependencies (`npm install`), migrations will **automatically run** via `postinstall`. + +--- diff --git a/migrations/umzug/index.js b/migrations/umzug/index.js new file mode 100644 index 00000000..0d7d2593 --- /dev/null +++ b/migrations/umzug/index.js @@ -0,0 +1,45 @@ +const config = require('config'); +const { Sequelize } = require('sequelize'); +const { Umzug, SequelizeStorage } = require('umzug'); + +// Initialize Sequelize +const sequelize = new Sequelize(config.get('dbConfig.masterUrl'), { + dialect: 'postgres', +}); + +console.log('Umzug migration script:', __dirname); + +// Initialize Umzug +const umzug = new Umzug({ + migrations: { + glob: 'migrations/umzug/migrations/*.js', + resolve: ({ name, path, context }) => { + console.log('Loading migration:', name, path); + const migration = require(path); + return { + name, + up: async () => migration.up(context, Sequelize), + down: async () => migration.down(context, Sequelize), + }; + }, + }, + context: sequelize.getQueryInterface(), + storage: new SequelizeStorage({ sequelize }), + logger: console, +}); + +// Run migrations +if (require.main === module) { + umzug + .up() + .then(() => { + console.log('Migrations executed successfully'); + process.exit(0); + }) + .catch((err) => { + console.error('Migration failed', err); + process.exit(1); + }); +} + +module.exports = umzug; diff --git a/migrations/umzug/migrations/20250130094419-create-copilot-requests.js b/migrations/umzug/migrations/20250130094419-create-copilot-requests.js new file mode 100644 index 00000000..72bc7e77 --- /dev/null +++ b/migrations/umzug/migrations/20250130094419-create-copilot-requests.js @@ -0,0 +1,60 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.createTable("copilot_requests", { + id: { + type: Sequelize.BIGINT, + allowNull: false, + primaryKey: true, + autoIncrement: true, + }, + data: { + type: Sequelize.JSON, + allowNull: false, + }, + status: { + type: Sequelize.STRING(16), + allowNull: false, + }, + projectId: { + type: Sequelize.BIGINT, + allowNull: false, + references: { + model: "projects", + key: "id", + }, + onUpdate: "CASCADE", + onDelete: "SET NULL", + }, + deletedAt: { + type: Sequelize.DATE, + allowNull: true, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: true, + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: true, + }, + deletedBy: { + type: Sequelize.BIGINT, + allowNull: true, + }, + createdBy: { + type: Sequelize.BIGINT, + allowNull: false, + }, + updatedBy: { + type: Sequelize.BIGINT, + allowNull: false, + }, + }); + }, + + down: async (queryInterface, Sequelize) => { + await queryInterface.dropTable("copilot_requests"); + } +}; diff --git a/package.json b/package.json index 6e0fcd59..6a27dfe2 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "lint": "eslint .", "lint:fix": "eslint . --fix || true", "build": "babel src -d dist --presets es2015 --copy-files", - "start": "node dist", + "start": "npm run migrate && node dist", "start:dev": "cross-env NODE_ENV=development PORT=3000 nodemon -w src --exec \"./node_modules/.bin/env-cmd npm run babel-node-script -- src\" | bunyan", "startKafkaConsumers": "node dist/index-kafka.js", "startKafkaConsumers:dev": "cross-env NODE_ENV=development nodemon -w src --exec \"./node_modules/.bin/env-cmd npm run babel-node-script src/index-kafka.js\" | bunyan", @@ -18,6 +18,9 @@ "test:watch": "cross-env NODE_ENV=test mocha -w --require babel-core/register --require ./src/tests \"./src/**/*.spec.js*\" ", "reset:db": "npm run babel-node-script -- migrations/sync.js", "reset:es": "npm run babel-node-script -- migrations/elasticsearch_sync.js", + "migrate": "node migrations/umzug/index.js", + "migrate:down": "node migrations/umzug/index.js down", + "migrate:reset": "node migrations/umzug/index.js down --all", "import-from-api": "env-cmd npm run babel-node-script -- scripts/import-from-api", "es-db-compare": "env-cmd npm run babel-node-script -- scripts/es-db-compare", "data:export": "cross-env NODE_ENV=development LOG_LEVEL=info env-cmd npm run babel-node-script -- scripts/data/export", @@ -42,6 +45,7 @@ }, "homepage": "https://github.com/appirio-tech/tc-projects-service#readme", "dependencies": { + "ajv": "^8.17.1", "analytics-node": "^2.1.1", "app-module-path": "^1.0.7", "aws-sdk": "^2.610.0", @@ -76,6 +80,7 @@ "swagger-ui-express": "^4.0.6", "tc-core-library-js": "github:appirio-tech/tc-core-library-js#v2.6.6", "traverse": "^0.6.6", + "umzug": "^3.8.2", "urlencode": "^1.1.0", "yamljs": "^0.3.0" }, diff --git a/src/constants.js b/src/constants.js index ac73a35b..3541aba7 100644 --- a/src/constants.js +++ b/src/constants.js @@ -9,6 +9,15 @@ export const PROJECT_STATUS = { CANCELLED: 'cancelled', }; +export const COPILOT_REQUEST_STATUS = { + NEW: 'new', + APPROVED: 'approved', + REJECTED: 'rejected', + SEEKING: 'seeking', + CANCELED: 'canceled', + FULFILLED: 'fulfiled', +}; + export const WORKSTREAM_STATUS = { DRAFT: 'draft', REVIEWED: 'reviewed', diff --git a/src/models/copilotRequest.js b/src/models/copilotRequest.js new file mode 100644 index 00000000..6be712e0 --- /dev/null +++ b/src/models/copilotRequest.js @@ -0,0 +1,34 @@ +import _ from 'lodash'; +import { COPILOT_REQUEST_STATUS } from '../constants'; + +module.exports = function defineCopilotRequest(sequelize, DataTypes) { + const CopliotRequest = sequelize.define('CopilotRequest', { + id: { type: DataTypes.BIGINT, primaryKey: true, autoIncrement: true }, + status: { + type: DataTypes.STRING(16), + defaultValue: 'new', + allowNull: false, + validate: { + isIn: [_.values(COPILOT_REQUEST_STATUS)], + } + }, + data: { type: DataTypes.JSON, defaultValue: {}, allowNull: false }, + + deletedAt: { type: DataTypes.DATE, allowNull: true }, + createdAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + updatedAt: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, + deletedBy: { type: DataTypes.INTEGER, allowNull: true }, + createdBy: { type: DataTypes.INTEGER, allowNull: false }, + updatedBy: { type: DataTypes.INTEGER, allowNull: false }, + }, { + tableName: 'copilot_requests', + paranoid: true, + timestamps: true, + updatedAt: 'updatedAt', + createdAt: 'createdAt', + deletedAt: 'deletedAt', + indexes: [], + }); + + return CopliotRequest; +}; diff --git a/src/models/project.js b/src/models/project.js index b9191dcd..fac8ef18 100644 --- a/src/models/project.js +++ b/src/models/project.js @@ -74,6 +74,7 @@ module.exports = function defineProject(sequelize, DataTypes) { Project.hasMany(models.ProjectMemberInvite, { as: 'invites', foreignKey: 'projectId' }); Project.hasMany(models.ScopeChangeRequest, { as: 'scopeChangeRequests', foreignKey: 'projectId' }); Project.hasMany(models.WorkStream, { as: 'workStreams', foreignKey: 'projectId' }); + Project.hasMany(models.CopilotRequest, { as: 'copilotRequests', foreignKey: 'projectId' }); }; /** diff --git a/src/permissions/constants.js b/src/permissions/constants.js index 312a2d42..0957daa2 100644 --- a/src/permissions/constants.js +++ b/src/permissions/constants.js @@ -250,6 +250,19 @@ export const PERMISSION = { // eslint-disable-line import/prefer-default-export scopes: SCOPES_PROJECTS_WRITE, }, + MANAGE_COPILOT_REQUEST: { + meta: { + title: 'Manage Copilot Request', + group: 'Copilot Request', + description: 'Who can create, update, delete copilot request.', + }, + topcoderRoles: [ + USER_ROLE.PROJECT_MANAGER, + USER_ROLE.TOPCODER_ADMIN, + ], + scopes: SCOPES_PROJECTS_WRITE, + }, + MANAGE_PROJECT_BILLING_ACCOUNT_ID: { meta: { title: 'Manage Project property "billingAccountId"', diff --git a/src/routes/copilotRequest/create.js b/src/routes/copilotRequest/create.js new file mode 100644 index 00000000..ca4dcac0 --- /dev/null +++ b/src/routes/copilotRequest/create.js @@ -0,0 +1,76 @@ +import validate from 'express-validation'; +import _ from 'lodash'; +import Joi from 'joi'; + +import models from '../../models'; +import util from '../../util'; +import { COPILOT_REQUEST_STATUS } from '../../constants'; +import { PERMISSION } from '../../permissions/constants'; +import { Op } from 'sequelize'; + +const addCopilotRequestValidations = { + body: Joi.object().keys({ + data: Joi.object().required(), + }), +}; + +module.exports = [ + validate(addCopilotRequestValidations), + (req, res, next) => { + const data = req.body; + if(!util.hasPermissionByReq(PERMISSION.MANAGE_COPILOT_REQUEST, req)) { + const err = new Error('Unable to create copilot request'); + _.assign(err, { + details: JSON.stringify({ message: 'You do not have permission to create copilot request' }), + status: 403, + }); + return Promise.reject(err); + } + // default values + const projectId = _.parseInt(req.params.projectId); + _.assign(data, { + projectId, + status: COPILOT_REQUEST_STATUS.NEW, + createdBy: req.authUser.userId, + updatedBy: req.authUser.userId, + }); + + models.sequelize.transaction((transaction) => { + req.log.debug('Create Copilot request transaction', data); + return models.Project.findOne({ + where: { id: projectId, deletedAt: { $eq: null } }, + }) + .then((existingProject) => { + if (!existingProject) { + const err = new Error(`active project not found for project id ${projectId}`); + err.status = 404; + throw err; + } + return models.CopilotRequest.findOne({ + where: { + createdBy: req.authUser.userId, + projectId: projectId, + status: { + [Op.in] : [COPILOT_REQUEST_STATUS.NEW, COPILOT_REQUEST_STATUS.APPROVED, COPILOT_REQUEST_STATUS.SEEKING], + } + }, + }).then((existingCopilotRequest) => { + if (existingCopilotRequest) { + return res.status(200).json(existingCopilotRequest); + } + return models.CopilotRequest + .create(data, { transaction }) + .then((_newCopilotRequest) => { + return res.status(201).json(_newCopilotRequest); + }); + }) + }) + }) + .catch((err) => { + if (err.message) { + _.assign(err, { details: err.message }); + } + util.handleError('Error creating copilot request', err, req, next); + }); + }, +]; diff --git a/src/routes/index.js b/src/routes/index.js index 33b7c902..be5ef802 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -28,7 +28,7 @@ router.get(`/${apiVersion}/projects/health`, (req, res) => { const jwtAuth = require('tc-core-library-js').middleware.jwtAuthenticator; router.all( - RegExp(`\\/${apiVersion}\\/(projects|timelines|orgConfig|customer-payments)(?!\\/health).*`), (req, res, next) => ( + RegExp(`\\/${apiVersion}\\/(copilots|projects|timelines|orgConfig|customer-payments)(?!\\/health).*`), (req, res, next) => ( // JWT authentication jwtAuth(config)(req, res, next) ), @@ -378,6 +378,10 @@ router.route('/v5/projects/:projectId(\\d+)/settings') .get(require('./projectSettings/list')) .post(require('./projectSettings/create')); +// Project Copilot Request +router.route('/v5/projects/:projectId(\\d+)/copilots/request') + .post(require('./copilotRequest/create')); + // Project Estimation Items router.route('/v5/projects/:projectId(\\d+)/estimations/:estimationId(\\d+)/items') .get(require('./projectEstimationItems/list'));