From 0925af16b5f3e0d742d70dee5db904aaa9862040 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 10 Sep 2025 10:44:52 +0300 Subject: [PATCH 1/6] PM-1780 - fetch workflow runs --- src/api/ai_workflow/aiWorkflow.service.ts | 101 +++++++++++++++++- src/api/ai_workflow/ai_workflow.controller.ts | 62 ++++++++++- src/shared/enums/challengeStatus.enum.ts | 17 +++ .../modules/global/challenge.service.ts | 2 + src/shared/modules/global/resource.service.ts | 42 +++++--- 5 files changed, 207 insertions(+), 17 deletions(-) create mode 100644 src/shared/enums/challengeStatus.enum.ts diff --git a/src/api/ai_workflow/aiWorkflow.service.ts b/src/api/ai_workflow/aiWorkflow.service.ts index 7a072f9..ea5d4d4 100644 --- a/src/api/ai_workflow/aiWorkflow.service.ts +++ b/src/api/ai_workflow/aiWorkflow.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({ @@ -149,4 +164,88 @@ 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!`, + ); + } + + if (!user.isMachine && !user.roles?.includes(UserRole.Admin)) { + const requiredRoles = [ + UserRole.Reviewer, + 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/api/ai_workflow/ai_workflow.controller.ts b/src/api/ai_workflow/ai_workflow.controller.ts index fff9cd1..3d4cfb5 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 './aiWorkflow.service'; import { @@ -105,4 +107,62 @@ export class AiWorkflowController { ) { return this.aiWorkflowService.createWorkflowRun(workflowId, body); } + + @Get('/:workflowId/runs') + @Roles(UserRole.Admin) + @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: 201, + 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) + @Scopes(Scope.ReadWorkflowRuns) + @ApiOperation({ + summary: 'Get an AI workflow runsby its ID', + }) + @ApiQuery({ + name: 'runId', + description: 'The ID of the run to fetch AI workflow run', + required: true, + }) + @ApiResponse({ + status: 201, + description: 'The AI workflow run for the given ID.', + }) + @ApiResponse({ status: 403, description: 'Forbidden.' }) + async getRun( + @Param('workflowId') workflowId: string, + @Query('runId') runId: string, + @User() user: JwtUser, + ) { + const runs = await this.aiWorkflowService.getWorkflowRuns( + workflowId, + user, + { + runId, + }, + ); + + return runs[0]; + } } diff --git a/src/shared/enums/challengeStatus.enum.ts b/src/shared/enums/challengeStatus.enum.ts new file mode 100644 index 0000000..8b42c98 --- /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', +} \ No newline at end of file diff --git a/src/shared/modules/global/challenge.service.ts b/src/shared/modules/global/challenge.service.ts index 439c3a6..de0f576 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 ChallengeData { id: string; @@ -14,6 +15,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 8c7d9c1..37640fe 100644 --- a/src/shared/modules/global/resource.service.ts +++ b/src/shared/modules/global/resource.service.ts @@ -86,6 +86,29 @@ export class ResourceApiService { } } + /** + * Fetch list of role resources + * + * @returns resolves to list of resouce 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 fole * @@ -99,27 +122,16 @@ export class ResourceApiService { requiredRoles: string[], authUser: JwtUser, challengeId: string, - resourceId: 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) { From 2a92e02a8ac0d39c49db37c641b82d80fd14003b Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 10 Sep 2025 12:08:06 +0300 Subject: [PATCH 2/6] pr fixes --- src/api/ai-workflow/ai-workflow.controller.ts | 8 +++++--- src/api/ai-workflow/ai-workflow.service.ts | 3 ++- src/shared/enums/challengeStatus.enum.ts | 2 +- src/shared/enums/userRole.enum.ts | 1 + src/shared/modules/global/resource.service.ts | 10 +++++----- 5 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/api/ai-workflow/ai-workflow.controller.ts b/src/api/ai-workflow/ai-workflow.controller.ts index 4416e12..b9d9b46 100644 --- a/src/api/ai-workflow/ai-workflow.controller.ts +++ b/src/api/ai-workflow/ai-workflow.controller.ts @@ -27,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() @@ -110,7 +112,7 @@ export class AiWorkflowController { required: true, }) @ApiResponse({ - status: 201, + status: 200, description: 'The AI workflow runs for the given submission ID.', }) @ApiResponse({ status: 403, description: 'Forbidden.' }) @@ -130,13 +132,13 @@ export class AiWorkflowController { @ApiOperation({ summary: 'Get an AI workflow runsby its ID', }) - @ApiQuery({ + @ApiParam({ name: 'runId', description: 'The ID of the run to fetch AI workflow run', required: true, }) @ApiResponse({ - status: 201, + status: 200, description: 'The AI workflow run for the given ID.', }) @ApiResponse({ status: 403, description: 'Forbidden.' }) diff --git a/src/api/ai-workflow/ai-workflow.service.ts b/src/api/ai-workflow/ai-workflow.service.ts index 8c4eae5..87c238b 100644 --- a/src/api/ai-workflow/ai-workflow.service.ts +++ b/src/api/ai-workflow/ai-workflow.service.ts @@ -205,7 +205,8 @@ export class AiWorkflowService { ); } - if (!user.isMachine && !user.roles?.includes(UserRole.Admin)) { + const isM2mOrAdmin = user.isMachine || user.roles?.includes(UserRole.Admin); + if (!isM2mOrAdmin) { const requiredRoles = [ UserRole.Reviewer, UserRole.Copilot, diff --git a/src/shared/enums/challengeStatus.enum.ts b/src/shared/enums/challengeStatus.enum.ts index 8b42c98..6993f24 100644 --- a/src/shared/enums/challengeStatus.enum.ts +++ b/src/shared/enums/challengeStatus.enum.ts @@ -14,4 +14,4 @@ export enum ChallengeStatus { CANCELLED_REQUIREMENTS_INFEASIBLE = 'CANCELLED_REQUIREMENTS_INFEASIBLE', CANCELLED_ZERO_REGISTRATIONS = 'CANCELLED_ZERO_REGISTRATIONS', CANCELLED_PAYMENT_FAILED = 'CANCELLED_PAYMENT_FAILED', -} \ No newline at end of file +} diff --git a/src/shared/enums/userRole.enum.ts b/src/shared/enums/userRole.enum.ts index f33d1ad..2da33b5 100644 --- a/src/shared/enums/userRole.enum.ts +++ b/src/shared/enums/userRole.enum.ts @@ -5,5 +5,6 @@ export enum UserRole { Admin = 'administrator', Copilot = 'copilot', Reviewer = 'reviewer', + Submitter = 'Submitter', User = 'Topcoder User', } diff --git a/src/shared/modules/global/resource.service.ts b/src/shared/modules/global/resource.service.ts index e4be94f..495a1a0 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; @@ -92,7 +92,7 @@ export class ResourceApiService { /** * Fetch list of role resources * - * @returns resolves to list of resouce info + * @returns resolves to list of resource info */ async getMemberResourcesRoles( challengeId?: string, @@ -113,7 +113,7 @@ export class ResourceApiService { } /** - * Validate resource fole + * Validate resource role * * @param requiredRoles list of require roles * @param authUser login user info @@ -125,7 +125,7 @@ export class ResourceApiService { requiredRoles: string[], authUser: JwtUser, challengeId: string, - resourceId?: string, + resourceId: string, ): Promise { const myResources = ( await this.getMemberResourcesRoles(challengeId, authUser.userId) From 9dedbdaeb8c3269bf67431043446cb9b99a473e2 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 10 Sep 2025 12:15:23 +0300 Subject: [PATCH 3/6] typo --- src/api/ai-workflow/ai-workflow.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/ai-workflow/ai-workflow.controller.ts b/src/api/ai-workflow/ai-workflow.controller.ts index b9d9b46..17552ad 100644 --- a/src/api/ai-workflow/ai-workflow.controller.ts +++ b/src/api/ai-workflow/ai-workflow.controller.ts @@ -130,7 +130,7 @@ export class AiWorkflowController { @Roles(UserRole.Admin) @Scopes(Scope.ReadWorkflowRuns) @ApiOperation({ - summary: 'Get an AI workflow runsby its ID', + summary: 'Get an AI workflow run by its ID', }) @ApiParam({ name: 'runId', From b1c742b14586c3143b090381f60eb457616882a3 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Wed, 10 Sep 2025 13:08:10 +0300 Subject: [PATCH 4/6] fix param --- src/api/ai-workflow/ai-workflow.controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/ai-workflow/ai-workflow.controller.ts b/src/api/ai-workflow/ai-workflow.controller.ts index 17552ad..a64198b 100644 --- a/src/api/ai-workflow/ai-workflow.controller.ts +++ b/src/api/ai-workflow/ai-workflow.controller.ts @@ -144,7 +144,7 @@ export class AiWorkflowController { @ApiResponse({ status: 403, description: 'Forbidden.' }) async getRun( @Param('workflowId') workflowId: string, - @Query('runId') runId: string, + @Param('runId') runId: string, @User() user: JwtUser, ) { const runs = await this.aiWorkflowService.getWorkflowRuns( From 1dfe410c4c69c5163acfe278a1baa447bc4b6d6b Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 11 Sep 2025 15:22:22 +0300 Subject: [PATCH 5/6] add project manager role --- src/api/ai-workflow/ai-workflow.controller.ts | 8 +++++++- src/api/ai-workflow/ai-workflow.service.ts | 1 + src/shared/enums/userRole.enum.ts | 1 + 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/api/ai-workflow/ai-workflow.controller.ts b/src/api/ai-workflow/ai-workflow.controller.ts index a64198b..446ee56 100644 --- a/src/api/ai-workflow/ai-workflow.controller.ts +++ b/src/api/ai-workflow/ai-workflow.controller.ts @@ -127,7 +127,13 @@ export class AiWorkflowController { } @Get('/:workflowId/runs/:runId') - @Roles(UserRole.Admin) + @Roles( + UserRole.Admin, + UserRole.Copilot, + UserRole.ProjectManager, + UserRole.Reviewer, + UserRole.Submitter, + ) @Scopes(Scope.ReadWorkflowRuns) @ApiOperation({ summary: 'Get an AI workflow run by its ID', diff --git a/src/api/ai-workflow/ai-workflow.service.ts b/src/api/ai-workflow/ai-workflow.service.ts index 87c238b..e9b9091 100644 --- a/src/api/ai-workflow/ai-workflow.service.ts +++ b/src/api/ai-workflow/ai-workflow.service.ts @@ -209,6 +209,7 @@ export class AiWorkflowService { if (!isM2mOrAdmin) { const requiredRoles = [ UserRole.Reviewer, + UserRole.ProjectManager, UserRole.Copilot, UserRole.Submitter, ].map((r) => r.toLowerCase()); diff --git a/src/shared/enums/userRole.enum.ts b/src/shared/enums/userRole.enum.ts index 2da33b5..e9b3c68 100644 --- a/src/shared/enums/userRole.enum.ts +++ b/src/shared/enums/userRole.enum.ts @@ -6,5 +6,6 @@ export enum UserRole { Copilot = 'copilot', Reviewer = 'reviewer', Submitter = 'Submitter', + ProjectManager = 'Manager', User = 'Topcoder User', } From baf6952dcb419a6c1d120df0d4d5be73588d2475 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Fri, 12 Sep 2025 18:49:58 +0300 Subject: [PATCH 6/6] lint fixes --- src/api/ai-workflow/ai-workflow.controller.ts | 8 +++++++- src/dto/aiWorkflow.dto.ts | 8 +++++++- src/shared/modules/global/challenge.service.ts | 2 +- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/api/ai-workflow/ai-workflow.controller.ts b/src/api/ai-workflow/ai-workflow.controller.ts index 446ee56..7974c61 100644 --- a/src/api/ai-workflow/ai-workflow.controller.ts +++ b/src/api/ai-workflow/ai-workflow.controller.ts @@ -101,7 +101,13 @@ export class AiWorkflowController { } @Get('/:workflowId/runs') - @Roles(UserRole.Admin) + @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', 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/modules/global/challenge.service.ts b/src/shared/modules/global/challenge.service.ts index 8a238d4..f272bf1 100644 --- a/src/shared/modules/global/challenge.service.ts +++ b/src/shared/modules/global/challenge.service.ts @@ -25,7 +25,7 @@ export class ChallengeData { track?: string | undefined; subTrack?: string | undefined; }; - status: ChallengeStatus + status: ChallengeStatus; numOfSubmissions?: number | undefined; track: string; legacyId: number;