diff --git a/.env.example b/.env.example index 32b3cdc9a..869d056e4 100644 --- a/.env.example +++ b/.env.example @@ -79,3 +79,4 @@ ES_PASSWORD="duo-password" ES_REFRESH=<"wait_for"|"false"> LOGGERS_CONFIG_FILE="loggers.json" +PROPOSAL_TYPES_FILE="proposalTypes.json" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a757094f9..e564bcaf5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -173,5 +173,6 @@ jobs: run: | cp CI/ESS/docker-compose.api.yaml docker-compose.yaml cp functionalAccounts.json.test functionalAccounts.json + cp proposalTypes.example.json proposalTypes.json docker compose up --build -d npm run test:api diff --git a/.gitignore b/.gitignore index a29dbc032..924c630ca 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /dist /node_modules functionalAccounts.json +proposalTypes.json loggers.json # Configs diff --git a/README.md b/README.md index 1f4c418bb..dd85794f1 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,10 @@ Thank you for your interest in contributing to our project! 3. Add _.env_ file to project root folder. See [Environment variables](#environment-variables). 4. _Optional_ Add [functionalAccounts.json](#local-user-accounts) file to project root folder to create local users. 5. _Optional_ Add [loggers.json](#loggers-configuration) file to the root folder and configure multiple loggers. -6. `npm run start:dev` -7. Go to http://localhost:3000/explorer to get an overview of available endpoints and database schemas. -8. To be able to run the e2e tests with the same setup as in the Github actions you will need to run `npm run prepare:local` and after that run `npm run start:dev`. This will start all needed containers and copy some configuration to the right place. +6. _Optional_ Add [proposalTypes.json](#prpopsal-types-configuration) file to the root folder and configure the proposal types. +7. `npm run start:dev` +8. Go to http://localhost:3000/explorer to get an overview of available endpoints and database schemas. +9. To be able to run the e2e tests with the same setup as in the Github actions you will need to run `npm run prepare:local` and after that run `npm run start:dev`. This will start all needed containers and copy some configuration to the right place. ## Develop in a container using the docker-compose.dev file @@ -50,10 +51,11 @@ Thank you for your interest in contributing to our project! 2. docker-compose -f docker-compose.dev.yaml up -d 3. _Optional_ Mount [functionalAccounts.json](#local-user-accounts) file to a volume in the container to create local users. 4. _Optional_ Mount [loggers.json](#loggers-configuration) file to a volume in the container to configure multiple loggers. -5. _Optional_ change the container env variables -6. Attach to the container -7. `npm run start:dev` -8. Go to http://localhost:3000/explorer to get an overview of available endpoints and database schemas. +5. _Optional_ Mount [proposalTypes.json](#prpopsal-types-configuration) file to a volume in the container to configure the proposal types. +6. _Optional_ change the container env variables +7. Attach to the container +8. `npm run start:dev` +9. Go to http://localhost:3000/explorer to get an overview of available endpoints and database schemas. ## Test the app @@ -96,6 +98,12 @@ Providing a file called _loggers.json_ at the root of the project, locally or in The `loggers.json.example` file in the root directory showcases the example of configuration structure for the one or multiple loggers. `logger.service.ts` file contains the configuration handling process logic, and `src/loggers/loggingProviders/grayLogger.ts` includes actual usecase of grayLogger. +### Prpopsal types configuration + +Providing a file called _proposalTypes.json_ at the root of the project, locally or in the container, will be automatically loaded into the application configuration service under property called `proposalTypes` and used for validation against proposal creation and update. + +The `proposalTypes.json.example` file in the root directory showcases the example of configuration structure for proposal types. + ## Environment variables Valid environment variables for the .env file. See [.env.example](/.env.example) for examples value formats. @@ -176,6 +184,7 @@ Valid environment variables for the .env file. See [.env.example](/.env.example) | `ES_FIELDS_LIMIT` | number | | The total number of fields in an index. | 1000 | | `ES_REFRESH` | string | | If set to `wait_for`, Elasticsearch will wait till data is inserted into the specified index before returning a response. | false | | `LOGGERS_CONFIG_FILE` | string | | The file name for loggers configuration, located in the project root directory. | "loggers.json" | +| `PROPOSAL_TYPES_FILE` | string | | The file name for proposal types configuration, located in the project root directory. | "proposalTypes.json" | | `SWAGGER_PATH` | string | Yes | swaggerPath is the path where the swagger UI will be available| "explorer"| | `MAX_FILE_UPLOAD_SIZE` | string | Yes | Maximum allowed file upload size | "16mb"| diff --git a/migrations/20241113130700-proposal-type.js b/migrations/20241113130700-proposal-type.js new file mode 100644 index 000000000..8cc924519 --- /dev/null +++ b/migrations/20241113130700-proposal-type.js @@ -0,0 +1,12 @@ +module.exports = { + async up(db, client) { + db.collection("Proposal").updateMany( + { type: { $exists: false } }, + { $set: { type: "Default Proposal" } }, + ); + }, + + async down(db, client) { + db.collection("Proposal").updateMany({}, { $unset: { type: 1 } }); + }, +}; diff --git a/package.json b/package.json index 6b64c6d86..c8265d49f 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "test:api": "npm run test:api:jest --maxWorkers=50% && concurrently -k -s first \"wait-on http://localhost:3000/explorer/ && npm run test:api:mocha\" \"npm run start\"", "test:api:jest": "jest --config ./test/config/jest-e2e.json --maxWorkers=50%", "test:api:mocha": "mocha --config ./test/config/.mocharc.json -r chai/register-should.js", - "prepare:local": "docker-compose -f CI/E2E/docker-compose-local.yaml --env-file CI/E2E/.env.elastic-search up -d && cp functionalAccounts.json.test functionalAccounts.json" + "prepare:local": "docker-compose -f CI/E2E/docker-compose-local.yaml --env-file CI/E2E/.env.elastic-search up -d && cp functionalAccounts.json.test functionalAccounts.json && cp proposalTypes.example.json proposalTypes.json" }, "dependencies": { "@casl/ability": "^6.3.2", diff --git a/proposalTypes.example.json b/proposalTypes.example.json new file mode 100644 index 000000000..912c44040 --- /dev/null +++ b/proposalTypes.example.json @@ -0,0 +1,4 @@ +{ + "DOORProposal": "DOOR Proposal", + "Beamtime": "Beamtime" +} diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 1fc02fbdc..971a2205a 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -2,6 +2,7 @@ import * as fs from "fs"; import { merge } from "lodash"; import localconfiguration from "./localconfiguration"; import { boolean } from "mathjs"; +import { DEFAULT_PROPOSAL_TYPE } from "src/proposals/schemas/proposal.schema"; const configuration = () => { const accessGroupsStaticValues = @@ -38,9 +39,12 @@ const configuration = () => { modulePath: "./loggingProviders/defaultLogger", config: {}, }; - const jsonConfigMap: { [key: string]: object[] | boolean } = {}; + const jsonConfigMap: { [key: string]: object | object[] | boolean } = { + proposalTypes: {}, + }; const jsonConfigFileList: { [key: string]: string } = { loggers: process.env.LOGGERS_CONFIG_FILE || "loggers.json", + proposalTypes: process.env.PROPOSAL_TYPES_FILE || "proposalTypes.json", }; Object.keys(jsonConfigFileList).forEach((key) => { const filePath = jsonConfigFileList[key]; @@ -57,6 +61,11 @@ const configuration = () => { } }); + // NOTE: Add the default proposal type here + Object.assign(jsonConfigMap.proposalTypes, { + DefaultProposal: DEFAULT_PROPOSAL_TYPE, + }); + const config = { maxFileUploadSizeInMb: process.env.MAX_FILE_UPLOAD_SIZE || "16mb", // 16MB by default versions: { @@ -203,6 +212,7 @@ const configuration = () => { policyPublicationShiftInYears: process.env.POLICY_PUBLICATION_SHIFT ?? 3, policyRetentionShiftInYears: process.env.POLICY_RETENTION_SHIFT ?? -1, }, + proposalTypes: jsonConfigMap.proposalTypes, }; return merge(config, localconfiguration); }; diff --git a/src/proposals/dto/update-proposal.dto.ts b/src/proposals/dto/update-proposal.dto.ts index 541c96fbe..c16ef17cb 100644 --- a/src/proposals/dto/update-proposal.dto.ts +++ b/src/proposals/dto/update-proposal.dto.ts @@ -4,6 +4,7 @@ import { IsArray, IsDateString, IsEmail, + IsEnum, IsObject, IsOptional, IsString, @@ -133,6 +134,16 @@ export class UpdateProposalDto extends OwnableDto { @IsOptional() @IsString() readonly parentProposalId?: string; + + @ApiProperty({ + type: String, + required: false, + description: + "Characterize type of proposal, use some of the configured values", + }) + @IsOptional() + @IsString() + readonly type?: string; } export class PartialUpdateProposalDto extends PartialType(UpdateProposalDto) {} diff --git a/src/proposals/proposals.module.ts b/src/proposals/proposals.module.ts index 557081bdb..c684c2e87 100644 --- a/src/proposals/proposals.module.ts +++ b/src/proposals/proposals.module.ts @@ -1,4 +1,4 @@ -import { forwardRef, Module } from "@nestjs/common"; +import { BadRequestException, forwardRef, Module } from "@nestjs/common"; import { ProposalsService } from "./proposals.service"; import { ProposalsController } from "./proposals.controller"; import { MongooseModule } from "@nestjs/mongoose"; @@ -6,6 +6,7 @@ import { ProposalClass, ProposalSchema } from "./schemas/proposal.schema"; import { CaslAbilityFactory } from "src/casl/casl-ability.factory"; import { AttachmentsModule } from "src/attachments/attachments.module"; import { DatasetsModule } from "src/datasets/datasets.module"; +import { ConfigModule, ConfigService } from "@nestjs/config"; @Module({ imports: [ @@ -14,7 +15,11 @@ import { DatasetsModule } from "src/datasets/datasets.module"; MongooseModule.forFeatureAsync([ { name: ProposalClass.name, - useFactory: () => { + imports: [ConfigModule], + inject: [ConfigService], + useFactory: async (configService: ConfigService) => { + const proposalTypes = configService.get("proposalTypes") || "{}"; + const proposalTypesArray: string[] = Object.values(proposalTypes); const schema = ProposalSchema; schema.pre("save", function (next) { @@ -23,8 +28,16 @@ import { DatasetsModule } from "src/datasets/datasets.module"; if (!this._id) { this._id = this.proposalId; } + + if (this.type && !proposalTypesArray.includes(this.type)) { + throw new BadRequestException( + `type must be one of the following values: ${proposalTypesArray.join(", ")}`, + ); + } + next(); }); + return schema; }, }, diff --git a/src/proposals/proposals.service.ts b/src/proposals/proposals.service.ts index 610163b32..09258fdc0 100644 --- a/src/proposals/proposals.service.ts +++ b/src/proposals/proposals.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable, Scope } from "@nestjs/common"; +import { Inject, Injectable, NotFoundException, Scope } from "@nestjs/common"; import { REQUEST } from "@nestjs/core"; import { Request } from "express"; import { InjectModel } from "@nestjs/mongoose"; @@ -97,15 +97,17 @@ export class ProposalsService { ): Promise { const username = (this.request.user as JWTUser).username; - return this.proposalModel - .findOneAndUpdate( - filter, - addUpdatedByField(updateProposalDto, username), - { - new: true, - }, - ) - .exec(); + const proposal = await this.proposalModel.findOne(filter); + + if (!proposal) { + throw new NotFoundException(`Proposal with filter: ${filter} not found`); + } + + Object.assign(proposal, addUpdatedByField(updateProposalDto, username)); + + const updatedProposal = new this.proposalModel(proposal); + + return updatedProposal.save(); } async remove(filter: FilterQuery): Promise { diff --git a/src/proposals/schemas/proposal.schema.ts b/src/proposals/schemas/proposal.schema.ts index 970931252..9ab2cf1ae 100644 --- a/src/proposals/schemas/proposal.schema.ts +++ b/src/proposals/schemas/proposal.schema.ts @@ -8,6 +8,9 @@ import { MeasurementPeriodSchema, } from "./measurement-period.schema"; +// NOTE: This is the proposal default type and it will be used if no proposalTypes.json config file is provided +export const DEFAULT_PROPOSAL_TYPE = "Default Proposal"; + export type ProposalDocument = ProposalClass & Document; @Schema({ collection: "Proposal", @@ -182,6 +185,20 @@ export class ProposalClass extends OwnableClass { ref: "Proposal", }) parentProposalId: string; + + @ApiProperty({ + type: String, + required: true, + default: DEFAULT_PROPOSAL_TYPE, + description: + "Characterize type of proposal, use some of the configured values", + }) + @Prop({ + type: String, + required: true, + default: DEFAULT_PROPOSAL_TYPE, + }) + type: string; } export const ProposalSchema = SchemaFactory.createForClass(ProposalClass); diff --git a/test/Proposal.js b/test/Proposal.js index a6736a96d..1efa4ea26 100644 --- a/test/Proposal.js +++ b/test/Proposal.js @@ -171,6 +171,8 @@ describe("1500: Proposal: Simple Proposal", () => { .then((res) => { res.body.should.have.property("createdBy").and.be.string; res.body.should.have.property("updatedBy").and.be.string; + res.body.should.have.property("type").and.be.string; + res.body.type.should.be.equal("Default Proposal"); }); }); @@ -241,6 +243,45 @@ describe("1500: Proposal: Simple Proposal", () => { }); }); + it("0116: adds a new proposal with a type different than default", async () => { + const proposalType = "DOOR Proposal"; + const proposalWithType = { + ...TestData.ProposalCorrectComplete, + proposalId: faker.string.numeric(8), + type: proposalType, + }; + + return request(appUrl) + .post("/api/v3/Proposals") + .send(proposalWithType) + .set("Accept", "application/json") + .set({ Authorization: `Bearer ${accessTokenProposalIngestor}` }) + .expect(TestData.EntryCreatedStatusCode) + .expect("Content-Type", /json/) + .then((res) => { + res.body.should.have.property("ownerGroup").and.be.string; + res.body.should.have.property("proposalId").and.be.string; + res.body.type.should.be.equal(proposalType); + }); + }); + + it("0117: cannot add a new proposal with a type different than predefined proposal types", async () => { + const proposalType = "Incorrect type"; + const proposalWithIncorrectType = { + ...TestData.ProposalCorrectComplete, + proposalId: faker.string.numeric(8), + type: proposalType, + }; + + return request(appUrl) + .post("/api/v3/Proposals") + .send(proposalWithIncorrectType) + .set("Accept", "application/json") + .set({ Authorization: `Bearer ${accessTokenProposalIngestor}` }) + .expect(TestData.BadRequestStatusCode) + .expect("Content-Type", /json/); + }); + it("0120: updates a proposal with a new parent proposal", async () => { return request(appUrl) .patch("/api/v3/Proposals/" + proposalWithParentId)