From 21909eaee9824a4af328b1ea29d18bb923a51848 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 21 Aug 2025 16:36:39 +0300 Subject: [PATCH 1/3] PM-1504 - scorecard: make sure to handle scorecard update properly --- src/api/scorecard/scorecard.service.ts | 179 +++++++++++++++++++++---- src/dto/scorecard.dto.ts | 78 +---------- 2 files changed, 161 insertions(+), 96 deletions(-) diff --git a/src/api/scorecard/scorecard.service.ts b/src/api/scorecard/scorecard.service.ts index b7f52a2..16082a7 100644 --- a/src/api/scorecard/scorecard.service.ts +++ b/src/api/scorecard/scorecard.service.ts @@ -3,10 +3,14 @@ import { InternalServerErrorException, NotFoundException, } from '@nestjs/common'; -import { Prisma } from '@prisma/client'; +import { + Prisma, + scorecardGroup, + scorecardQuestion, + scorecardSection, +} from '@prisma/client'; import { mapScorecardRequestForCreate, - mapScorecardRequestToDto, ScorecardGroupBaseDto, ScorecardPaginatedResponseDto, ScorecardQueryDto, @@ -18,10 +22,36 @@ import { } from 'src/dto/scorecard.dto'; import { JwtUser } from 'src/shared/modules/global/jwt.service'; import { PrismaService } from 'src/shared/modules/global/prisma.service'; +import { omit } from 'lodash'; +import { LoggerService } from 'src/shared/modules/global/logger.service'; + +type ChildEntity = scorecardGroup | scorecardSection | scorecardQuestion; + +interface SyncChildrenParams { + parentId: string; + parentField: keyof T; // "scorecardId" | "groupId" | "sectionId" + model: { + create: (args: { data: any }) => Promise; + update: (args: { where: { id: string }; data: any }) => Promise; + deleteMany: (args: { where: { id: { in: string[] } } }) => Promise; + findMany: (args: { where: any }) => Promise; + }; + incoming: (T & { + sections?: scorecardSection[]; + questions?: scorecardQuestion[]; + })[]; + existing: T[]; + cascade?: (child: any) => Promise; + userId: string; +} @Injectable() export class ScoreCardService { - constructor(private readonly prisma: PrismaService) {} + private readonly logger: LoggerService; + + constructor(private readonly prisma: PrismaService) { + this.logger = LoggerService.forRoot('ScorecardService'); + } /** * Adds score card @@ -62,26 +92,78 @@ export class ScoreCardService { * @returns ScorecardWithGroupResponseDto */ async editScorecard( - id: string, - body: ScorecardRequestDto, + scorecardId: string, + scorecardInput: ScorecardRequestDto, user: JwtUser, ): Promise { const original = await this.prisma.scorecard.findUnique({ - where: { id }, + where: { id: scorecardId }, }); if (!original) { throw new NotFoundException({ message: `Scorecard not found.` }); } - const data = await this.prisma.scorecard - .update({ - where: { id }, - data: mapScorecardRequestToDto({ - ...body, - createdBy: user.isMachine ? 'System' : (user.userId as string), - updatedBy: user.isMachine ? 'System' : (user.userId as string), - }) as any, + const userId = user.isMachine ? 'System' : (user.userId as string); + + this.logger.log( + `[updateScorecard] Updating scorecard with id: ${scorecardId}`, + ); + + return this.prisma.$transaction(async (tx) => { + // Update scorecard basic info + const updatedScorecard = await tx.scorecard.update({ + where: { id: scorecardId }, + data: { name: scorecardInput.name }, + }); + this.logger.log( + `[updateScorecard] Updated scorecard basic info: ${JSON.stringify(updatedScorecard)}`, + ); + + // Sync groups + await this.syncChildren({ + parentId: scorecardId, + parentField: 'scorecardId', + model: tx.scorecardGroup, + incoming: scorecardInput.scorecardGroups as any, + existing: await tx.scorecardGroup.findMany({ + where: { scorecardId: scorecardId }, + }), + userId, + cascade: async (groupInput) => { + // Sync sections + await this.syncChildren({ + parentId: groupInput.id, + parentField: 'scorecardGroupId', + model: tx.scorecardSection, + incoming: groupInput.sections ?? [], + existing: await tx.scorecardSection.findMany({ + where: { scorecardGroupId: groupInput.id }, + }), + userId, + cascade: async (sectionInput) => { + // Sync questions + await this.syncChildren({ + parentId: sectionInput.id, + parentField: 'scorecardSectionId', + model: tx.scorecardQuestion, + incoming: sectionInput.questions ?? [], + existing: await tx.scorecardQuestion.findMany({ + where: { scorecardSectionId: sectionInput.id }, + }), + userId, + }); + }, + }); + }, + }); + + this.logger.log( + `[updateScorecard] Finished syncing groups, sections, and questions for scorecard ${scorecardId}`, + ); + + return this.prisma.scorecard.findUnique({ + where: { id: scorecardId }, include: { scorecardGroups: { include: { @@ -93,17 +175,68 @@ export class ScoreCardService { }, }, }, - }) - .catch((error) => { - if (error.code !== 'P2025') { - throw new NotFoundException({ message: `Scorecard not found.` }); - } - throw new InternalServerErrorException({ - message: `Error: ${error.code}`, + }) as Promise; + }); + } + + async syncChildren({ + parentId, + parentField, + model, + incoming, + existing, + cascade, + userId, + }: SyncChildrenParams): Promise { + const incomingIds = incoming.filter((c) => c.id).map((c) => c.id); + const existingIds = existing.map((e) => e.id); + + // Delete removed children + const toDelete = existingIds.filter((id) => !incomingIds.includes(id)); + if (toDelete.length > 0) { + this.logger.log( + `[syncChildren] Deleting children with ids: ${toDelete.join(',')}`, + ); + await model.deleteMany({ where: { id: { in: toDelete } } }); + } + + // Upsert incoming + for (const child of incoming) { + if (child.id) { + this.logger.log(`[syncChildren] Updating child with id: ${child.id}`); + await model.update({ + where: { id: child.id }, + data: { + ...omit(child, ['sections', 'questions']), + updatedBy: userId, + }, // ⚠️ Adjust to include other fields as needed }); - }); + } else { + this.logger.log( + `[syncChildren] Creating new child for parentId: ${parentId}`, + ); + const created = await model.create({ + data: { + ...omit(child, ['sections', 'questions']), + [parentField]: parentId, + createdBy: userId, + updatedBy: userId, + }, + }); + child.id = created.id; // assign id back to input + this.logger.log( + `[syncChildren] Created child with new id: ${created.id}`, + ); + } - return data as unknown as ScorecardWithGroupResponseDto; + // Cascade to nested children + if (cascade) { + await cascade(child); + } + } + this.logger.log( + `[syncChildren] Finished syncing children for parentId: ${parentId}`, + ); } /** diff --git a/src/dto/scorecard.dto.ts b/src/dto/scorecard.dto.ts index e52f737..c310bec 100644 --- a/src/dto/scorecard.dto.ts +++ b/src/dto/scorecard.dto.ts @@ -72,6 +72,7 @@ export class ScorecardQuestionBaseDto { example: 'Provide detailed information.', }) @IsString() + @IsNotEmpty() guidelines: string; @ApiProperty({ description: 'The weight of the question', example: 10 }) @@ -105,6 +106,10 @@ export class ScorecardQuestionBaseDto { @IsOptional() @IsNumber() scaleMax?: number; + + @ApiProperty({ description: 'Sort order of the question', example: 1 }) + @IsNumber() + sortOrder: number; } export class ScorecardQuestionRequestDto extends ScorecardQuestionBaseDto {} @@ -374,76 +379,3 @@ export function mapScorecardRequestForCreate(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, - })), - }, - })), - }, - }, - })), - }, - }; -} From b5a85b1ae305fdde6b35e0310c1572369e0f1ef5 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 21 Aug 2025 16:40:57 +0300 Subject: [PATCH 2/3] cleanup --- src/api/scorecard/scorecard.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/scorecard/scorecard.service.ts b/src/api/scorecard/scorecard.service.ts index 16082a7..989fe58 100644 --- a/src/api/scorecard/scorecard.service.ts +++ b/src/api/scorecard/scorecard.service.ts @@ -209,7 +209,7 @@ export class ScoreCardService { data: { ...omit(child, ['sections', 'questions']), updatedBy: userId, - }, // ⚠️ Adjust to include other fields as needed + }, }); } else { this.logger.log( From 19bc1ed0d663c37fc4656b59821b9942baf7205c Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Thu, 21 Aug 2025 17:30:25 +0300 Subject: [PATCH 3/3] Make sure all fields for scorecard are saved --- src/api/scorecard/scorecard.service.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/api/scorecard/scorecard.service.ts b/src/api/scorecard/scorecard.service.ts index 989fe58..59c57cd 100644 --- a/src/api/scorecard/scorecard.service.ts +++ b/src/api/scorecard/scorecard.service.ts @@ -114,7 +114,9 @@ export class ScoreCardService { // Update scorecard basic info const updatedScorecard = await tx.scorecard.update({ where: { id: scorecardId }, - data: { name: scorecardInput.name }, + data: { + ...omit(scorecardInput, 'scorecardGroups'), + }, }); this.logger.log( `[updateScorecard] Updated scorecard basic info: ${JSON.stringify(updatedScorecard)}`,