Skip to content
1 change: 1 addition & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ workflows:
only:
- develop
- feat/ai-workflows
- pm-1793

- 'build-prod':
context: org-global
Expand Down
85 changes: 33 additions & 52 deletions prisma/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ const modelMappingKeys = [
'review_item_comment',
'llm_provider',
'llm_model',
'ai_workflow'
'ai_workflow',
];
const subModelMappingKeys = {
review_item_comment: ['reviewItemComment', 'appeal', 'appealResponse'],
Expand Down Expand Up @@ -813,27 +813,26 @@ async function processType(type: string, subtype?: string) {
}
case 'scorecard': {
console.log(`[${type}][${file}] Processing file`);
const processedData = jsonData[key]
.map((sc) => {
const id = nanoid(14);
scorecardIdMap.set(sc.scorecard_id, id);
return {
id: id,
legacyId: sc.scorecard_id,
status: scorecardStatusMap[sc.scorecard_status_id],
type: scorecardTypeMap[sc.scorecard_type_id],
challengeTrack: projectCategoryMap[sc.project_category_id].type,
challengeType: projectCategoryMap[sc.project_category_id].name,
name: sc.name,
version: sc.version,
minScore: parseFloat(sc.min_score),
maxScore: parseFloat(sc.max_score),
createdAt: new Date(sc.create_date),
createdBy: sc.create_user,
updatedAt: new Date(sc.modify_date),
updatedBy: sc.modify_user,
};
});
const processedData = jsonData[key].map((sc) => {
const id = nanoid(14);
scorecardIdMap.set(sc.scorecard_id, id);
return {
id: id,
legacyId: sc.scorecard_id,
status: scorecardStatusMap[sc.scorecard_status_id],
type: scorecardTypeMap[sc.scorecard_type_id],
challengeTrack: projectCategoryMap[sc.project_category_id].type,
challengeType: projectCategoryMap[sc.project_category_id].name,
name: sc.name,
version: sc.version,
minScore: parseFloat(sc.min_score),
maxScore: parseFloat(sc.max_score),
createdAt: new Date(sc.create_date),
createdBy: sc.create_user,
updatedAt: new Date(sc.modify_date),
updatedBy: sc.modify_user,
};
});
const totalBatches = Math.ceil(processedData.length / batchSize);
for (let i = 0; i < processedData.length; i += batchSize) {
const batchIndex = i / batchSize + 1;
Expand Down Expand Up @@ -1350,13 +1349,9 @@ async function processType(type: string, subtype?: string) {
case 'llm_provider': {
console.log(`[${type}][${subtype}][${file}] Processing file`);
const idToLegacyIdMap = {};
const processedData = jsonData[key]
.map((c) => {
const processedData = jsonData[key].map((c) => {
const id = nanoid(14);
llmProviderIdMap.set(
c.llm_provider_id,
id,
);
llmProviderIdMap.set(c.llm_provider_id, id);
idToLegacyIdMap[id] = c.llm_provider_id;
return {
id: id,
Expand Down Expand Up @@ -1387,9 +1382,7 @@ async function processType(type: string, subtype?: string) {
data: item,
})
.catch((err) => {
llmProviderIdMap.delete(
idToLegacyIdMap[item.id],
);
llmProviderIdMap.delete(idToLegacyIdMap[item.id]);
console.error(
`[${type}][${subtype}][${file}] Error code: ${err.code}, LegacyId: ${idToLegacyIdMap[item.id]}`,
);
Expand All @@ -1402,15 +1395,11 @@ async function processType(type: string, subtype?: string) {
case 'llm_model': {
console.log(`[${type}][${subtype}][${file}] Processing file`);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The log statement uses the variable file, but file is not defined within the current scope. Ensure that file is defined or passed to the function if needed.

const idToLegacyIdMap = {};
const processedData = jsonData[key]
.map((c) => {
const processedData = jsonData[key].map((c) => {
const id = nanoid(14);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider using a more descriptive variable name than c for better readability and maintainability of the code.

llmModelIdMap.set(
c.llm_model_id,
id,
);
llmModelIdMap.set(c.llm_model_id, id);
idToLegacyIdMap[id] = c.llm_model_id;
console.log(llmProviderIdMap.get(c.provider_id), 'c.provider_id')
console.log(llmProviderIdMap.get(c.provider_id), 'c.provider_id');
return {
id: id,
providerId: llmProviderIdMap.get(c.provider_id),
Expand All @@ -1423,7 +1412,7 @@ async function processType(type: string, subtype?: string) {
};
});

console.log(llmProviderIdMap, processedData, 'processedData')
console.log(llmProviderIdMap, processedData, 'processedData');

const totalBatches = Math.ceil(processedData.length / batchSize);
for (let i = 0; i < processedData.length; i += batchSize) {
Expand All @@ -1446,9 +1435,7 @@ async function processType(type: string, subtype?: string) {
data: item,
})
.catch((err) => {
llmModelIdMap.delete(
idToLegacyIdMap[item.id],
);
llmModelIdMap.delete(idToLegacyIdMap[item.id]);
console.error(
`[${type}][${subtype}][${file}] Error code: ${err.code}, LegacyId: ${idToLegacyIdMap[item.id]}`,
);
Expand All @@ -1461,13 +1448,9 @@ async function processType(type: string, subtype?: string) {
case 'ai_workflow': {
console.log(`[${type}][${subtype}][${file}] Processing file`);
const idToLegacyIdMap = {};
const processedData = jsonData[key]
.map((c) => {
const processedData = jsonData[key].map((c) => {
const id = nanoid(14);
aiWorkflowIdMap.set(
c.ai_workflow_id,
id,
);
aiWorkflowIdMap.set(c.ai_workflow_id, id);
idToLegacyIdMap[id] = c.ai_workflow_id;
return {
id: id,
Expand Down Expand Up @@ -1506,9 +1489,7 @@ async function processType(type: string, subtype?: string) {
data: item,
})
.catch((err) => {
aiWorkflowIdMap.delete(
idToLegacyIdMap[item.id],
);
aiWorkflowIdMap.delete(idToLegacyIdMap[item.id]);
console.error(
`[${type}][${subtype}][${file}] Error code: ${err.code}, LegacyId: ${idToLegacyIdMap[item.id]}`,
);
Expand Down Expand Up @@ -1687,7 +1668,7 @@ migrate()
{ key: 'submissionIdMap', value: submissionIdMap },
{ key: 'llmProviderIdMap', value: llmProviderIdMap },
{ key: 'llmModelIdMap', value: llmModelIdMap },
{ key: 'aiWorkflowIdMap', value: aiWorkflowIdMap }
{ key: 'aiWorkflowIdMap', value: aiWorkflowIdMap },
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is an extra comma at the end of the object in line 1671. This may lead to syntax errors in environments that do not support trailing commas. Consider removing the trailing comma.

].forEach((f) => {
if (!fs.existsSync('.tmp')) {
fs.mkdirSync('.tmp');
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- AlterTable
ALTER TABLE "aiWorkflow" ALTER COLUMN "updatedAt" DROP NOT NULL,
ALTER COLUMN "updatedBy" DROP NOT NULL;

-- AlterTable
ALTER TABLE "aiWorkflowRunItem" ALTER COLUMN "createdAt" DROP NOT NULL,
ALTER COLUMN "createdBy" DROP NOT NULL;

-- AlterTable
ALTER TABLE "aiWorkflowRunItemComment" ALTER COLUMN "updatedAt" DROP NOT NULL,
ALTER COLUMN "updatedBy" DROP NOT NULL;
60 changes: 30 additions & 30 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -570,18 +570,18 @@ model llmModel {
}

model aiWorkflow {
id String @id @default(dbgenerated("nanoid()")) @db.VarChar(14)
name String @unique @db.VarChar
llmId String @db.VarChar(14)
description String @db.Text
defUrl String @db.VarChar
gitId String @db.VarChar
gitOwner String @db.VarChar
scorecardId String @db.VarChar(14)
createdAt DateTime @default(now()) @db.Timestamp(3)
createdBy String @db.Text
updatedAt DateTime @db.Timestamp(3)
updatedBy String @db.Text
id String @id @default(dbgenerated("nanoid()")) @db.VarChar(14)
name String @unique @db.VarChar
llmId String @db.VarChar(14)
description String @db.Text
defUrl String @db.VarChar
gitId String @db.VarChar
gitOwner String @db.VarChar
scorecardId String @db.VarChar(14)
createdAt DateTime @default(now()) @db.Timestamp(3)
createdBy String @db.Text
updatedAt DateTime? @db.Timestamp(3)
updatedBy String? @db.Text

llm llmModel @relation(fields: [llmId], references: [id])
scorecard scorecard @relation(fields: [scorecardId], references: [id])
Expand All @@ -604,31 +604,31 @@ model aiWorkflowRun {
}

model aiWorkflowRunItem {
id String @id @default(dbgenerated("nanoid()")) @db.VarChar(14)
workflowRunId String @db.VarChar(14)
scorecardQuestionId String @db.VarChar(14)
content String @db.Text
upVotes Int @default(0)
downVotes Int @default(0)
questionScore Float? @db.DoublePrecision
createdAt DateTime @db.Timestamp(3)
createdBy String @db.Text
id String @id @default(dbgenerated("nanoid()")) @db.VarChar(14)
workflowRunId String @db.VarChar(14)
scorecardQuestionId String @db.VarChar(14)
content String @db.Text
upVotes Int @default(0)
downVotes Int @default(0)
questionScore Float? @db.DoublePrecision
createdAt DateTime? @db.Timestamp(3)
createdBy String? @db.Text

run aiWorkflowRun @relation(fields: [workflowRunId], references: [id])
question scorecardQuestion @relation(fields: [scorecardQuestionId], references: [id])
comments aiWorkflowRunItemComment[]
}

model aiWorkflowRunItemComment {
id String @id @default(dbgenerated("nanoid()")) @db.VarChar(14)
workflowRunItemId String @db.VarChar(14)
userId String @db.Text
content String @db.Text
parentId String? @db.VarChar(14)
createdAt DateTime @default(now()) @db.Timestamp(3)
createdBy String @db.Text
updatedAt DateTime @db.Timestamp(3)
updatedBy String @db.Text
id String @id @default(dbgenerated("nanoid()")) @db.VarChar(14)
workflowRunItemId String @db.VarChar(14)
userId String @db.Text
content String @db.Text
parentId String? @db.VarChar(14)
createdAt DateTime @default(now()) @db.Timestamp(3)
createdBy String @db.Text
updatedAt DateTime? @db.Timestamp(3)
updatedBy String? @db.Text

item aiWorkflowRunItem @relation(fields: [workflowRunItemId], references: [id])
parent aiWorkflowRunItemComment? @relation("CommentHierarchy", fields: [parentId], references: [id])
Expand Down
33 changes: 33 additions & 0 deletions src/api/ai-workflow/ai-workflow.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Controller, Post, Body } from '@nestjs/common';
import {
ApiBearerAuth,
ApiTags,
ApiOperation,
ApiResponse,
} from '@nestjs/swagger';
import { AiWorkflowService } from './ai-workflow.service';
import { CreateAiWorkflowDto } from '../../dto/aiWorkflow.dto';
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';

@ApiTags('ai_workflow')
@ApiBearerAuth()
@Controller('/workflows')
export class AiWorkflowController {
constructor(private readonly aiWorkflowService: AiWorkflowService) {}

@Post()
@Roles(UserRole.Admin)
@Scopes(Scope.CreateWorkflow)
@ApiOperation({ summary: 'Create a new AI workflow' })
@ApiResponse({
status: 201,
description: 'The AI workflow has been successfully created.',
})
@ApiResponse({ status: 403, description: 'Forbidden.' })
async create(@Body() createAiWorkflowDto: CreateAiWorkflowDto) {
return this.aiWorkflowService.createWithValidation(createAiWorkflowDto);
}
}
49 changes: 49 additions & 0 deletions src/api/ai-workflow/ai-workflow.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Injectable, BadRequestException } from '@nestjs/common';
import { PrismaService } from '../../shared/modules/global/prisma.service';
import { CreateAiWorkflowDto } from '../../dto/aiWorkflow.dto';
import { ScorecardStatus } from 'src/dto/scorecard.dto';

@Injectable()
export class AiWorkflowService {
constructor(private readonly prisma: PrismaService) {}

async scorecardExists(scorecardId: string): Promise<boolean> {
const count = await this.prisma.scorecard.count({
where: { id: scorecardId, status: ScorecardStatus.ACTIVE },
});
return count > 0;
}

async llmModelExists(llmId: string): Promise<boolean> {
const count = await this.prisma.llmModel.count({
where: { id: llmId },
});
return count > 0;
}

async createWithValidation(createAiWorkflowDto: CreateAiWorkflowDto) {
const { scorecardId, llmId, ...rest } = createAiWorkflowDto;

const scorecardExists = await this.scorecardExists(scorecardId);
if (!scorecardExists) {
throw new BadRequestException(
`Scorecard with id ${scorecardId} does not exist or is not active.`,
);
}

const llmExists = await this.llmModelExists(llmId);
if (!llmExists) {
throw new BadRequestException(
`LLM model with id ${llmId} does not exist.`,
);
}

return this.prisma.aiWorkflow.create({
data: {
...rest,
Copy link
Collaborator

@vas3a vas3a Sep 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you shouldn't do ...rest in here. you should extract the data you need and then pass it to the db.

@kkartunov @jmgasper this is an issue with the current global config for the ValidationPipe. Currently it is configured with whitelist: false - which will not strip off any additional data sent to the API.
This is bad IMO. like in this example, if we don't make sure to manually strip off excess data, it could be passed to DB.
It can get even uglier if the user sends fields that are not validated through DTO but they exist in db (eg. createdBy shouldn't be accepted through API, and we shouldn't add it in DTO, BUT this means that this gets sent to DB without validation).

This could be fixed by either usingwhitelist: true, but might cause other errors if someone used whitelist: false intentionally (we might do it anyway, it's dev only atm and can fix it), OR use forbidNonWhitelisted: true which will throw error if you send additional data. This should be more obvious.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I do agree with @vas3a. @jmgasper we plan to have whitelist: false in the dto here. What do you think?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@hentrymartin please update the code to not use ...rest but pick fields.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vas3a As the audit fields are not null fields, I am passing empty string for now to avoid type errors, once the Prisma middleware is done, then it should be removed.

scorecardId,
llmId,
},
});
}
}
4 changes: 4 additions & 0 deletions src/api/api.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ 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';
import { AiWorkflowService } from './ai-workflow/ai-workflow.service';
import { AiWorkflowController } from './ai-workflow/ai-workflow.controller';

@Module({
imports: [HttpModule, GlobalProvidersModule, FileUploadModule],
Expand All @@ -42,6 +44,7 @@ import { ScoreCardService } from './scorecard/scorecard.service';
ReviewApplicationController,
ReviewHistoryController,
WebhookController,
AiWorkflowController,
],
providers: [
ReviewOpportunityService,
Expand All @@ -53,6 +56,7 @@ import { ScoreCardService } from './scorecard/scorecard.service';
ScoreCardService,
SubmissionService,
ReviewSummationService,
AiWorkflowService,
],
})
export class ApiModule {}
Loading