diff --git a/src/modules/proposal/controller/proposal-contracting.controller.ts b/src/modules/proposal/controller/proposal-contracting.controller.ts index 110b737..d0f8d23 100644 --- a/src/modules/proposal/controller/proposal-contracting.controller.ts +++ b/src/modules/proposal/controller/proposal-contracting.controller.ts @@ -28,6 +28,7 @@ import { RevertLocationVoteDto } from '../dto/revert-location-vote.dto'; import { SignContractDto, SignContractWithFileDto } from '../dto/sign-contract.dto'; import { InitContractingDto } from '../dto/proposal/init-contracting.dto'; import { ProposalContractingService } from '../services/proposal-contracting.service'; +import { SetDizConditionApprovalDto } from '../dto/set-diz-condition-approval.dto'; @ApiController('proposals', undefined, 'contracting') export class ProposalContractingController { @@ -74,10 +75,10 @@ export class ProposalContractingController { @ApiNoContentResponse({ description: 'Vote successfully set. No content returns.' }) @HttpCode(204) @ApiOperation({ summary: 'Sets the UAC vote after a DIZ condition review of a proposal' }) - @ApiBody({ type: SetUacApprovalDto }) + @ApiBody({ type: SetDizConditionApprovalDto }) async dizConditionApproval( @Param() { id }: MongoIdParamDto, - @Body() vote: SetUacApprovalDto, + @Body() vote: SetDizConditionApprovalDto, @Request() { user }: FdpgRequest, ): Promise { return await this.proposalContractingService.dizConditionApproval(id, vote, user); diff --git a/src/modules/proposal/controller/proposal-misc.controller.ts b/src/modules/proposal/controller/proposal-misc.controller.ts index f6ad971..d7b8781 100644 --- a/src/modules/proposal/controller/proposal-misc.controller.ts +++ b/src/modules/proposal/controller/proposal-misc.controller.ts @@ -1,5 +1,16 @@ -import { Body, Get, HttpCode, Param, Put, Request, StreamableFile, UsePipes, ValidationPipe } from '@nestjs/common'; -import { ApiNoContentResponse, ApiNotFoundResponse, ApiOperation, ApiProduces } from '@nestjs/swagger'; +import { + Body, + Get, + HttpCode, + Param, + Post, + Put, + Request, + StreamableFile, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { ApiBody, ApiNoContentResponse, ApiNotFoundResponse, ApiOperation } from '@nestjs/swagger'; import { MarkAsDoneDto } from 'src/modules/comment/dto/mark-as-done.dto'; import { ApiController } from 'src/shared/decorators/api-controller.decorator'; import { Auth } from 'src/shared/decorators/auth.decorator'; @@ -12,6 +23,7 @@ import { ResearcherIdentityDto } from '../dto/proposal/participants/researcher.d import { SetBooleanStatusDto, SetProposalStatusDto } from '../dto/set-status.dto'; import { SetFdpgCheckNotesDto } from '../dto/set-fdpg-check-notes.dto'; import { ProposalMiscService } from '../services/proposal-misc.service'; +import { SetAdditionalLocationInformationDto } from '../dto/set-additional-location-information.dto'; @ApiController('proposals', undefined, 'misc') export class ProposalMiscController { @@ -115,4 +127,20 @@ export class ProposalMiscController { ): Promise { return await this.proposalMiscService.setFdpgCheckNotes(id, value, user); } + + @Auth(Role.DizMember) + @Post(':id/additionalLocationInformation') + @ProposalValidation() + @ApiNotFoundResponse({ description: 'Item could not be found' }) + @ApiNoContentResponse({ description: 'Set additional information about location on proposal' }) + @HttpCode(204) + @ApiBody({ type: SetAdditionalLocationInformationDto }) + @ApiOperation({ summary: 'Sets additional information about a location on a proposal' }) + async updateAdditionalLocationInformation( + @Param() { id }: MongoIdParamDto, + @Body() additionalLocationInformation: SetAdditionalLocationInformationDto, + @Request() { user }: FdpgRequest, + ): Promise { + return this.proposalMiscService.updateAdditionalInformationForLocation(id, additionalLocationInformation, user); + } } diff --git a/src/modules/proposal/dto/proposal/additional-location-information.dto.ts b/src/modules/proposal/dto/proposal/additional-location-information.dto.ts new file mode 100644 index 0000000..51af8f8 --- /dev/null +++ b/src/modules/proposal/dto/proposal/additional-location-information.dto.ts @@ -0,0 +1,19 @@ +import { Exclude, Expose, Transform } from 'class-transformer'; +import { IsBoolean } from 'class-validator'; +import { MiiLocation } from 'src/shared/constants/mii-locations'; +import { IsNotEmptyString } from 'src/shared/validators/is-not-empty-string.validator'; + +@Exclude() +export class AdditionalLocationInformationGetDto { + @Expose() + location: MiiLocation; + + @Expose() + @IsBoolean() + @Transform((params) => (params.value === 'true' || params.value === true ? true : false)) + legalBasis: boolean; + + @Expose() + @IsNotEmptyString() + locationPublicationName: string; +} diff --git a/src/modules/proposal/dto/proposal/proposal.dto.ts b/src/modules/proposal/dto/proposal/proposal.dto.ts index 5b937d0..c0d81f1 100644 --- a/src/modules/proposal/dto/proposal/proposal.dto.ts +++ b/src/modules/proposal/dto/proposal/proposal.dto.ts @@ -36,6 +36,7 @@ import { UacApprovalGetDto } from './uac-approval.dto'; import { UserProjectDto } from './user-project.dto'; import { PlatformIdentifier } from 'src/modules/admin/enums/platform-identifier.enum'; import { OutputGroup } from 'src/shared/enums/output-group.enum'; +import { AdditionalLocationInformationGetDto } from './additional-location-information.dto'; const getRoleFromTransform = (options: ClassTransformOptions) => { const [role] = options.groups @@ -239,6 +240,21 @@ export class ProposalGetDto extends ProposalBaseDto { @Expose({ groups: [Role.FdpgMember, Role.Researcher] }) signedContracts: MiiLocation[]; + @Expose({ groups: [Role.FdpgMember, Role.DizMember, Role.UacMember] }) + @Type(() => AdditionalLocationInformationGetDto) + @Transform(({ value, options }) => { + const { role, location } = getRoleFromTransform(options); + + return value.filter((additionalInformation: AdditionalLocationInformationGetDto) => { + if (role === Role.DizMember || role === Role.UacMember) { + return additionalInformation.location === location; + } + + return true; + }); + }) + additionalLocationInformation: AdditionalLocationInformationGetDto[]; + // LOCATION Tasks <---- // Conditional and UAC approval are stored additionally to the "flow-arrays" and are persistent diff --git a/src/modules/proposal/dto/set-additional-location-information.dto.ts b/src/modules/proposal/dto/set-additional-location-information.dto.ts new file mode 100644 index 0000000..9596cec --- /dev/null +++ b/src/modules/proposal/dto/set-additional-location-information.dto.ts @@ -0,0 +1,13 @@ +import { Exclude, Expose, Transform } from 'class-transformer'; +import { IsBoolean } from 'class-validator'; + +@Exclude() +export class SetAdditionalLocationInformationDto { + @Expose() + @IsBoolean() + @Transform((params) => (params.value === 'true' || params.value === true ? true : false)) + legalBasis: boolean; + + @Expose() + locationPublicationName: string; +} diff --git a/src/modules/proposal/dto/set-diz-condition-approval.dto.ts b/src/modules/proposal/dto/set-diz-condition-approval.dto.ts new file mode 100644 index 0000000..543f195 --- /dev/null +++ b/src/modules/proposal/dto/set-diz-condition-approval.dto.ts @@ -0,0 +1,38 @@ +import { Exclude, Expose, Transform } from 'class-transformer'; +import { IsBoolean, IsNumber, MaxLength, ValidateIf } from 'class-validator'; +import { IsNotEmptyString } from 'src/shared/validators/is-not-empty-string.validator'; + +// For some reason the values are all strings so they need to be transformed to the desired type +@Exclude() +export class SetDizConditionApprovalDto { + @Expose() + @IsBoolean() + @Transform((params) => (params.value === 'true' || params.value === true ? true : false)) + value: boolean; + + @Expose() + @ValidateIf((obj: SetDizConditionApprovalDto) => obj.value === true) + @IsNumber() + @Transform((params) => { + if (params.obj.value === 'false' || params.obj.value === false) { + return undefined; + } + const parsed = parseFloat(params.value); + return isNaN(parsed) ? undefined : parsed; + }) + dataAmount?: number; + + @Expose() + @ValidateIf( + (obj: SetDizConditionApprovalDto) => + obj.value === true && typeof obj.conditionReasoning === 'string' && obj.conditionReasoning.trim() !== '', + ) + @MaxLength(10_000) + conditionReasoning?: string; + + @Expose() + @ValidateIf((obj: SetDizConditionApprovalDto) => obj.value === false) + @IsNotEmptyString() + @MaxLength(10_000) + declineReason?: string; +} diff --git a/src/modules/proposal/dto/set-uac-approval.dto.ts b/src/modules/proposal/dto/set-uac-approval.dto.ts index 15351a6..4cacf0c 100644 --- a/src/modules/proposal/dto/set-uac-approval.dto.ts +++ b/src/modules/proposal/dto/set-uac-approval.dto.ts @@ -1,6 +1,6 @@ import { ApiProperty } from '@nestjs/swagger'; import { Exclude, Expose, Transform } from 'class-transformer'; -import { IsBoolean, IsNumber, MaxLength, ValidateIf } from 'class-validator'; +import { IsBoolean, MaxLength, ValidateIf } from 'class-validator'; import { IsNotEmptyString } from 'src/shared/validators/is-not-empty-string.validator'; // For some reason the values are all strings so they need to be transformed to the desired type @@ -11,18 +11,6 @@ export class SetUacApprovalDto { @Transform((params) => (params.value === 'true' || params.value === true ? true : false)) value: boolean; - @Expose() - @ValidateIf((obj: SetUacApprovalDto) => obj.value === true) - @IsNumber() - @Transform((params) => { - if (params.obj.value === 'false' || params.obj.value === false) { - return undefined; - } - const parsed = parseFloat(params.value); - return isNaN(parsed) ? undefined : parsed; - }) - dataAmount?: number; - @Expose() @ValidateIf( (obj: SetUacApprovalDto) => diff --git a/src/modules/proposal/schema/constants/get-list.projection.ts b/src/modules/proposal/schema/constants/get-list.projection.ts index 7afef71..1412b45 100644 --- a/src/modules/proposal/schema/constants/get-list.projection.ts +++ b/src/modules/proposal/schema/constants/get-list.projection.ts @@ -28,4 +28,5 @@ export const GetListProjection: Partial, number>> = openFdpgTasks: 1, contractAcceptedByResearcher: 1, contractRejectedByResearcher: 1, + additionalLocationInformation: 1, }; diff --git a/src/modules/proposal/schema/proposal.schema.ts b/src/modules/proposal/schema/proposal.schema.ts index 9849d96..fab1f2a 100644 --- a/src/modules/proposal/schema/proposal.schema.ts +++ b/src/modules/proposal/schema/proposal.schema.ts @@ -21,6 +21,10 @@ import { Applicant, ApplicantSchema } from './sub-schema/applicant.schema'; import { ProjectResponsible, ProjectResponsibleSchema } from './sub-schema/project-responsible.schema'; import { ProjectUser, ProjectUserSchema } from './sub-schema/project-user.schema'; import { PlatformIdentifier } from 'src/modules/admin/enums/platform-identifier.enum'; +import { + AdditionalLocationInformation, + AdditionalLocationInformationSchema, +} from './sub-schema/additional-location-information.schema'; export type ProposalDocument = Proposal & Document; @@ -95,6 +99,9 @@ export class Proposal { @Prop({ type: [PublicationSchema], default: [] }) publications: Publication[]; + @Prop({ type: [AdditionalLocationInformationSchema], default: [] }) + additionalLocationInformation: AdditionalLocationInformation[]; + @Prop({ type: [ReportSchema], default: [] }) reports: Report[]; diff --git a/src/modules/proposal/schema/sub-schema/additional-location-information.schema.ts b/src/modules/proposal/schema/sub-schema/additional-location-information.schema.ts new file mode 100644 index 0000000..73920f7 --- /dev/null +++ b/src/modules/proposal/schema/sub-schema/additional-location-information.schema.ts @@ -0,0 +1,35 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Document } from 'mongoose'; +import { MiiLocation } from 'src/shared/constants/mii-locations'; + +export type AdditionalLocationInformationDocument = AdditionalLocationInformation & Document; + +@Schema({ _id: true }) +export class AdditionalLocationInformation { + _id?: string; + + @Prop(String) + location: MiiLocation; + + @Prop() + legalBasis: boolean; + + @Prop() + locationPublicationName: string; + + @Prop({ + type: Date, + default: Date.now, + immutable: true, + }) + createdAt: Date; + + @Prop({ + type: Date, + default: Date.now, + immutable: false, + }) + updatedAt: Date; +} + +export const AdditionalLocationInformationSchema = SchemaFactory.createForClass(AdditionalLocationInformation); diff --git a/src/modules/proposal/services/proposal-contracting.service.ts b/src/modules/proposal/services/proposal-contracting.service.ts index 1cc6ef0..dc47fd3 100644 --- a/src/modules/proposal/services/proposal-contracting.service.ts +++ b/src/modules/proposal/services/proposal-contracting.service.ts @@ -48,6 +48,7 @@ import { import { ProposalCrudService } from './proposal-crud.service'; import { ProposalUploadService } from './proposal-upload.service'; import { StatusChangeService } from './status-change.service'; +import { SetDizConditionApprovalDto } from '../dto/set-diz-condition-approval.dto'; @Injectable() export class ProposalContractingService { @@ -100,7 +101,7 @@ export class ProposalContractingService { await this.eventEngineService.handleProposalUacApproval(saveResult, vote.value, user.miiLocation); } - async dizConditionApproval(proposalId: string, vote: SetUacApprovalDto, user: IRequestUser): Promise { + async dizConditionApproval(proposalId: string, vote: SetDizConditionApprovalDto, user: IRequestUser): Promise { const toBeUpdated = await this.proposalCrudService.findDocument(proposalId, user, undefined, true); validateDizConditionApproval(toBeUpdated, user); diff --git a/src/modules/proposal/services/proposal-crud.service.ts b/src/modules/proposal/services/proposal-crud.service.ts index b49f7a9..3734982 100644 --- a/src/modules/proposal/services/proposal-crud.service.ts +++ b/src/modules/proposal/services/proposal-crud.service.ts @@ -76,6 +76,7 @@ export class ProposalCrudService { dbProjection.uacApprovedLocations = 1; dbProjection.signedContracts = 1; dbProjection.requestedButExcludedLocations = 1; + dbProjection.additionalLocationInformation = 1; } else if (user.singleKnownRole === Role.UacMember) { dbProjection.owner = 1; dbProjection.locationConditionDraft = 1; @@ -85,6 +86,7 @@ export class ProposalCrudService { dbProjection.uacApprovedLocations = 1; dbProjection.signedContracts = 1; dbProjection.requestedButExcludedLocations = 1; + dbProjection.additionalLocationInformation = 1; } else { dbProjection.owner = 1; } diff --git a/src/modules/proposal/services/proposal-misc.service.ts b/src/modules/proposal/services/proposal-misc.service.ts index 13c12f2..f5b4b24 100644 --- a/src/modules/proposal/services/proposal-misc.service.ts +++ b/src/modules/proposal/services/proposal-misc.service.ts @@ -30,6 +30,9 @@ import { ProposalCrudService } from './proposal-crud.service'; import { StatusChangeService } from './status-change.service'; import { OutputGroup } from 'src/shared/enums/output-group.enum'; import { ProposalDocument } from '../schema/proposal.schema'; +import { SetAdditionalLocationInformationDto } from '../dto/set-additional-location-information.dto'; +import { AdditionalLocationInformation } from '../schema/sub-schema/additional-location-information.schema'; +import { validateUpdateAdditionalInformationAccess } from '../utils/validate-misc.util'; @Injectable() export class ProposalMiscService { @@ -225,4 +228,28 @@ export class ProposalMiscService { toBeUpdated.fdpgCheckNotes = text; await toBeUpdated.save(); } + + async updateAdditionalInformationForLocation( + proposalId: string, + additionalInformationDto: SetAdditionalLocationInformationDto, + user: IRequestUser, + ): Promise { + const toBeUpdated = await this.proposalCrudService.findDocument(proposalId, user); + + validateUpdateAdditionalInformationAccess(toBeUpdated); + + toBeUpdated.additionalLocationInformation = (toBeUpdated.additionalLocationInformation ?? []).filter( + (additionalInformation) => additionalInformation.location !== user.miiLocation, + ); + + const additionalInformation: Omit = { + location: user.miiLocation, + legalBasis: additionalInformationDto.legalBasis, + locationPublicationName: additionalInformationDto.locationPublicationName, + }; + + toBeUpdated.additionalLocationInformation.push(additionalInformation as AdditionalLocationInformation); + + await toBeUpdated.save(); + } } diff --git a/src/modules/proposal/utils/add-location-vote.util.ts b/src/modules/proposal/utils/add-location-vote.util.ts index a55845b..39be736 100644 --- a/src/modules/proposal/utils/add-location-vote.util.ts +++ b/src/modules/proposal/utils/add-location-vote.util.ts @@ -11,6 +11,7 @@ import { ConditionalApproval } from '../schema/sub-schema/conditional-approval.s import { UacApproval } from '../schema/sub-schema/uac-approval.schema'; import { addFdpgTaskAndReturnId, removeFdpgTask } from './add-fdpg-task.util'; import { clearLocationsVotes } from './location-flow.util'; +import { SetDizConditionApprovalDto } from '../dto/set-diz-condition-approval.dto'; export const addDizApproval = (proposal: Proposal, user: IRequestUser, vote: SetDizApprovalDto) => { clearLocationsVotes(proposal, user.miiLocation); @@ -51,7 +52,7 @@ export const addUacApprovalWithCondition = ( location, isAccepted: false, uploadId: upload?._id, - dataAmount: vote.dataAmount, + dataAmount: undefined, isContractSigned: false, conditionReasoning, }; @@ -78,7 +79,7 @@ export const addUacApprovalWithCondition = ( } }; -export const addDizConditionApproval = (proposal: Proposal, user: IRequestUser, vote: SetUacApprovalDto) => { +export const addDizConditionApproval = (proposal: Proposal, user: IRequestUser, vote: SetDizConditionApprovalDto) => { clearLocationsVotes(proposal, user.miiLocation); if (vote.value === true) { @@ -124,7 +125,7 @@ export const addDizConditionApproval = (proposal: Proposal, user: IRequestUser, export const addDizApprovalWithCondition = ( proposal: Proposal, location: MiiLocation, - vote: SetUacApprovalDto, + vote: SetDizConditionApprovalDto, conditionReasoning: string, ) => { const fdpgTaskId = addFdpgTaskAndReturnId(proposal, FdpgTaskType.ConditionApproval); diff --git a/src/modules/proposal/utils/validate-misc.util.ts b/src/modules/proposal/utils/validate-misc.util.ts new file mode 100644 index 0000000..04696b5 --- /dev/null +++ b/src/modules/proposal/utils/validate-misc.util.ts @@ -0,0 +1,9 @@ +import { ForbiddenException } from '@nestjs/common'; +import { ProposalStatus } from '../enums/proposal-status.enum'; +import { Proposal } from '../schema/proposal.schema'; + +export const validateUpdateAdditionalInformationAccess = (proposal: Proposal) => { + if (proposal.status !== ProposalStatus.LocationCheck) { + throw new ForbiddenException('cannot update additional information outside of the location check step.'); + } +};