diff --git a/prisma/migrations/20250912082808_make_audit_fields_optional/migration.sql b/prisma/migrations/20250912082808_make_audit_fields_optional/migration.sql new file mode 100644 index 0000000..d7475c7 --- /dev/null +++ b/prisma/migrations/20250912082808_make_audit_fields_optional/migration.sql @@ -0,0 +1,81 @@ +-- -- AlterTable +-- ALTER TABLE "aiWorkflow" ALTER COLUMN "createdBy" DROP NOT NULL, +-- ALTER COLUMN "updatedBy" DROP NOT NULL; + +-- -- AlterTable +-- ALTER TABLE "aiWorkflowRunItem" ALTER COLUMN "createdBy" DROP NOT NULL; + +-- -- AlterTable +-- ALTER TABLE "aiWorkflowRunItemComment" ALTER COLUMN "createdBy" DROP NOT NULL, +-- ALTER COLUMN "updatedBy" DROP NOT NULL; + +-- -- AlterTable +-- ALTER TABLE "appeal" ALTER COLUMN "createdBy" DROP NOT NULL, +-- ALTER COLUMN "updatedBy" DROP NOT NULL; + +-- -- AlterTable +-- ALTER TABLE "appealResponse" ALTER COLUMN "createdBy" DROP NOT NULL, +-- ALTER COLUMN "updatedBy" DROP NOT NULL; + +-- -- AlterTable +-- ALTER TABLE "challengeResult" ALTER COLUMN "createdBy" DROP NOT NULL, +-- ALTER COLUMN "updatedBy" DROP NOT NULL; + +-- -- AlterTable +-- ALTER TABLE "contactRequest" ALTER COLUMN "createdBy" DROP NOT NULL, +-- ALTER COLUMN "updatedBy" DROP NOT NULL; + +-- -- AlterTable +-- ALTER TABLE "llmModel" ALTER COLUMN "createdBy" DROP NOT NULL; + +-- -- AlterTable +-- ALTER TABLE "llmProvider" ALTER COLUMN "createdBy" DROP NOT NULL; + +-- -- AlterTable +-- ALTER TABLE "resourceSubmission" ALTER COLUMN "createdBy" DROP NOT NULL; + +-- -- AlterTable +-- ALTER TABLE "review" ALTER COLUMN "createdBy" DROP NOT NULL, +-- ALTER COLUMN "updatedBy" DROP NOT NULL; + +-- -- AlterTable +-- ALTER TABLE "reviewApplication" ALTER COLUMN "createdBy" DROP NOT NULL, +-- ALTER COLUMN "updatedBy" DROP NOT NULL; + +-- -- AlterTable +-- ALTER TABLE "reviewItem" ALTER COLUMN "createdBy" DROP NOT NULL, +-- ALTER COLUMN "updatedBy" DROP NOT NULL; + +-- -- AlterTable +-- ALTER TABLE "reviewItemComment" ALTER COLUMN "createdBy" DROP NOT NULL, +-- ALTER COLUMN "updatedBy" DROP NOT NULL; + +-- -- AlterTable +-- ALTER TABLE "reviewOpportunity" ALTER COLUMN "createdBy" DROP NOT NULL, +-- ALTER COLUMN "updatedBy" DROP NOT NULL; + +-- -- AlterTable +-- ALTER TABLE "reviewSummation" ALTER COLUMN "createdBy" DROP NOT NULL, +-- ALTER COLUMN "updatedBy" DROP NOT NULL; + +-- -- AlterTable +-- ALTER TABLE "scorecard" ALTER COLUMN "createdBy" DROP NOT NULL, +-- ALTER COLUMN "updatedBy" DROP NOT NULL; + +-- -- AlterTable +-- ALTER TABLE "scorecardGroup" ALTER COLUMN "createdBy" DROP NOT NULL, +-- ALTER COLUMN "updatedBy" DROP NOT NULL; + +-- -- AlterTable +-- ALTER TABLE "scorecardQuestion" ALTER COLUMN "createdBy" DROP NOT NULL, +-- ALTER COLUMN "updatedBy" DROP NOT NULL; + +-- -- AlterTable +-- ALTER TABLE "scorecardSection" ALTER COLUMN "createdBy" DROP NOT NULL, +-- ALTER COLUMN "updatedBy" DROP NOT NULL; + +-- -- AlterTable +-- ALTER TABLE "submission" ALTER COLUMN "createdBy" DROP NOT NULL; + +-- -- AlterTable +-- ALTER TABLE "upload" ALTER COLUMN "createdBy" DROP NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 656ea48..e52368b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -26,9 +26,9 @@ model scorecard { minScore Float maxScore Float createdAt DateTime @default(now()) - createdBy String + createdBy String? updatedAt DateTime @updatedAt - updatedBy String + updatedBy String? scorecardGroups scorecardGroup[] reviews review[] @@ -75,9 +75,9 @@ model scorecardGroup { weight Float sortOrder Int createdAt DateTime @default(now()) - createdBy String + createdBy String? updatedAt DateTime @updatedAt - updatedBy String + updatedBy String? scorecard scorecard @relation(fields: [scorecardId], references: [id], onDelete: Cascade) sections scorecardSection[] @@ -95,9 +95,9 @@ model scorecardSection { weight Float sortOrder Int createdAt DateTime @default(now()) - createdBy String + createdBy String? updatedAt DateTime @updatedAt - updatedBy String + updatedBy String? group scorecardGroup @relation(fields: [scorecardGroupId], references: [id], onDelete: Cascade) questions scorecardQuestion[] @@ -125,9 +125,9 @@ model scorecardQuestion { requiresUpload Boolean sortOrder Int createdAt DateTime @default(now()) - createdBy String + createdBy String? updatedAt DateTime @updatedAt - updatedBy String + updatedBy String? scaleMin Int? // Minimum value for scale (used when type is SCALE) scaleMax Int? // Maximum value for scale (used when type is SCALE) @@ -167,9 +167,9 @@ model review { status ReviewStatus? reviewDate DateTime? createdAt DateTime @default(now()) - createdBy String + createdBy String? updatedAt DateTime @updatedAt - updatedBy String + updatedBy String? scorecard scorecard @relation(fields: [scorecardId], references: [id], onDelete: Cascade) submission submission? @relation(fields: [submissionId], references: [id], onDelete: Cascade) @@ -193,9 +193,9 @@ model reviewItem { finalAnswer String? managerComment String? createdAt DateTime @default(now()) - createdBy String + createdBy String? updatedAt DateTime @updatedAt - updatedBy String + updatedBy String? review review @relation(fields: [reviewId], references: [id], onDelete: Cascade) reviewItemComments reviewItemComment[] @@ -214,9 +214,9 @@ model reviewItemComment { type ReviewItemCommentType sortOrder Int @default(0) createdAt DateTime @default(now()) - createdBy String + createdBy String? updatedAt DateTime @updatedAt - updatedBy String + updatedBy String? reviewItem reviewItem @relation(fields: [reviewItemId], references: [id], onDelete: Cascade) appeal appeal? @@ -249,9 +249,9 @@ model appeal { reviewItemCommentId String @unique // Ensure one-to-one relationship content String createdAt DateTime @default(now()) - createdBy String + createdBy String? updatedAt DateTime @updatedAt - updatedBy String + updatedBy String? reviewItemComment reviewItemComment @relation(fields: [reviewItemCommentId], references: [id], onDelete: Cascade) // Define fields and references here appealResponse appealResponse? @@ -269,9 +269,9 @@ model appealResponse { content String success Boolean createdAt DateTime @default(now()) - createdBy String + createdBy String? updatedAt DateTime @updatedAt - updatedBy String + updatedBy String? appeal appeal @relation(fields: [appealId], references: [id], onDelete: Cascade) @@ -297,9 +297,9 @@ model challengeResult { ratingOrder Int? createdAt DateTime @default(now()) - createdBy String + createdBy String? updatedAt DateTime @updatedAt - updatedBy String + updatedBy String? @@id([challengeId, userId]) @@index([challengeId]) // Index for filtering by challenge @@ -313,9 +313,9 @@ model contactRequest { challengeId String // Associated challenge message String // Markdown content createdAt DateTime @default(now()) - createdBy String + createdBy String? updatedAt DateTime @updatedAt - updatedBy String + updatedBy String? @@index([id]) // Index for direct ID lookups @@index([resourceId]) // Index for filtering by resource (requester) @@ -344,9 +344,9 @@ model reviewSummation { isFinal Boolean? reviewedDate DateTime? createdAt DateTime @default(now()) - createdBy String + createdBy String? updatedAt DateTime @updatedAt - updatedBy String + updatedBy String? submission submission @relation(fields: [submissionId], references: [id], onDelete: Cascade) @@ -383,7 +383,7 @@ model submission { submittedDate DateTime? createdAt DateTime @default(now()) - createdBy String + createdBy String? updatedAt DateTime? @updatedAt updatedBy String? @@ -449,9 +449,9 @@ model reviewOpportunity { applications reviewApplication[] createdAt DateTime @default(now()) - createdBy String + createdBy String? updatedAt DateTime @updatedAt - updatedBy String + updatedBy String? @@unique([challengeId, type]) @@index([id]) // Index for direct ID lookups @@ -469,9 +469,9 @@ model reviewApplication { opportunity reviewOpportunity @relation(fields: [opportunityId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) - createdBy String + createdBy String? updatedAt DateTime @updatedAt - updatedBy String + updatedBy String? @@unique([opportunityId, userId, role]) @@index([id]) // Index for direct ID lookups @@ -521,7 +521,7 @@ model upload { projectPhaseId String? createdAt DateTime @default(now()) - createdBy String + createdBy String? updatedAt DateTime? @updatedAt updatedBy String? @@ -538,7 +538,7 @@ model resourceSubmission { legacySubmissionId String? createdAt DateTime @default(now()) - createdBy String + createdBy String? updatedAt DateTime? @updatedAt updatedBy String? @@ -561,7 +561,7 @@ model llmProvider { id String @id @default(dbgenerated("nanoid()")) @db.VarChar(14) name String @unique @db.VarChar createdAt DateTime @db.Timestamp(3) - createdBy String @db.Text + createdBy String? @db.Text models llmModel[] } @@ -574,7 +574,7 @@ model llmModel { icon String? @db.VarChar url String? @db.VarChar createdAt DateTime @default(now()) @db.Timestamp(3) - createdBy String @db.Text + createdBy String? @db.Text provider llmProvider @relation(fields: [providerId], references: [id]) workflows aiWorkflow[] @@ -590,9 +590,9 @@ model aiWorkflow { 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 + createdBy String? @db.Text + updatedAt DateTime @updatedAt + updatedBy String? @db.Text llm llmModel @relation(fields: [llmId], references: [id]) scorecard scorecard @relation(fields: [scorecardId], references: [id]) @@ -623,7 +623,7 @@ model aiWorkflowRunItem { downVotes Int @default(0) questionScore Float? @db.DoublePrecision createdAt DateTime @db.Timestamp(3) - createdBy String @db.Text + createdBy String? @db.Text run aiWorkflowRun @relation(fields: [workflowRunId], references: [id]) question scorecardQuestion @relation(fields: [scorecardQuestionId], references: [id]) @@ -637,9 +637,9 @@ model aiWorkflowRunItemComment { 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 + createdBy String? @db.Text + updatedAt DateTime @updatedAt + updatedBy String? @db.Text item aiWorkflowRunItem @relation(fields: [workflowRunItemId], references: [id]) parent aiWorkflowRunItemComment? @relation("CommentHierarchy", fields: [parentId], references: [id]) diff --git a/src/api/ai-workflow/ai-workflow.service.ts b/src/api/ai-workflow/ai-workflow.service.ts index 62fe289..fbf5314 100644 --- a/src/api/ai-workflow/ai-workflow.service.ts +++ b/src/api/ai-workflow/ai-workflow.service.ts @@ -80,10 +80,6 @@ export class AiWorkflowService { name, scorecardId, llmId, - // TODO: This has to be removed once the prisma middleware is implemented - createdBy: '', - updatedAt: new Date(), - updatedBy: '', }, }) .catch((e) => { diff --git a/src/api/appeal/appeal.controller.ts b/src/api/appeal/appeal.controller.ts index a32ff02..b85f0ff 100644 --- a/src/api/appeal/appeal.controller.ts +++ b/src/api/appeal/appeal.controller.ts @@ -29,7 +29,6 @@ import { AppealResponseDto, AppealResponseRequestDto, AppealResponseResponseDto, - mapAppealRequestToDto, mapAppealResponseRequestToDto, } from 'src/dto/appeal.dto'; import { PrismaService } from '../../shared/modules/global/prisma.service'; @@ -103,7 +102,7 @@ export class AppealController { await this.challengeApiService.validateAppealSubmission(challengeId); const data = await this.prisma.appeal.create({ - data: mapAppealRequestToDto(body), + data: { ...body }, }); this.logger.log(`Appeal created with ID: ${data.id}`); return data as AppealResponseDto; @@ -156,7 +155,7 @@ export class AppealController { try { const data = await this.prisma.appeal.update({ where: { id: appealId }, - data: mapAppealRequestToDto(body), + data: { ...body }, }); this.logger.log(`Appeal updated successfully: ${appealId}`); return data as AppealResponseDto; diff --git a/src/api/contact/contactRequests.service.ts b/src/api/contact/contactRequests.service.ts index 06f564e..0fae9a1 100644 --- a/src/api/contact/contactRequests.service.ts +++ b/src/api/contact/contactRequests.service.ts @@ -10,7 +10,6 @@ import { LoggerService } from 'src/shared/modules/global/logger.service'; import { ContactRequestDto, ContactRequestResponseDto, - mapContactRequestToDto, } from 'src/dto/contactRequest.dto'; import { JwtUser } from 'src/shared/modules/global/jwt.service'; import { @@ -57,7 +56,10 @@ export class ContactRequestsService { // Persist contact request const data = await this.prisma.contactRequest.create({ - data: mapContactRequestToDto(body, requesterResource.id), + data: { + ...body, + resourceId: requesterResource.id, + }, }); // Fire email notification to managers/copilots for the challenge diff --git a/src/api/review-application/reviewApplication.controller.ts b/src/api/review-application/reviewApplication.controller.ts index 698ebf0..1a83d9e 100644 --- a/src/api/review-application/reviewApplication.controller.ts +++ b/src/api/review-application/reviewApplication.controller.ts @@ -147,9 +147,8 @@ export class ReviewApplicationController { @ApiResponse({ status: 500, description: 'Internal Error' }) @Roles(UserRole.Admin) @Patch('/:id/accept') - async approveApplication(@Req() req: Request, @Param('id') id: string) { - const authUser: JwtUser = req['user'] as JwtUser; - await this.service.approve(authUser, id); + async approveApplication(@Param('id') id: string) { + await this.service.approve(id); return OkResponse({}); } @@ -172,9 +171,8 @@ export class ReviewApplicationController { @ApiResponse({ status: 500, description: 'Internal Error' }) @Roles(UserRole.Admin) @Patch('/:id/reject') - async rejectApplication(@Req() req: Request, @Param('id') id: string) { - const authUser: JwtUser = req['user'] as JwtUser; - await this.service.reject(authUser, id); + async rejectApplication(@Param('id') id: string) { + await this.service.reject(id); return OkResponse({}); } @@ -197,12 +195,8 @@ export class ReviewApplicationController { @ApiResponse({ status: 500, description: 'Internal Error' }) @Roles(UserRole.Admin) @Patch('/opportunity/:opportunityId/reject-all') - async rejectAllPending( - @Req() req: Request, - @Param('opportunityId') opportunityId: string, - ) { - const authUser: JwtUser = req['user'] as JwtUser; - await this.service.rejectAllPending(authUser, opportunityId); + async rejectAllPending(@Param('opportunityId') opportunityId: string) { + await this.service.rejectAllPending(opportunityId); return OkResponse({}); } } diff --git a/src/api/review-application/reviewApplication.service.ts b/src/api/review-application/reviewApplication.service.ts index 57ec0ce..1078885 100644 --- a/src/api/review-application/reviewApplication.service.ts +++ b/src/api/review-application/reviewApplication.service.ts @@ -83,8 +83,6 @@ export class ReviewApplicationService { status: ReviewApplicationStatus.PENDING, userId, handle, - createdBy: userId, - updatedBy: userId, }, }); return this.buildResponse(entity); @@ -184,21 +182,16 @@ export class ReviewApplicationService { /** * Approve a review application. - * @param authUser auth user * @param id review application id */ - async approve(authUser: JwtUser, id: string): Promise { + async approve(id: string): Promise { try { const entity = await this.checkExists(id); - const updatedBy = - authUser.userId === null || authUser.userId === undefined - ? '' - : String(authUser.userId); + await this.prisma.reviewApplication.update({ where: { id }, data: { status: ReviewApplicationStatus.APPROVED, - updatedBy, }, }); // send email @@ -223,21 +216,16 @@ export class ReviewApplicationService { /** * Reject a review application. - * @param authUser auth user * @param id review application id */ - async reject(authUser: JwtUser, id: string): Promise { + async reject(id: string): Promise { try { const entity = await this.checkExists(id); - const updatedBy = - authUser.userId === null || authUser.userId === undefined - ? '' - : String(authUser.userId); + await this.prisma.reviewApplication.update({ where: { id }, data: { status: ReviewApplicationStatus.REJECTED, - updatedBy, }, }); // send email @@ -262,13 +250,9 @@ export class ReviewApplicationService { /** * Reject all pending applications of specific opportunity - * @param authUser auth user * @param opportunityId opportunity id */ - async rejectAllPending( - authUser: JwtUser, - opportunityId: string, - ): Promise { + async rejectAllPending(opportunityId: string): Promise { try { // select all pending const entityList = await this.prisma.reviewApplication.findMany({ @@ -276,15 +260,10 @@ export class ReviewApplicationService { include: { opportunity: true }, }); // update all pending - const updatedBy = - authUser.userId === null || authUser.userId === undefined - ? '' - : String(authUser.userId); await this.prisma.reviewApplication.updateMany({ where: { opportunityId, status: ReviewApplicationStatus.PENDING }, data: { status: ReviewApplicationStatus.REJECTED, - updatedBy, }, }); // send emails to these users diff --git a/src/api/review-opportunity/reviewOpportunity.controller.ts b/src/api/review-opportunity/reviewOpportunity.controller.ts index 6e03fcc..c3fa38a 100644 --- a/src/api/review-opportunity/reviewOpportunity.controller.ts +++ b/src/api/review-opportunity/reviewOpportunity.controller.ts @@ -226,12 +226,10 @@ export class ReviewOpportunityController { @Roles(UserRole.Admin, UserRole.Copilot) @Scopes(Scope.UpdateReviewOpportunity, Scope.AllReviewOpportunity) async updateById( - @Req() req: Request, @Param('id') id: string, @Body() dto: UpdateReviewOpportunityDto, ) { - const authUser: JwtUser = req['user'] as JwtUser; - return OkResponse(await this.service.update(authUser, id, dto)); + return OkResponse(await this.service.update(id, dto)); } @ApiOperation({ diff --git a/src/api/review-opportunity/reviewOpportunity.service.ts b/src/api/review-opportunity/reviewOpportunity.service.ts index e9fa895..e7d83e5 100644 --- a/src/api/review-opportunity/reviewOpportunity.service.ts +++ b/src/api/review-opportunity/reviewOpportunity.service.ts @@ -263,15 +263,10 @@ export class ReviewOpportunityService { `Review opportunity already exists for challenge ${dto.challengeId} and type ${dto.type}`, ); } - const userId = - authUser.userId === null || authUser.userId === undefined - ? '' - : String(authUser.userId); + const entity = await this.prisma.reviewOpportunity.create({ data: { ...dto, - createdBy: userId, - updatedBy: userId, }, }); return this.buildResponse(entity, challengeData); @@ -325,22 +320,16 @@ export class ReviewOpportunityService { /** * Update review opportunity by id - * @param authUser auth user * @param id opportunity id * @param dto update dto */ - async update(authUser: JwtUser, id: string, dto: UpdateReviewOpportunityDto) { + async update(id: string, dto: UpdateReviewOpportunityDto) { try { - const updatedBy = - authUser.userId === null || authUser.userId === undefined - ? '' - : String(authUser.userId); await this.checkExists(id); const entity = await this.prisma.reviewOpportunity.update({ where: { id }, data: { ...dto, - updatedBy, }, }); return await this.assembleResult(entity); diff --git a/src/api/review-summation/review-summation.service.ts b/src/api/review-summation/review-summation.service.ts index ac83939..4ba8f33 100644 --- a/src/api/review-summation/review-summation.service.ts +++ b/src/api/review-summation/review-summation.service.ts @@ -58,9 +58,6 @@ export class ReviewSummationService { const data = await this.prisma.reviewSummation.create({ data: { ...body, - createdBy: String(authUser.userId) || '', - createdAt: new Date(), - updatedBy: String(authUser.userId) || '', }, }); this.logger.log(`Review summation created with ID: ${data.id}`); @@ -259,8 +256,6 @@ export class ReviewSummationService { where: { id }, data: { ...body, - updatedBy: String(authUser.userId) || '', - updatedAt: new Date(), }, }); this.logger.log(`Review summation updated successfully: ${id}`); diff --git a/src/api/scorecard/scorecard.controller.ts b/src/api/scorecard/scorecard.controller.ts index fb15fae..9f4370a 100644 --- a/src/api/scorecard/scorecard.controller.ts +++ b/src/api/scorecard/scorecard.controller.ts @@ -58,9 +58,8 @@ export class ScorecardController { async addScorecard( @Body() body: ScorecardRequestDto, - @User() user: JwtUser, ): Promise { - return await this.scorecardService.addScorecard(body, user); + return await this.scorecardService.addScorecard(body); } @Put('/:id') @@ -240,10 +239,7 @@ export class ScorecardController { }) @ApiResponse({ status: 403, description: 'Forbidden.' }) @ApiResponse({ status: 404, description: 'Scorecard not found.' }) - async cloneScorecard( - @Param('id') id: string, - @User() user: JwtUser, - ): Promise { - return this.scorecardService.cloneScorecard(id, user); + async cloneScorecard(@Param('id') id: string): Promise { + return this.scorecardService.cloneScorecard(id); } } diff --git a/src/api/scorecard/scorecard.service.ts b/src/api/scorecard/scorecard.service.ts index 3dd6453..fee2e4b 100644 --- a/src/api/scorecard/scorecard.service.ts +++ b/src/api/scorecard/scorecard.service.ts @@ -64,16 +64,13 @@ export class ScoreCardService { */ async addScorecard( body: ScorecardRequestDto, - user: JwtUser, ): Promise { try { const data = await this.prisma.scorecard.create({ data: { - ...(mapScorecardRequestForCreate({ + ...mapScorecardRequestForCreate({ ...body, - createdBy: user.isMachine ? 'System' : (user.userId as string), - updatedBy: user.isMachine ? 'System' : (user.userId as string), - }) as any), + }), }, include: { scorecardGroups: { @@ -130,7 +127,7 @@ export class ScoreCardService { `[updateScorecard] Updating scorecard with id: ${scorecardId}`, ); - return await this.prisma.$transaction(async (tx) => { + await this.prisma.$transaction(async (tx) => { // Update scorecard basic info const updatedScorecard = await tx.scorecard.update({ where: { id: scorecardId }, @@ -183,22 +180,22 @@ export class ScoreCardService { this.logger.log( `[updateScorecard] Finished syncing groups, sections, and questions for scorecard ${scorecardId}`, ); + }); - return this.prisma.scorecard.findUnique({ - where: { id: scorecardId }, - include: { - scorecardGroups: { - include: { - sections: { - include: { - questions: true, - }, + return this.prisma.scorecard.findUnique({ + where: { id: scorecardId }, + include: { + scorecardGroups: { + include: { + sections: { + include: { + questions: true, }, }, }, }, - }) as Promise; - }); + }, + }) as Promise; } catch (error) { if (error instanceof NotFoundException) { throw error; @@ -222,7 +219,6 @@ export class ScoreCardService { incoming, existing, cascade, - userId, }: SyncChildrenParams): Promise { const incomingIds = incoming.filter((c) => c.id).map((c) => c.id); const existingIds = existing.map((e) => e.id); @@ -243,8 +239,7 @@ export class ScoreCardService { await model.update({ where: { id: child.id }, data: { - ...omit(child, ['sections', 'questions']), - updatedBy: userId, + ...omit(child, ['id', 'sections', 'questions']), }, }); } else { @@ -253,10 +248,8 @@ export class ScoreCardService { ); const created = await model.create({ data: { - ...omit(child, ['sections', 'questions']), + ...omit(child, ['id', 'sections', 'questions']), [parentField]: parentId, - createdBy: userId, - updatedBy: userId, }, }); child.id = created.id; // assign id back to input @@ -427,10 +420,7 @@ export class ScoreCardService { } } - async cloneScorecard( - id: string, - user: { userId?: string; isMachine: boolean }, - ): Promise { + async cloneScorecard(id: string): Promise { try { const original = await this.prisma.scorecard.findUnique({ where: { id }, @@ -454,32 +444,22 @@ export class ScoreCardService { }); } - const auditFields = { - createdBy: user.isMachine ? 'System' : (user.userId as string), - updatedBy: user.isMachine ? 'System' : (user.userId as string), - createdAt: undefined, - updatedAt: undefined, - }; - // Remove id fields from nested objects for cloning const cloneGroups = original.scorecardGroups.map( (group: ScorecardGroupBaseDto) => ({ ...group, id: undefined, - ...auditFields, scorecardId: undefined, sections: { create: group.sections.map((section: ScorecardSectionBaseDto) => ({ ...section, id: undefined, - ...auditFields, scorecardGroupId: undefined, questions: { create: section.questions.map( (question: ScorecardQuestionBaseDto) => ({ ...question, id: undefined, - ...auditFields, sectionId: undefined, scorecardSectionId: undefined, }), @@ -495,7 +475,6 @@ export class ScoreCardService { ...original, id: undefined, name: `${original.name} (Clone)`, - ...auditFields, scorecardGroups: { create: cloneGroups, }, diff --git a/src/api/submission/submission.service.ts b/src/api/submission/submission.service.ts index 252aa71..9fcece9 100644 --- a/src/api/submission/submission.service.ts +++ b/src/api/submission/submission.service.ts @@ -1110,8 +1110,6 @@ export class SubmissionService { viewCount: 0, status: SubmissionStatus.ACTIVE, type: body.type as SubmissionType, - createdBy: String(authUser.userId) || '', - createdAt: new Date(), }, }); this.logger.log(`Submission created with ID: ${data.id}`); @@ -1383,8 +1381,6 @@ export class SubmissionService { data: { ...body, type: (body.type as SubmissionType) || existing.type, - updatedBy: String(authUser.userId) || '', - updatedAt: new Date(), }, }); this.logger.log(`Submission updated successfully: ${submissionId}`); diff --git a/src/app.module.ts b/src/app.module.ts index 599aec8..7122bae 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,9 +1,16 @@ -import { Module } from '@nestjs/common'; +import { MiddlewareConsumer, Module } from '@nestjs/common'; import { ApiModule } from './api/api.module'; +import { CreateRequestStoreMiddleware } from './shared/request/createRequestStore.middleware'; +import { TokenValidatorMiddleware } from './shared/request/tokenRequestValidator.middleware'; @Module({ imports: [ApiModule], controllers: [], providers: [], }) -export class AppModule {} +export class AppModule { + configure(consumer: MiddlewareConsumer) { + consumer.apply(TokenValidatorMiddleware).forRoutes('*'); + consumer.apply(CreateRequestStoreMiddleware).forRoutes('*'); + } +} diff --git a/src/dto/appeal.dto.ts b/src/dto/appeal.dto.ts index 224245e..54fa932 100644 --- a/src/dto/appeal.dto.ts +++ b/src/dto/appeal.dto.ts @@ -128,31 +128,13 @@ export class AppealResponseDto extends AppealBaseDto { updatedAt?: Date; } -export function mapAppealRequestToDto(request: AppealRequestDto) { - const userFields = { - createdBy: '', - updatedBy: '', - }; - - return { - ...request, - ...userFields, - }; -} - export function mapAppealResponseRequestToDto( request: AppealResponseRequestDto, ) { - const userFields = { - createdBy: '', - updatedBy: '', - }; - // Only forward allowed fields; relation ID is set by Prisma in nested create return { resourceId: request.resourceId, content: request.content, success: request.success, - ...userFields, }; } diff --git a/src/dto/contactRequest.dto.ts b/src/dto/contactRequest.dto.ts index 26b281a..f64ebc6 100644 --- a/src/dto/contactRequest.dto.ts +++ b/src/dto/contactRequest.dto.ts @@ -28,44 +28,4 @@ export class ContactRequestResponseDto extends ContactRequestBaseDto { example: 'abc123', }) id: string; - - @ApiProperty({ - description: 'The user who created the request', - example: 'user123', - }) - createdBy: string; - - @ApiProperty({ - description: 'The timestamp when the request was created', - example: '2023-02-10T00:00:00Z', - }) - createdAt: Date; - - @ApiProperty({ - description: 'The timestamp when the request was last updated', - example: '2023-02-10T00:00:00Z', - }) - updatedAt: Date; - - @ApiProperty({ - description: 'The user who last updated the request', - example: 'user456', - }) - updatedBy: string; -} - -export function mapContactRequestToDto( - request: ContactRequestDto, - resourceId: string, -) { - const userFields = { - createdBy: '', - updatedBy: '', - }; - - return { - ...request, - resourceId, - ...userFields, - }; } diff --git a/src/dto/review.dto.ts b/src/dto/review.dto.ts index e4c0ebc..0ef4066 100644 --- a/src/dto/review.dto.ts +++ b/src/dto/review.dto.ts @@ -382,8 +382,6 @@ type MappedReviewItemComment = { content: string; type: ReviewItemCommentType; sortOrder: number; - createdBy: string; - updatedBy: string; resourceId: string; }; @@ -392,8 +390,6 @@ type MappedReviewItem = { initialAnswer: string; finalAnswer?: string; managerComment?: string; - createdBy: string; - updatedBy: string; review?: { connect: { id: string } }; reviewItemComments?: { create?: MappedReviewItemComment[] }; }; @@ -401,15 +397,9 @@ type MappedReviewItem = { export function mapReviewRequestToDto( request: ReviewRequestDto | ReviewPatchRequestDto, ) { - const userFields = { - createdBy: '', - updatedBy: '', - }; - if (request instanceof ReviewRequestDto) { return { ...request, - ...userFields, reviewItems: { create: request.reviewItems?.map((item) => { // When creating review items nested within a review, don't include reviewId @@ -434,7 +424,6 @@ export function mapReviewRequestToDto( } else { return { ...request, - ...userFields, }; } } @@ -442,22 +431,15 @@ export function mapReviewRequestToDto( export function mapReviewItemRequestToDto( request: ReviewItemRequestDto, ): MappedReviewItem { - const userFields = { - createdBy: '', - updatedBy: '', - }; - const { reviewId, ...rest } = request as { reviewId?: string; } & ReviewItemRequestDto; const payload: MappedReviewItem = { ...rest, - ...userFields, reviewItemComments: { create: request.reviewItemComments?.map((comment) => ({ ...comment, - ...userFields, // resourceId is required on reviewItemComment; leave population to controller/service resourceId: '', })), @@ -476,10 +458,6 @@ export function mapReviewItemRequestToDto( export function mapReviewItemRequestForUpdate( request: ReviewItemRequestDto, ): Partial { - const userFields = { - updatedBy: '', - }; - // eslint-disable-next-line @typescript-eslint/no-unused-vars const { reviewId, reviewItemComments, ...rest } = request as { reviewId?: string; @@ -490,7 +468,6 @@ export function mapReviewItemRequestForUpdate( // Comments should be handled separately via dedicated comment endpoints const payload: Partial = { ...rest, - ...userFields, }; return payload; diff --git a/src/dto/reviewSummation.dto.ts b/src/dto/reviewSummation.dto.ts index 6527788..1f94eb5 100644 --- a/src/dto/reviewSummation.dto.ts +++ b/src/dto/reviewSummation.dto.ts @@ -211,7 +211,7 @@ export class ReviewSummationResponseDto { description: 'The user who created the review summation', example: 'user123', }) - createdBy: string; + createdBy: string | null; @ApiProperty({ description: 'The last update timestamp', diff --git a/src/dto/scorecard.dto.ts b/src/dto/scorecard.dto.ts index 02679fa..228bf89 100644 --- a/src/dto/scorecard.dto.ts +++ b/src/dto/scorecard.dto.ts @@ -265,14 +265,6 @@ export class ScorecardBaseDto { @Max(100) @IsGreaterThan('minScore') maxScore: number; - - /** - * These shouldn't be editable via API - */ - createdAt: Date; - createdBy: string; - updatedAt: Date; - updatedBy: string; } export class ScorecardBaseWithGroupsDto extends ScorecardBaseDto { @@ -390,31 +382,22 @@ export class ScorecardPaginatedResponseDto { } export function mapScorecardRequestForCreate(request: ScorecardRequestDto) { - const userFields = { - ...(request.createdBy ? { createdBy: request.createdBy } : {}), - updatedBy: request.updatedBy, - }; - return { ...request, id: undefined, - ...userFields, scorecardGroups: { create: request.scorecardGroups.map((group) => ({ ...group, id: undefined, - ...userFields, sections: { create: group.sections.map((section) => ({ ...section, id: undefined, - ...userFields, questions: { create: section.questions.map((question) => ({ ...question, id: undefined, sortOrder: 1, - ...userFields, })), }, })), diff --git a/src/shared/guards/tokenRoles.guard.ts b/src/shared/guards/tokenRoles.guard.ts index 8ce26bc..d340733 100644 --- a/src/shared/guards/tokenRoles.guard.ts +++ b/src/shared/guards/tokenRoles.guard.ts @@ -21,7 +21,7 @@ export class TokenRolesGuard implements CanActivate { private jwtService: JwtService, ) {} - async canActivate(context: ExecutionContext): Promise { + canActivate(context: ExecutionContext): boolean { // Get required roles and scopes from decorators const requiredRoles = this.reflector.get(ROLES_KEY, context.getHandler()) || []; @@ -35,17 +35,9 @@ export class TokenRolesGuard implements CanActivate { } const request = context.switchToHttp().getRequest(); - const authHeader = request.headers['authorization']; - if (!authHeader || !authHeader.startsWith('Bearer ')) { - throw new UnauthorizedException('Invalid or missing token'); - } try { - const token = authHeader.split(' ')[1]; - const user = await this.jwtService.validateToken(token); - - // Add user to request for later use in controllers - request['user'] = user; + const user = request['user'] ?? {}; // Check role-based access for regular users if (user.roles && requiredRoles.length > 0) { diff --git a/src/shared/modules/global/prisma.service.ts b/src/shared/modules/global/prisma.service.ts index 25d3bea..15354b6 100644 --- a/src/shared/modules/global/prisma.service.ts +++ b/src/shared/modules/global/prisma.service.ts @@ -2,6 +2,159 @@ import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; import { PrismaClient, Prisma } from '@prisma/client'; import { LoggerService } from './logger.service'; import { PrismaErrorService } from './prisma-error.service'; +import { getStore } from 'src/shared/request/requestStore'; + +enum auditField { + createdBy = 'createdBy', + updatedBy = 'updatedBy', +} + +/** + * Checks if a given Prisma model contains a specific field. + * + * @param model - The name of the Prisma model to search for. + * @param field - The name of the field to check within the model. + * @returns `true` if the model contains the specified field, otherwise `false`. + */ +const modelHasField = (model: string, field: string) => { + const modelObj = Prisma.dmmf?.datamodel?.models?.find( + (x) => x.name === model, + ); + return modelObj && modelObj.fields?.some((x) => x.name === field); +}; + +/** + * Retrieves the type of a specified field from a given Prisma model. + * + * @param model - The name of the Prisma model to search for. + * @param field - The name of the field within the model whose type is to be retrieved. + * @returns The type of the specified field as a string, or `undefined` if the model or field does not exist. + */ +const getFieldType = (model: string, field: string) => { + const modelObj = Prisma.dmmf?.datamodel?.models?.find( + (x) => x.name === model, + ); + return modelObj && modelObj.fields?.find((x) => x.name === field)?.type; +}; + +/** + * Checks an object's properties for nested 'update' or 'create' operations, + * and applies the `addUserAuditField` function to those operations. + * + * Iterates over the object's entries, filtering for values that are objects + * containing either an 'update' or 'create' key. For each matching entry, + * it determines the field type using `getFieldType`, and then calls + * `addUserAuditField` with the appropriate parameters. + * + * @param model - The name of the model being audited. + * @param field - The audit field information. + * @param obj - The object to inspect for nested update or create operations. + */ +const checkForNestedUpdateCreateOps = ( + model: string, + field: auditField, + obj: object, +) => { + return Object.entries(obj) + .filter( + ([key, value]) => + value && + typeof value === 'object' && + ('update' in value || 'create' in value) && + getFieldType(model, key), + ) + .forEach(([key, value]) => + addUserAuditField( + getFieldType(model, key)!, + field, + (value.create ?? value.update) as object, + ), + ); +}; + +/** + * Adds a user audit field to the provided data object(s) if the current user ID is available + * and the specified model contains the audit field. Handles both single objects and arrays of objects. + * Also checks for nested update/create operations within the data. + * + * @param model - The name of the model to check for the audit field. + * @param field - The audit field to add (e.g., createdBy, updatedBy). + * @param data - The object or array of objects to which the audit field should be added. + */ +const addUserAuditField = ( + model: string, + field: auditField, + data: object | Array, +) => { + const userId = getStore()?.userId; + + if (userId && modelHasField(model, field)) { + if (Array.isArray(data)) { + data.forEach((x) => { + x[field] = userId; + checkForNestedUpdateCreateOps(model, field, x); + }); + } else { + data[field] = userId; + checkForNestedUpdateCreateOps(model, field, data); + } + } +}; + +/** + * Prisma extension that automatically adds audit fields (`createdBy`, `updatedBy`) + * to all models during create, update, and upsert operations. + * + * - On `create` and `createMany`, both `createdBy` and `updatedBy` fields are set. + * - On `update` and `updateMany`, only the `updatedBy` field is set. + * - On `upsert`, sets `createdBy` and `updatedBy` on creation, and `updatedBy` on update. + * + * This extension relies on the `addUserAuditField` helper to inject audit information + * into the model's data payload before executing the query. + * + * @see addUserAuditField + * @see auditField + */ +const auditFieldsExtension = Prisma.defineExtension({ + query: { + $allModels: { + async create({ model, args, query }) { + addUserAuditField(model, auditField.createdBy, args.data); + addUserAuditField(model, auditField.updatedBy, args.data); + return query(args); + }, + + async createMany({ model, args, query }) { + addUserAuditField(model, auditField.createdBy, args.data); + addUserAuditField(model, auditField.updatedBy, args.data); + return query(args); + }, + + async update({ model, args, query }) { + addUserAuditField(model, auditField.updatedBy, args.data); + return query(args); + }, + + async updateMany({ model, args, query }) { + addUserAuditField(model, auditField.updatedBy, args.data); + return query(args); + }, + + async upsert({ model, args, query }) { + if (args.create) { + addUserAuditField(model, auditField.createdBy, args.create); + addUserAuditField(model, auditField.updatedBy, args.create); + } + + if (args.update) { + addUserAuditField(model, auditField.updatedBy, args.update); + } + + return query(args); + }, + }, + }, +}); @Injectable() export class PrismaService @@ -60,6 +213,9 @@ export class PrismaService this.$on('error' as never, (e: Prisma.LogEvent) => { this.logger.error(`Prisma Error: ${e.message}`, e.target); }); + + // Extend the client and replace this instance with the extended instance + Object.assign(this, this.$extends(auditFieldsExtension)); } async onModuleInit() { diff --git a/src/shared/request/createRequestStore.middleware.ts b/src/shared/request/createRequestStore.middleware.ts new file mode 100644 index 0000000..acc3b87 --- /dev/null +++ b/src/shared/request/createRequestStore.middleware.ts @@ -0,0 +1,19 @@ +import { Injectable, NestMiddleware } from '@nestjs/common'; +import { Response, NextFunction } from 'express'; +import { RequestMetadata, saveStore } from './requestStore'; + +@Injectable() +export class CreateRequestStoreMiddleware implements NestMiddleware { + constructor() {} + + use(req: any, res: Response, next: NextFunction) { + const requestUserId = req['user'] + ? req['user'].isMachine + ? 'System' + : `${req['user'].userId}` + : undefined; + const requestMetaData = new RequestMetadata({ userId: requestUserId }); + + saveStore(requestMetaData, next); + } +} diff --git a/src/shared/request/index.ts b/src/shared/request/index.ts new file mode 100644 index 0000000..a812f4f --- /dev/null +++ b/src/shared/request/index.ts @@ -0,0 +1,2 @@ +export * from './createRequestStore.middleware'; +export * from './requestStore'; diff --git a/src/shared/request/requestStore.ts b/src/shared/request/requestStore.ts new file mode 100644 index 0000000..135b2b0 --- /dev/null +++ b/src/shared/request/requestStore.ts @@ -0,0 +1,40 @@ +import { AsyncLocalStorage } from 'async_hooks'; +import { NextFunction } from 'express'; +import { nanoid } from 'nanoid'; + +// Class for storing request specific metadata +export class RequestMetadata { + requestId: string; + userId?: string; + + constructor(params: { requestId?: string; userId?: string }) { + this.requestId = params.requestId ?? nanoid(11); + this.userId = params.userId; + } +} + +// Create a AsyncLocalStorage of type RequestMetaData for storing request specific data +const asyncStorage = new AsyncLocalStorage(); + +// Gets the RequestMetadada object associated with the current request +export function getStore(): RequestMetadata { + let store = asyncStorage.getStore(); + if (store === undefined) { + store = new RequestMetadata({ + requestId: '', + }); + } + + return store; +} + +// For use in middleware +// Saves RequestMetadata for the current request +export function saveStore( + requestMetaData: RequestMetadata, + next: NextFunction, +) { + asyncStorage.run(requestMetaData, () => { + next(); + }); +} diff --git a/src/shared/request/tokenRequestValidator.middleware.ts b/src/shared/request/tokenRequestValidator.middleware.ts new file mode 100644 index 0000000..24f7aae --- /dev/null +++ b/src/shared/request/tokenRequestValidator.middleware.ts @@ -0,0 +1,43 @@ +import { + Injectable, + NestMiddleware, + UnauthorizedException, +} from '@nestjs/common'; +import { JwtService } from '../modules/global/jwt.service'; +import { LoggerService } from '../modules/global/logger.service'; + +@Injectable() +export class TokenValidatorMiddleware implements NestMiddleware { + private readonly logger: LoggerService; + + constructor(private jwtService: JwtService) { + this.logger = LoggerService.forRoot('Auth/TokenValidatorMiddleware'); + } + + async use(request: any, res: Response, next: (error?: any) => void) { + const authHeader = request.headers.authorization; + if (!authHeader) { + return next(); + } + + const [type, idToken] = request.headers.authorization.split(' ') ?? []; + + if (type !== 'Bearer' || !idToken) { + throw new UnauthorizedException('Invalid or missing JWT!'); + } + + let decoded: any; + try { + decoded = await this.jwtService.validateToken(idToken); + } catch (error) { + this.logger.error('Error verifying JWT', error); + throw new UnauthorizedException('Invalid or expired JWT!'); + } + + // Add user to request for later use in controllers + request['user'] = decoded; + request.idTokenVerified = true; + + return next(); + } +}