diff --git a/src/api/ai-workflow/ai-workflow.controller.ts b/src/api/ai-workflow/ai-workflow.controller.ts index 8467ea2..7974c61 100644 --- a/src/api/ai-workflow/ai-workflow.controller.ts +++ b/src/api/ai-workflow/ai-workflow.controller.ts @@ -3,9 +3,10 @@ import { Post, Body, Get, - Param, Patch, ValidationPipe, + Query, + Param, } from '@nestjs/common'; import { ApiBearerAuth, @@ -14,6 +15,7 @@ import { ApiResponse, ApiParam, ApiBody, + ApiQuery, } from '@nestjs/swagger'; import { AiWorkflowService } from './ai-workflow.service'; import { @@ -25,6 +27,8 @@ import { Scopes } from 'src/shared/decorators/scopes.decorator'; import { UserRole } from 'src/shared/enums/userRole.enum'; import { Scope } from 'src/shared/enums/scopes.enum'; import { Roles } from 'src/shared/guards/tokenRoles.guard'; +import { JwtUser } from 'src/shared/modules/global/jwt.service'; +import { User } from 'src/shared/decorators/user.decorator'; @ApiTags('ai_workflow') @ApiBearerAuth() @@ -95,4 +99,74 @@ export class AiWorkflowController { ) { return this.aiWorkflowService.createWorkflowRun(workflowId, body); } + + @Get('/:workflowId/runs') + @Roles( + UserRole.Admin, + UserRole.Copilot, + UserRole.ProjectManager, + UserRole.Reviewer, + UserRole.Submitter, + ) + @Scopes(Scope.ReadWorkflowRuns) + @ApiOperation({ + summary: 'Get all the AI workflow runs for a given submission ID', + }) + @ApiQuery({ + name: 'submissionId', + description: 'The ID of the submission to fetch AI workflow runs for', + required: true, + }) + @ApiResponse({ + status: 200, + description: 'The AI workflow runs for the given submission ID.', + }) + @ApiResponse({ status: 403, description: 'Forbidden.' }) + getRuns( + @Param('workflowId') workflowId: string, + @Query('submissionId') submissionId: string, + @User() user: JwtUser, + ) { + return this.aiWorkflowService.getWorkflowRuns(workflowId, user, { + submissionId, + }); + } + + @Get('/:workflowId/runs/:runId') + @Roles( + UserRole.Admin, + UserRole.Copilot, + UserRole.ProjectManager, + UserRole.Reviewer, + UserRole.Submitter, + ) + @Scopes(Scope.ReadWorkflowRuns) + @ApiOperation({ + summary: 'Get an AI workflow run by its ID', + }) + @ApiParam({ + name: 'runId', + description: 'The ID of the run to fetch AI workflow run', + required: true, + }) + @ApiResponse({ + status: 200, + description: 'The AI workflow run for the given ID.', + }) + @ApiResponse({ status: 403, description: 'Forbidden.' }) + async getRun( + @Param('workflowId') workflowId: string, + @Param('runId') runId: string, + @User() user: JwtUser, + ) { + const runs = await this.aiWorkflowService.getWorkflowRuns( + workflowId, + user, + { + runId, + }, + ); + + return runs[0]; + } } diff --git a/src/api/ai-workflow/ai-workflow.service.ts b/src/api/ai-workflow/ai-workflow.service.ts index 3ef16da..b43191e 100644 --- a/src/api/ai-workflow/ai-workflow.service.ts +++ b/src/api/ai-workflow/ai-workflow.service.ts @@ -3,6 +3,8 @@ import { BadRequestException, Logger, NotFoundException, + InternalServerErrorException, + ForbiddenException, } from '@nestjs/common'; import { PrismaService } from '../../shared/modules/global/prisma.service'; import { @@ -11,11 +13,24 @@ import { UpdateAiWorkflowDto, } from '../../dto/aiWorkflow.dto'; import { ScorecardStatus } from 'src/dto/scorecard.dto'; +import { JwtUser } from 'src/shared/modules/global/jwt.service'; +import { + ChallengeApiService, + ChallengeData, +} from 'src/shared/modules/global/challenge.service'; +import { ResourceApiService } from 'src/shared/modules/global/resource.service'; +import { UserRole } from 'src/shared/enums/userRole.enum'; +import { ChallengeStatus } from 'src/shared/enums/challengeStatus.enum'; @Injectable() export class AiWorkflowService { private readonly logger: Logger = new Logger(AiWorkflowService.name); - constructor(private readonly prisma: PrismaService) {} + + constructor( + private readonly prisma: PrismaService, + private readonly challengeApiService: ChallengeApiService, + private readonly resourceApiService: ResourceApiService, + ) {} async scorecardExists(scorecardId: string): Promise { const count = await this.prisma.scorecard.count({ @@ -154,4 +169,90 @@ export class AiWorkflowService { throw e; } } + + async getWorkflowRuns( + workflowId: string, + user: JwtUser, + filter: { submissionId?: string; runId?: string }, + ) { + // validate workflowId + try { + await this.getWorkflowById(workflowId); + } catch (e) { + if (e instanceof NotFoundException) { + throw new BadRequestException( + `Invalid workflow id provided! Workflow with id ${workflowId} does not exist!`, + ); + } + } + + const runs = await this.prisma.aiWorkflowRun.findMany({ + where: { + workflowId, + id: filter.runId, + submissionId: filter.submissionId, + }, + include: { + submission: true, + }, + }); + + if (filter.runId && !runs.length) { + throw new NotFoundException( + `AI Workflow run with id ${filter.runId} not found!`, + ); + } + + const submission = runs[0]?.submission; + const challengeId = submission?.challengeId; + const challenge: ChallengeData = + await this.challengeApiService.getChallengeDetail(challengeId!); + + if (!challenge) { + throw new InternalServerErrorException( + `Challenge with id ${challengeId} was not found!`, + ); + } + + const isM2mOrAdmin = user.isMachine || user.roles?.includes(UserRole.Admin); + if (!isM2mOrAdmin) { + const requiredRoles = [ + UserRole.Reviewer, + UserRole.ProjectManager, + UserRole.Copilot, + UserRole.Submitter, + ].map((r) => r.toLowerCase()); + + const memberRoles = ( + await this.resourceApiService.getMemberResourcesRoles( + challengeId!, + user.userId, + ) + ).filter((resource) => + requiredRoles.some( + (role) => + resource.roleName!.toLowerCase().indexOf(role.toLowerCase()) >= 0, + ), + ); + + if (!memberRoles.length) { + throw new ForbiddenException('Insufficient permissions'); + } + + if ( + challenge.status !== ChallengeStatus.COMPLETED && + memberRoles.some( + (r) => r.roleName?.toLowerCase() === UserRole.Submitter.toLowerCase(), + ) && + user.userId !== submission.memberId + ) { + this.logger.log( + `Submitter ${user.userId} trying to access AI workflow run for other submitters.`, + ); + throw new ForbiddenException('Insufficient permissions'); + } + } + + return runs; + } } diff --git a/src/dto/aiWorkflow.dto.ts b/src/dto/aiWorkflow.dto.ts index 148ff83..c79835b 100644 --- a/src/dto/aiWorkflow.dto.ts +++ b/src/dto/aiWorkflow.dto.ts @@ -1,5 +1,11 @@ import { ApiProperty, PartialType } from '@nestjs/swagger'; -import { IsString, IsNotEmpty, IsDateString, IsNumber, IsOptional } from 'class-validator'; +import { + IsString, + IsNotEmpty, + IsDateString, + IsNumber, + IsOptional, +} from 'class-validator'; export class CreateAiWorkflowDto { @ApiProperty() diff --git a/src/shared/enums/challengeStatus.enum.ts b/src/shared/enums/challengeStatus.enum.ts new file mode 100644 index 0000000..6993f24 --- /dev/null +++ b/src/shared/enums/challengeStatus.enum.ts @@ -0,0 +1,17 @@ +export enum ChallengeStatus { + NEW = 'NEW', + DRAFT = 'DRAFT', + APPROVED = 'APPROVED', + ACTIVE = 'ACTIVE', + COMPLETED = 'COMPLETED', + DELETED = 'DELETED', + CANCELLED = 'CANCELLED', + CANCELLED_FAILED_REVIEW = 'CANCELLED_FAILED_REVIEW', + CANCELLED_FAILED_SCREENING = 'CANCELLED_FAILED_SCREENING', + CANCELLED_ZERO_SUBMISSIONS = 'CANCELLED_ZERO_SUBMISSIONS', + CANCELLED_WINNER_UNRESPONSIVE = 'CANCELLED_WINNER_UNRESPONSIVE', + CANCELLED_CLIENT_REQUEST = 'CANCELLED_CLIENT_REQUEST', + CANCELLED_REQUIREMENTS_INFEASIBLE = 'CANCELLED_REQUIREMENTS_INFEASIBLE', + CANCELLED_ZERO_REGISTRATIONS = 'CANCELLED_ZERO_REGISTRATIONS', + CANCELLED_PAYMENT_FAILED = 'CANCELLED_PAYMENT_FAILED', +} diff --git a/src/shared/enums/userRole.enum.ts b/src/shared/enums/userRole.enum.ts index f33d1ad..e9b3c68 100644 --- a/src/shared/enums/userRole.enum.ts +++ b/src/shared/enums/userRole.enum.ts @@ -5,5 +5,7 @@ export enum UserRole { Admin = 'administrator', Copilot = 'copilot', Reviewer = 'reviewer', + Submitter = 'Submitter', + ProjectManager = 'Manager', User = 'Topcoder User', } diff --git a/src/shared/modules/global/challenge.service.ts b/src/shared/modules/global/challenge.service.ts index 149202f..f272bf1 100644 --- a/src/shared/modules/global/challenge.service.ts +++ b/src/shared/modules/global/challenge.service.ts @@ -6,6 +6,7 @@ import { AxiosError } from 'axios'; import { M2MService } from './m2m.service'; import { Injectable, Logger } from '@nestjs/common'; import { CommonConfig } from 'src/shared/config/common.config'; +import { ChallengeStatus } from 'src/shared/enums/challengeStatus.enum'; export class PhaseData { id: string; @@ -24,6 +25,7 @@ export class ChallengeData { track?: string | undefined; subTrack?: string | undefined; }; + status: ChallengeStatus; numOfSubmissions?: number | undefined; track: string; legacyId: number; diff --git a/src/shared/modules/global/resource.service.ts b/src/shared/modules/global/resource.service.ts index 20dda85..a8902bb 100644 --- a/src/shared/modules/global/resource.service.ts +++ b/src/shared/modules/global/resource.service.ts @@ -21,7 +21,7 @@ export class ResourceApiService { /** * Fetch list of resource roles * - * @returns resolves to list of resouce role + * @returns resolves to list of resource role */ async getResourceRoles(): Promise<{ [key: string]: ResourceRole; @@ -54,7 +54,7 @@ export class ResourceApiService { /** * Fetch list of resource * - * @returns resolves to list of resouce info + * @returns resolves to list of resource info */ async getResources(query: { challengeId?: string; @@ -90,7 +90,30 @@ export class ResourceApiService { } /** - * Validate resource fole + * Fetch list of role resources + * + * @returns resolves to list of resource info + */ + async getMemberResourcesRoles( + challengeId?: string, + memberId?: string, + ): Promise { + const resourceRoles = await this.getResourceRoles(); + return ( + await this.getResources({ + challengeId: challengeId, + memberId: memberId, + }) + ) + .filter((resource) => resource.memberId === memberId) + .map((resource) => ({ + ...resource, + roleName: resourceRoles?.[resource.roleId]?.name ?? '', + })); + } + + /** + * Validate resource role * * @param requiredRoles list of require roles * @param authUser login user info @@ -104,25 +127,14 @@ export class ResourceApiService { challengeId: string, resourceId: string, ): Promise { - const resourceRoles = await this.getResourceRoles(); const myResources = ( - await this.getResources({ - challengeId: challengeId, - memberId: authUser.userId, - }) + await this.getMemberResourcesRoles(challengeId, authUser.userId) ) - .filter( - (resource) => - resource.id === resourceId && resource.memberId === authUser.userId, - ) - .map((resource) => ({ - ...resource, - roleName: resourceRoles?.[resource.roleId]?.name ?? '', - })) + .filter((resource) => resource.id === resourceId) .filter((resource) => some( requiredRoles.map((item) => item.toLowerCase()), - (role: string) => resource.roleName.toLowerCase().indexOf(role) >= 0, + (role: string) => resource.roleName!.toLowerCase().indexOf(role) >= 0, ), ); if (!myResources.length) {