diff --git a/src/api/scorecard/scorecard.controller.ts b/src/api/scorecard/scorecard.controller.ts index 1eb06ba..c547cb0 100644 --- a/src/api/scorecard/scorecard.controller.ts +++ b/src/api/scorecard/scorecard.controller.ts @@ -8,6 +8,7 @@ import { Param, Query, UseInterceptors, + ValidationPipe, } from '@nestjs/common'; import { ApiTags, @@ -32,6 +33,8 @@ import { ChallengeTrack } from 'src/shared/enums/challengeTrack.enum'; import { ScoreCardService } from './scorecard.service'; import { PaginationHeaderInterceptor } from 'src/interceptors/PaginationHeaderInterceptor'; import { $Enums } from '@prisma/client'; +import { User } from 'src/shared/decorators/user.decorator'; +import { JwtUser } from 'src/shared/modules/global/jwt.service'; @ApiTags('Scorecard') @ApiBearerAuth() @@ -54,9 +57,11 @@ export class ScorecardController { }) @ApiResponse({ status: 403, description: 'Forbidden.' }) async addScorecard( - @Body() body: ScorecardRequestDto, + @Body(new ValidationPipe({ whitelist: true, transform: true })) + body: ScorecardRequestDto, + @User() user: JwtUser, ): Promise { - return await this.scorecardService.addScorecard(body); + return await this.scorecardService.addScorecard(body, user); } @Put('/:id') @@ -81,9 +86,11 @@ export class ScorecardController { @ApiResponse({ status: 404, description: 'Scorecard not found.' }) async editScorecard( @Param('id') id: string, - @Body() body: ScorecardWithGroupResponseDto, + @Body(new ValidationPipe({ whitelist: true, transform: true })) + body: ScorecardRequestDto, + @User() user: JwtUser, ): Promise { - return await this.scorecardService.editScorecard(id, body); + return await this.scorecardService.editScorecard(id, body, user); } @Delete(':id') @@ -248,7 +255,10 @@ export class ScorecardController { }) @ApiResponse({ status: 403, description: 'Forbidden.' }) @ApiResponse({ status: 404, description: 'Scorecard not found.' }) - async cloneScorecard(@Param('id') id: string): Promise { - return this.scorecardService.cloneScorecard(id); + async cloneScorecard( + @Param('id') id: string, + @User() user: JwtUser, + ): Promise { + return this.scorecardService.cloneScorecard(id, user); } } diff --git a/src/api/scorecard/scorecard.service.ts b/src/api/scorecard/scorecard.service.ts index a19ea41..47b2121 100644 --- a/src/api/scorecard/scorecard.service.ts +++ b/src/api/scorecard/scorecard.service.ts @@ -5,16 +5,18 @@ import { } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { + mapScorecardRequestForCreate, mapScorecardRequestToDto, - // ScorecardGroupBaseDto, + ScorecardGroupBaseDto, ScorecardPaginatedResponseDto, ScorecardQueryDto, - // ScorecardQuestionBaseDto, + ScorecardQuestionBaseDto, ScorecardRequestDto, ScorecardResponseDto, - // ScorecardSectionBaseDto, + ScorecardSectionBaseDto, ScorecardWithGroupResponseDto, } from 'src/dto/scorecard.dto'; +import { JwtUser } from 'src/shared/modules/global/jwt.service'; import { PrismaService } from 'src/shared/modules/global/prisma.service'; @Injectable() @@ -28,9 +30,16 @@ export class ScoreCardService { */ async addScorecard( body: ScorecardRequestDto, + user: JwtUser, ): Promise { const data = await this.prisma.scorecard.create({ - data: mapScorecardRequestToDto(body), + data: { + ...(mapScorecardRequestForCreate({ + ...body, + createdBy: user.isMachine ? 'System' : (user.userId as string), + updatedBy: user.isMachine ? 'System' : (user.userId as string), + }) as any), + }, include: { scorecardGroups: { include: { @@ -44,7 +53,7 @@ export class ScoreCardService { }, }); - return data as ScorecardWithGroupResponseDto; + return data as unknown as ScorecardWithGroupResponseDto; } /** @@ -54,12 +63,17 @@ export class ScoreCardService { */ async editScorecard( id: string, - body: ScorecardWithGroupResponseDto, + body: ScorecardRequestDto, + user: JwtUser, ): Promise { const data = await this.prisma.scorecard .update({ where: { id }, - data: mapScorecardRequestToDto(body), + data: mapScorecardRequestToDto({ + ...body, + createdBy: user.isMachine ? 'System' : (user.userId as string), + updatedBy: user.isMachine ? 'System' : (user.userId as string), + }) as any, include: { scorecardGroups: { include: { @@ -81,7 +95,7 @@ export class ScoreCardService { }); }); - return data as ScorecardWithGroupResponseDto; + return data as unknown as ScorecardWithGroupResponseDto; } /** @@ -202,79 +216,87 @@ export class ScoreCardService { }; } - // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/require-await - async cloneScorecard(id: string): Promise { - // const original = await this.prisma.scorecard.findUnique({ - // where: { id }, - // include: { - // scorecardGroups: { - // include: { - // sections: { - // include: { - // questions: true, - // }, - // }, - // }, - // }, - // }, - // }); + async cloneScorecard( + id: string, + user: { userId?: string; isMachine: boolean }, + ): Promise { + const original = await this.prisma.scorecard.findUnique({ + where: { id }, + include: { + scorecardGroups: { + include: { + sections: { + include: { + questions: true, + }, + }, + }, + }, + }, + }); - // if (!original) { - // throw new NotFoundException({ message: `Scorecard not found.` }); - // } + if (!original) { + throw new NotFoundException({ message: `Scorecard not found.` }); + } - // // Remove id fields from nested objects for cloning - // const cloneGroups = original.scorecardGroups.map( - // (group: ScorecardGroupBaseDto) => ({ - // ...group, - // id: undefined, - // createdAt: undefined, - // updatedAt: undefined, - // scorecardId: undefined, - // sections: group.sections.map((section: ScorecardSectionBaseDto) => ({ - // ...section, - // id: undefined, - // createdAt: undefined, - // updatedAt: undefined, - // scorecardGroupId: undefined, - // questions: section.questions.map( - // (question: ScorecardQuestionBaseDto) => ({ - // ...question, - // id: undefined, - // createdAt: undefined, - // updatedAt: undefined, - // sectionId: undefined, - // scorecardSectionId: undefined, - // }), - // ), - // })), - // }), - // ); + const auditFields = { + createdBy: user.isMachine ? 'System' : (user.userId as string), + updatedBy: user.isMachine ? 'System' : (user.userId as string), + createdAt: undefined, + updatedAt: undefined, + }; - // const clonedScorecard = await this.prisma.scorecard.create({ - // data: { - // ...original, - // id: undefined, - // name: `${original.name} (Clone)`, - // createdAt: undefined, - // updatedAt: undefined, - // scorecardGroups: { - // create: cloneGroups, - // }, - // }, - // include: { - // scorecardGroups: { - // include: { - // sections: { - // include: { - // questions: true, - // }, - // }, - // }, - // }, - // }, - // }); - const clonedScorecard = {}; + // Remove id fields from nested objects for cloning + const cloneGroups = original.scorecardGroups.map( + (group: ScorecardGroupBaseDto) => ({ + ...group, + id: undefined, + ...auditFields, + scorecardId: undefined, + sections: { + create: group.sections.map((section: ScorecardSectionBaseDto) => ({ + ...section, + id: undefined, + ...auditFields, + scorecardGroupId: undefined, + questions: { + create: section.questions.map( + (question: ScorecardQuestionBaseDto) => ({ + ...question, + id: undefined, + ...auditFields, + sectionId: undefined, + scorecardSectionId: undefined, + }), + ), + }, + })), + }, + }), + ) as any; + + const clonedScorecard = await this.prisma.scorecard.create({ + data: { + ...original, + id: undefined, + name: `${original.name} (Clone)`, + ...auditFields, + scorecardGroups: { + create: cloneGroups, + }, + }, + include: { + scorecardGroups: { + include: { + sections: { + include: { + questions: true, + }, + }, + }, + }, + }, + }); return clonedScorecard as ScorecardResponseDto; } diff --git a/src/dto/scorecard.dto.ts b/src/dto/scorecard.dto.ts index 19db58e..e2385b9 100644 --- a/src/dto/scorecard.dto.ts +++ b/src/dto/scorecard.dto.ts @@ -1,5 +1,20 @@ import { ApiProperty } from '@nestjs/swagger'; import { $Enums } from '@prisma/client'; +import { Type } from 'class-transformer'; +import { + IsArray, + IsBoolean, + IsEnum, + IsNumber, + IsOptional, + IsString, + Min, + ValidateNested, +} from 'class-validator'; +import { + IsGreaterThan, + IsSmallerThan, +} from 'src/shared/validators/customValidators'; export enum ScorecardStatus { ACTIVE = 'ACTIVE', @@ -32,28 +47,39 @@ export enum QuestionType { } export class ScorecardQuestionBaseDto { + @ApiProperty({ description: 'The id of the question', example: 'abc' }) + @IsOptional() + @IsString() + id: string; + @ApiProperty({ description: 'The type of the question', enum: QuestionType }) + @IsEnum(QuestionType) type: QuestionType; @ApiProperty({ description: 'The description of the question', example: 'What is the challenge?', }) + @IsString() description: string; @ApiProperty({ description: 'Guidelines for the question', example: 'Provide detailed information.', }) + @IsString() guidelines: string; @ApiProperty({ description: 'The weight of the question', example: 10 }) + @IsNumber() weight: number; @ApiProperty({ description: 'Indicates whether the question requires an upload', example: true, }) + @IsOptional() + @IsBoolean() requiresUpload: boolean; @ApiProperty({ @@ -61,6 +87,8 @@ export class ScorecardQuestionBaseDto { example: 0, required: false, }) + @IsOptional() + @IsNumber() scaleMin?: number; @ApiProperty({ @@ -68,6 +96,8 @@ export class ScorecardQuestionBaseDto { example: 9, required: false, }) + @IsOptional() + @IsNumber() scaleMax?: number; } @@ -79,16 +109,24 @@ export class ScorecardQuestionResponseDto extends ScorecardQuestionBaseDto { } export class ScorecardSectionBaseDto { + @ApiProperty({ description: 'The id of the section', example: 'abc' }) + @IsOptional() + @IsString() + id: string; + @ApiProperty({ description: 'The name of the section', example: 'Technical Skills', }) + @IsString() name: string; @ApiProperty({ description: 'The weight of the section', example: 20 }) + @IsNumber() weight: number; @ApiProperty({ description: 'Sort order of the section', example: 1 }) + @IsNumber() sortOrder: number; questions: any[]; @@ -99,6 +137,9 @@ export class ScorecardSectionRequestDto extends ScorecardSectionBaseDto { description: 'The list of questions within this section', type: [ScorecardQuestionRequestDto], }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ScorecardQuestionRequestDto) questions: ScorecardQuestionRequestDto[]; } @@ -114,13 +155,21 @@ export class ScorecardSectionResponseDto extends ScorecardSectionBaseDto { } export class ScorecardGroupBaseDto { + @ApiProperty({ description: 'The id of the group', example: 'abc' }) + @IsOptional() + @IsString() + id: string; + @ApiProperty({ description: 'The name of the group', example: 'Group A' }) + @IsString() name: string; @ApiProperty({ description: 'The weight of the group', example: 30 }) + @IsNumber() weight: number; @ApiProperty({ description: 'Sort order of the group', example: 1 }) + @IsNumber() sortOrder: number; sections: any[]; @@ -130,6 +179,9 @@ export class ScorecardGroupRequestDto extends ScorecardGroupBaseDto { description: 'The list of sections within this group', type: [ScorecardSectionRequestDto], }) + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ScorecardSectionRequestDto) sections: ScorecardSectionRequestDto[]; } @@ -149,60 +201,55 @@ export class ScorecardBaseDto { description: 'The status of the scorecard', enum: ScorecardStatus, }) + @IsEnum(ScorecardStatus) status: ScorecardStatus; @ApiProperty({ description: 'The type of the scorecard', enum: ScorecardType, }) + @IsEnum(ScorecardType) type: ScorecardType; @ApiProperty({ description: 'The challenge track associated with the scorecard', enum: ChallengeTrack, }) + @IsEnum(ChallengeTrack) challengeTrack: ChallengeTrack; @ApiProperty({ description: 'The challenge type', example: 'Code' }) + @IsString() challengeType: string; @ApiProperty({ description: 'The name of the scorecard', example: 'Sample Scorecard', }) + @IsString() name: string; @ApiProperty({ description: 'The version of the scorecard', example: '1.0' }) + @IsString() version: string; @ApiProperty({ description: 'The minimum score', example: 0 }) + @IsNumber() + @Min(0) + @IsSmallerThan('maxScore') minScore: number; @ApiProperty({ description: 'The maximum score', example: 100 }) + @IsNumber() + @IsGreaterThan('minScore') maxScore: number; - @ApiProperty({ - description: 'The creation timestamp', - example: '2023-10-01T00:00:00Z', - }) + /** + * These shouldn't be editable via API + */ createdAt: Date; - - @ApiProperty({ - description: 'The user who created the scorecard', - example: 'user123', - }) createdBy: string; - - @ApiProperty({ - description: 'The last update timestamp', - example: '2023-10-01T00:00:00Z', - }) updatedAt: Date; - - @ApiProperty({ - description: 'The user who last updated the scorecard', - example: 'user456', - }) updatedBy: string; } @@ -218,6 +265,9 @@ export class ScorecardRequestDto extends ScorecardBaseWithGroupsDto { description: 'The list of groups associated with the scorecard', type: [ScorecardGroupRequestDto], }) + @IsArray() + @ValidateNested({ each: true }) // validate each item in the array + @Type(() => ScorecardGroupRequestDto) scorecardGroups: ScorecardGroupRequestDto[]; } @@ -272,9 +322,9 @@ export class ScorecardQueryDto { scorecardTypesArray?: $Enums.ScorecardType[]; } -export function mapScorecardRequestToDto(request: ScorecardRequestDto) { +export function mapScorecardRequestForCreate(request: ScorecardRequestDto) { const userFields = { - createdBy: request.createdBy, + ...(request.createdBy ? { createdBy: request.createdBy } : {}), updatedBy: request.updatedBy, }; @@ -302,3 +352,76 @@ export function mapScorecardRequestToDto(request: ScorecardRequestDto) { }, }; } + +export function mapScorecardRequestToDto(request: ScorecardRequestDto) { + const userFields = { + ...(request.createdBy ? { createdBy: request.createdBy } : {}), + updatedBy: request.updatedBy, + }; + + return { + ...request, + ...userFields, + scorecardGroups: { + upsert: request.scorecardGroups.map((group) => ({ + where: { id: (group as any).id as string }, + update: { + ...group, + updatedBy: request.updatedBy, + sections: { + upsert: group.sections.map((section) => ({ + where: { id: (section as any).id as string }, + update: { + ...section, + updatedBy: request.updatedBy, + questions: { + upsert: section.questions.map((question) => ({ + where: { id: (question as any).id as string }, + update: { + ...question, + sortOrder: 1, + updatedBy: request.updatedBy, + }, + create: { + ...question, + sortOrder: 1, + ...userFields, + }, + })), + }, + }, + create: { + ...section, + ...userFields, + questions: { + create: section.questions.map((question) => ({ + ...question, + sortOrder: 1, + ...userFields, + })), + }, + }, + })), + }, + }, + create: { + ...group, + ...userFields, + sections: { + create: group.sections.map((section) => ({ + ...section, + ...userFields, + questions: { + create: section.questions.map((question) => ({ + ...question, + sortOrder: 1, + ...userFields, + })), + }, + })), + }, + }, + })), + }, + }; +} diff --git a/src/shared/decorators/user.decorator.ts b/src/shared/decorators/user.decorator.ts new file mode 100644 index 0000000..6a78f27 --- /dev/null +++ b/src/shared/decorators/user.decorator.ts @@ -0,0 +1,11 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { JwtUser } from '../modules/global/jwt.service'; + +export const User = createParamDecorator( + (data: string, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + const user = request.user; + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return (data ? user?.[data] : user) as JwtUser; + }, +); diff --git a/src/shared/validators/customValidators.ts b/src/shared/validators/customValidators.ts new file mode 100644 index 0000000..fa012d0 --- /dev/null +++ b/src/shared/validators/customValidators.ts @@ -0,0 +1,87 @@ +import { + registerDecorator, + ValidationOptions, + ValidationArguments, +} from 'class-validator'; + +type ComparatorFn = (value: any, relatedValue: any) => boolean; + +export function IsDependingOn( + relatedPropertyName: string, + comparatorFn: ComparatorFn, + validationOptions?: ValidationOptions, + defaultMessage?: (args: ValidationArguments) => string, +) { + return function (object: object, propertyName: string) { + registerDecorator({ + name: 'IsDependingOn', + target: object.constructor, + propertyName: propertyName, + constraints: [relatedPropertyName, comparatorFn], + options: validationOptions, + validator: { + validate(value: Date, args: ValidationArguments) { + const [relatedPropertyName, comparatorFn] = args.constraints as [ + string, + ComparatorFn, + ]; + const relatedValue = (args.object as any)[relatedPropertyName]; + + if (typeof comparatorFn !== 'function') { + throw new Error('Comparator function is missing in IsDependingOn'); + } + + return comparatorFn(value, relatedValue); + }, + + defaultMessage(args: ValidationArguments) { + if (typeof defaultMessage === 'function') { + return defaultMessage(args); + } + + const [relatedPropertyName] = args.constraints; + const relatedValue = (args.object as any)[relatedPropertyName]; + return `$property is invalid based on ${relatedPropertyName} (${relatedValue})`; + }, + }, + }); + }; +} + +export function IsGreaterThan( + relatedPropertyName: string, + validationOptions?: ValidationOptions, +) { + return IsDependingOn( + relatedPropertyName, + (value, related) => + typeof value === 'number' && + typeof related === 'number' && + value > related, + validationOptions, + (args: ValidationArguments) => { + const [relatedPropertyName] = args.constraints; + const relatedValue = (args.object as any)[relatedPropertyName]; + return `$property must be greater than ${relatedPropertyName} (${relatedValue})`; + }, + ); +} + +export function IsSmallerThan( + relatedPropertyName: string, + validationOptions?: ValidationOptions, +) { + return IsDependingOn( + relatedPropertyName, + (value, related) => + typeof value === 'number' && + typeof related === 'number' && + value < related, + validationOptions, + (args: ValidationArguments) => { + const [relatedPropertyName] = args.constraints; + const relatedValue = (args.object as any)[relatedPropertyName]; + return `$property must be less than ${relatedPropertyName} (${relatedValue})`; + }, + ); +}