diff --git a/.gitignore b/.gitignore index 4b56acf..e60d4b7 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,9 @@ lerna-debug.log* .env.production.local .env.local +# Prisma score cards +/prisma/Scorecards + # temp directory .temp .tmp diff --git a/src/api/api.module.ts b/src/api/api.module.ts index af307c6..108c9a5 100644 --- a/src/api/api.module.ts +++ b/src/api/api.module.ts @@ -16,6 +16,7 @@ import { ChallengeApiService } from 'src/shared/modules/global/challenge.service import { WebhookController } from './webhook/webhook.controller'; import { WebhookService } from './webhook/webhook.service'; import { GiteaWebhookAuthGuard } from '../shared/guards/gitea-webhook-auth.guard'; +import { ScoreCardService } from './scorecard/scorecard.service'; @Module({ imports: [HttpModule, GlobalProvidersModule], @@ -37,6 +38,7 @@ import { GiteaWebhookAuthGuard } from '../shared/guards/gitea-webhook-auth.guard ChallengeApiService, WebhookService, GiteaWebhookAuthGuard, + ScoreCardService, ], }) export class ApiModule {} diff --git a/src/api/scorecard/scorecard.controller.ts b/src/api/scorecard/scorecard.controller.ts index 1bdf59d..b314171 100644 --- a/src/api/scorecard/scorecard.controller.ts +++ b/src/api/scorecard/scorecard.controller.ts @@ -7,8 +7,7 @@ import { Body, Param, Query, - NotFoundException, - InternalServerErrorException, + UseInterceptors, } from '@nestjs/common'; import { ApiTags, @@ -24,18 +23,20 @@ import { UserRole } from 'src/shared/enums/userRole.enum'; import { Scopes } from 'src/shared/decorators/scopes.decorator'; import { Scope } from 'src/shared/enums/scopes.enum'; import { + ScorecardPaginatedResponseDto, ScorecardRequestDto, ScorecardResponseDto, - mapScorecardRequestToDto, + ScorecardWithGroupResponseDto, } from 'src/dto/scorecard.dto'; import { ChallengeTrack } from 'src/shared/enums/challengeTrack.enum'; -import { PrismaService } from '../../shared/modules/global/prisma.service'; +import { ScoreCardService } from './scorecard.service'; +import { PaginationHeaderInterceptor } from 'src/interceptors/PaginationHeaderInterceptor'; @ApiTags('Scorecard') @ApiBearerAuth() @Controller('/scorecards') export class ScorecardController { - constructor(private readonly prisma: PrismaService) {} + constructor(private readonly scorecardService: ScoreCardService) {} @Post() @Roles(UserRole.Admin) @@ -48,27 +49,13 @@ export class ScorecardController { @ApiResponse({ status: 201, description: 'Scorecard added successfully.', - type: ScorecardResponseDto, + type: ScorecardWithGroupResponseDto, }) @ApiResponse({ status: 403, description: 'Forbidden.' }) async addScorecard( @Body() body: ScorecardRequestDto, - ): Promise { - const data = await this.prisma.scorecard.create({ - data: mapScorecardRequestToDto(body), - include: { - scorecardGroups: { - include: { - sections: { - include: { - questions: true, - }, - }, - }, - }, - }, - }); - return data as ScorecardResponseDto; + ): Promise { + return await this.scorecardService.addScorecard(body); } @Put('/:id') @@ -87,41 +74,15 @@ export class ScorecardController { @ApiResponse({ status: 200, description: 'Scorecard updated successfully.', - type: ScorecardResponseDto, + type: ScorecardWithGroupResponseDto, }) @ApiResponse({ status: 403, description: 'Forbidden.' }) @ApiResponse({ status: 404, description: 'Scorecard not found.' }) async editScorecard( @Param('id') id: string, - @Body() body: ScorecardRequestDto, - ): Promise { - console.log(JSON.stringify(body)); - - const data = await this.prisma.scorecard - .update({ - where: { id }, - data: mapScorecardRequestToDto(body), - include: { - scorecardGroups: { - include: { - sections: { - include: { - questions: true, - }, - }, - }, - }, - }, - }) - .catch((error) => { - if (error.code !== 'P2025') { - throw new NotFoundException({ message: `Scorecard not found.` }); - } - throw new InternalServerErrorException({ - message: `Error: ${error.code}`, - }); - }); - return data as ScorecardResponseDto; + @Body() body: ScorecardWithGroupResponseDto, + ): Promise { + return await this.scorecardService.editScorecard(id, body); } @Delete(':id') @@ -139,24 +100,12 @@ export class ScorecardController { @ApiResponse({ status: 200, description: 'Scorecard deleted successfully.', - type: ScorecardResponseDto, + type: ScorecardWithGroupResponseDto, }) @ApiResponse({ status: 403, description: 'Forbidden.' }) @ApiResponse({ status: 404, description: 'Scorecard not found.' }) async deleteScorecard(@Param('id') id: string) { - await this.prisma.scorecard - .delete({ - where: { id }, - }) - .catch((error) => { - if (error.code !== 'P2025') { - throw new NotFoundException({ message: `Scorecard not found.` }); - } - throw new InternalServerErrorException({ - message: `Error: ${error.code}`, - }); - }); - return { message: `Scorecard ${id} deleted successfully.` }; + return await this.scorecardService.deleteScorecard(id); } @Get('/:id') @@ -173,38 +122,15 @@ export class ScorecardController { @ApiResponse({ status: 200, description: 'Scorecard retrieved successfully.', - type: ScorecardResponseDto, + type: ScorecardWithGroupResponseDto, }) @ApiResponse({ status: 404, description: 'Scorecard not found.' }) - async viewScorecard(@Param('id') id: string): Promise { - const data = await this.prisma.scorecard - .findUniqueOrThrow({ - where: { id }, - include: { - scorecardGroups: { - include: { - sections: { - include: { - questions: true, - }, - }, - }, - }, - }, - }) - .catch((error) => { - if (error.code !== 'P2025') { - throw new NotFoundException({ message: `Scorecard not found.` }); - } - throw new InternalServerErrorException({ - message: `Error: ${error.code}`, - }); - }); - return data as ScorecardResponseDto; + async viewScorecard(@Param('id') id: string): Promise { + return await this.scorecardService.viewScorecard(id); } @Get() - @Roles(UserRole.Admin, UserRole.Copilot) + @Roles(UserRole.Admin) @Scopes(Scope.ReadScorecard) @ApiOperation({ summary: 'Search scorecards', @@ -249,34 +175,31 @@ export class ScorecardController { description: 'List of matching scorecards', type: [ScorecardResponseDto], }) + @UseInterceptors(PaginationHeaderInterceptor) async searchScorecards( - @Query('challengeTrack') challengeTrack?: ChallengeTrack, - @Query('challengeType') challengeType?: string, + @Query('challengeTrack') challengeTrack?: ChallengeTrack | ChallengeTrack[], + @Query('challengeType') challengeType?: string | string[], @Query('name') name?: string, @Query('page') page: number = 1, @Query('perPage') perPage: number = 10, - ) { - const skip = (page - 1) * perPage; - const data = await this.prisma.scorecard.findMany({ - where: { - ...(challengeTrack && { challengeTrack }), - ...(challengeType && { challengeType }), - ...(name && { name: { contains: name, mode: 'insensitive' } }), - }, - include: { - scorecardGroups: { - include: { - sections: { - include: { - questions: true, - }, - }, - }, - }, - }, - skip, - take: perPage, + ): Promise { + const challengeTrackArray = Array.isArray(challengeTrack) + ? challengeTrack + : challengeTrack + ? [challengeTrack] + : []; + const challengeTypeArray = Array.isArray(challengeType) + ? challengeType + : challengeType + ? [challengeType] + : []; + const result = await this.scorecardService.getScoreCards({ + challengeTrack: challengeTrackArray, + challengeType: challengeTypeArray, + name, + page, + perPage, }); - return data as ScorecardResponseDto[]; + return result; } } diff --git a/src/api/scorecard/scorecard.service.ts b/src/api/scorecard/scorecard.service.ts new file mode 100644 index 0000000..8bc47f7 --- /dev/null +++ b/src/api/scorecard/scorecard.service.ts @@ -0,0 +1,169 @@ +import { Injectable, InternalServerErrorException, NotFoundException } from "@nestjs/common"; +import { Prisma } from "@prisma/client"; +import { mapScorecardRequestToDto, ScorecardPaginatedResponseDto, ScorecardQueryDto, ScorecardRequestDto, ScorecardResponseDto, ScorecardWithGroupResponseDto } from "src/dto/scorecard.dto"; +import { PrismaService } from "src/shared/modules/global/prisma.service"; + +@Injectable() +export class ScoreCardService { + constructor( + private readonly prisma: PrismaService, + ) {} + + /** + * Adds score card + * @param body body from request + * @returns ScorecardWithGroupResponseDto + */ + async addScorecard(body: ScorecardRequestDto): Promise { + const data = await this.prisma.scorecard.create({ + data: mapScorecardRequestToDto(body), + include: { + scorecardGroups: { + include: { + sections: { + include: { + questions: true, + }, + }, + }, + }, + }, + }); + + return data as ScorecardWithGroupResponseDto; + } + + /** + * Edit score card + * @param body body from request + * @returns ScorecardWithGroupResponseDto + */ + async editScorecard(id: string, body: ScorecardWithGroupResponseDto): Promise { + const data = await this.prisma.scorecard + .update({ + where: { id }, + data: mapScorecardRequestToDto(body), + include: { + scorecardGroups: { + include: { + sections: { + include: { + questions: true, + }, + }, + }, + }, + }, + }) + .catch((error) => { + if (error.code !== 'P2025') { + throw new NotFoundException({ message: `Scorecard not found.` }); + } + throw new InternalServerErrorException({ + message: `Error: ${error.code}`, + }); + }); + + return data as ScorecardWithGroupResponseDto; + } + + /** + * Delete score card + * @param id score card id + * @returns + */ + async deleteScorecard(id: string): Promise<{ message: string }> { + await this.prisma.scorecard + .delete({ + where: { id }, + }) + .catch((error) => { + if (error.code !== 'P2025') { + throw new NotFoundException({ message: `Scorecard not found.` }); + } + throw new InternalServerErrorException({ + message: `Error: ${error.code}`, + }); + }); + return { message: `Scorecard ${id} deleted successfully.` }; + } + + /** + * View score card + * @param id score card id + * @returns + */ + async viewScorecard(id: string): Promise { + const data = await this.prisma.scorecard + .findUniqueOrThrow({ + where: { id }, + include: { + scorecardGroups: { + include: { + sections: { + include: { + questions: true, + }, + }, + }, + }, + }, + }) + .catch((error) => { + if (error.code !== 'P2025') { + throw new NotFoundException({ message: `Scorecard not found.` }); + } + throw new InternalServerErrorException({ + message: `Error: ${error.code}`, + }); + }); + return data as ScorecardWithGroupResponseDto; + } + + /** + * Get list of score cards and send it in paginated way + * @param query query params + * @returns response dto + */ + async getScoreCards( + query: ScorecardQueryDto + ): Promise { + const { page = 1, perPage = 10, challengeTrack, challengeType, name } = query; + const skip = (page - 1) * perPage; + const where: Prisma.scorecardWhereInput = { + ...(challengeTrack?.length && { + challengeTrack: { + in: challengeTrack, + }, + }), + ...(challengeType?.length && { + challengeType: { + in: challengeType, + }, + }), + ...(name && { name: { contains: name, mode: 'insensitive' } }), + }; + const data = await this.prisma.scorecard.findMany({ + where, + skip, + take: perPage, + orderBy: { + name: 'asc', + }, + }); + + const totalCount = await this.prisma.scorecard.count({ + where, + }); + + return { + metadata: { + total: totalCount, + page, + perPage, + totalPages: Math.ceil(totalCount/perPage), + }, + scoreCards: data as ScorecardResponseDto[], + }; + } +} \ No newline at end of file diff --git a/src/dto/scorecard.dto.ts b/src/dto/scorecard.dto.ts index 09d835d..543efc4 100644 --- a/src/dto/scorecard.dto.ts +++ b/src/dto/scorecard.dto.ts @@ -203,11 +203,13 @@ export class ScorecardBaseDto { example: 'user456', }) updatedBy: string; +} +export class ScorecardBaseWithGroupsDto extends ScorecardBaseDto { scorecardGroups: any[]; } -export class ScorecardRequestDto extends ScorecardBaseDto { +export class ScorecardRequestDto extends ScorecardBaseWithGroupsDto { @ApiProperty({ description: 'The ID of the scorecard', example: 'abc123' }) id: string; @@ -221,6 +223,11 @@ export class ScorecardRequestDto extends ScorecardBaseDto { export class ScorecardResponseDto extends ScorecardBaseDto { @ApiProperty({ description: 'The ID of the scorecard', example: 'abc123' }) id: string; +} + +export class ScorecardWithGroupResponseDto extends ScorecardBaseDto { + @ApiProperty({ description: 'The ID of the scorecard', example: 'abc123' }) + id: string; @ApiProperty({ description: 'The list of groups associated with the scorecard', @@ -229,6 +236,39 @@ export class ScorecardResponseDto extends ScorecardBaseDto { scorecardGroups: ScorecardGroupResponseDto[]; } +export class PaginationMetaDto { + @ApiProperty({ example: 100 }) + total: number; + + @ApiProperty({ example: 1 }) + page: number; + + @ApiProperty({ example: 10 }) + perPage: number; + + @ApiProperty({ example: 10 }) + totalPages: number; +} + +export class ScorecardPaginatedResponseDto { + @ApiProperty({ description: 'This contains pagination metadata' }) + metadata: PaginationMetaDto; + + @ApiProperty({ + description: 'The list of score cards', + type: [ScorecardGroupResponseDto], + }) + scoreCards: ScorecardResponseDto[]; +} + +export class ScorecardQueryDto { + challengeTrack?: ChallengeTrack[]; + challengeType?: string[]; + name?: string; + page?: number; + perPage?: number; +} + export function mapScorecardRequestToDto(request: ScorecardRequestDto) { const userFields = { createdBy: request.createdBy, diff --git a/src/interceptors/PaginationHeaderInterceptor.ts b/src/interceptors/PaginationHeaderInterceptor.ts new file mode 100644 index 0000000..8a9aa87 --- /dev/null +++ b/src/interceptors/PaginationHeaderInterceptor.ts @@ -0,0 +1,21 @@ +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from "@nestjs/common"; +import { Observable, tap } from "rxjs"; +import { Response } from "express"; + +@Injectable() +export class PaginationHeaderInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + const res = context.switchToHttp().getResponse(); + return next.handle().pipe( + tap((response) => { + if (response?.metadata) { + const { total, page, perPage, totalPages } = response.metadata; + res.setHeader('X-Total-Count', total); + res.setHeader('X-Page', page); + res.setHeader('X-Per-Page', perPage); + res.setHeader('X-Total-Pages', totalPages); + } + }), + ); + } +}