diff --git a/prisma/migrations/20250218000100_add_submission_is_file_submission/migration.sql b/prisma/migrations/20251031000100_add_submission_is_file_submission/migration.sql similarity index 100% rename from prisma/migrations/20250218000100_add_submission_is_file_submission/migration.sql rename to prisma/migrations/20251031000100_add_submission_is_file_submission/migration.sql diff --git a/prisma/migrations/20251101013110_add_additional_my_review_indexes/migration.sql b/prisma/migrations/20251101013110_add_additional_my_review_indexes/migration.sql new file mode 100644 index 0000000..69e6ce8 --- /dev/null +++ b/prisma/migrations/20251101013110_add_additional_my_review_indexes/migration.sql @@ -0,0 +1,18 @@ +-- CreateIndex +CREATE INDEX "appeal_response_appeal_resource_idx" ON "appealResponse"("appealId", "resourceId"); + +-- CreateIndex +CREATE INDEX "review_resource_status_phase_idx" ON "review"("resourceId", "status", "phaseId"); + +-- Clean up orphaned reviewSummations before enforcing FK +UPDATE "reviewSummation" rs +SET "scorecardId" = NULL +WHERE "scorecardId" IS NOT NULL + AND NOT EXISTS ( + SELECT 1 + FROM "scorecard" sc + WHERE sc."id" = rs."scorecardId" + ); + +-- AddForeignKey +ALTER TABLE "reviewSummation" ADD CONSTRAINT "reviewSummation_scorecardId_fkey" FOREIGN KEY ("scorecardId") REFERENCES "scorecard"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20251101030908_additional_comment_index/migration.sql b/prisma/migrations/20251101030908_additional_comment_index/migration.sql new file mode 100644 index 0000000..8526c0b --- /dev/null +++ b/prisma/migrations/20251101030908_additional_comment_index/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX "appeal_comment_resource_idx" ON "appeal"("reviewItemCommentId", "resourceId"); diff --git a/prisma/migrations/20251108093000_add_my_review_indexes/migration.sql b/prisma/migrations/20251108093000_add_my_review_indexes/migration.sql new file mode 100644 index 0000000..80309e8 --- /dev/null +++ b/prisma/migrations/20251108093000_add_my_review_indexes/migration.sql @@ -0,0 +1,10 @@ +-- Add composite indexes to improve My Reviews query performance + +CREATE INDEX IF NOT EXISTS "review_resource_status_phase_idx" + ON "reviews"."review"("resourceId", "status", "phaseId"); + +CREATE INDEX IF NOT EXISTS "appeal_response_appeal_resource_idx" + ON "reviews"."appealResponse"("appealId", "resourceId"); + +CREATE INDEX IF NOT EXISTS "appeal_comment_resource_idx" + ON "reviews"."appeal"("reviewItemCommentId", "resourceId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b3e2858..9cd7862 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -188,6 +188,7 @@ model review { @@index([status]) // Index for filtering by review status @@index([status, phaseId]) @@index([resourceId, status]) + @@index([resourceId, status, phaseId], map: "review_resource_status_phase_idx") // Supports incomplete review lookups that also consider phase ordering @@unique([resourceId, submissionId, scorecardId]) } @@ -284,6 +285,7 @@ model appeal { @@index([resourceId]) // Index for resource ID @@index([id]) // Index for direct ID lookups @@index([reviewItemCommentId]) // Index for joining with reviewItemComment table + @@index([reviewItemCommentId, resourceId], map: "appeal_comment_resource_idx") // Supports filtered appeal lookups by comment and resource } model appealResponse { @@ -303,6 +305,7 @@ model appealResponse { @@index([id]) // Index for direct ID lookups @@index([appealId]) // Index for joining with appeal table @@index([resourceId]) // Index for filtering by resource (responder) + @@index([appealId, resourceId], map: "appeal_response_appeal_resource_idx") // Supports lookups for pending appeal responses by appeal and resource } model challengeResult { diff --git a/src/api/my-review/myReview.service.ts b/src/api/my-review/myReview.service.ts index 84dd1a9..e2c59ed 100644 --- a/src/api/my-review/myReview.service.ts +++ b/src/api/my-review/myReview.service.ts @@ -157,6 +157,9 @@ export class MyReviewService { } const baseJoins: Prisma.Sql[] = []; + const countJoins: Prisma.Sql[] = []; + const countExtras: Prisma.Sql[] = []; + const rowExtras: Prisma.Sql[] = []; if (!adminUser) { if (!normalizedUserId) { @@ -175,7 +178,17 @@ export class MyReviewService { `, ); - whereFragments.push(Prisma.sql`r."challengeId" IS NOT NULL`); + rowExtras.push(Prisma.sql`r."challengeId" IS NOT NULL`); + countExtras.push( + Prisma.sql` + EXISTS ( + SELECT 1 + FROM resources."Resource" r + WHERE r."challengeId" = c.id + AND r."memberId" = ${normalizedUserId} + ) + `, + ); } else { baseJoins.push( Prisma.sql` @@ -193,6 +206,11 @@ export class MyReviewService { LEFT JOIN challenges."ChallengeType" ct ON ct.id = c."typeId" `, ); + countJoins.push( + Prisma.sql` + LEFT JOIN challenges."ChallengeType" ct ON ct.id = c."typeId" + `, + ); const metricJoins: Prisma.Sql[] = [ Prisma.sql` @@ -318,7 +336,10 @@ export class MyReviewService { [...baseJoins, ...metricJoins], Prisma.sql``, ); - const countJoinClause = joinSqlFragments(baseJoins, Prisma.sql``); + const countJoinClause = joinSqlFragments(countJoins, Prisma.sql``); + + const rowWhereFragments = [...whereFragments, ...rowExtras]; + const countWhereFragments = [...whereFragments, ...countExtras]; if (challengeTypeId) { whereFragments.push(Prisma.sql`c."typeId" = ${challengeTypeId}`); @@ -340,7 +361,14 @@ export class MyReviewService { ); } - const whereClause = joinSqlFragments(whereFragments, Prisma.sql` AND `); + const rowWhereClause = joinSqlFragments( + rowWhereFragments, + Prisma.sql` AND `, + ); + const countWhereClause = joinSqlFragments( + countWhereFragments, + Prisma.sql` AND `, + ); const phaseEndExpression = Prisma.sql` COALESCE(cp."actualEndDate", cp."scheduledEndDate") @@ -416,10 +444,10 @@ export class MyReviewService { const orderClause = joinSqlFragments(orderFragments, Prisma.sql`, `); const countQuery = Prisma.sql` - SELECT COUNT(DISTINCT c.id) AS "total" + SELECT COUNT(*) AS "total" FROM challenges."Challenge" c ${countJoinClause} - WHERE ${whereClause} + WHERE ${countWhereClause} `; const countQueryDetails = countQuery.inspect(); @@ -470,7 +498,7 @@ export class MyReviewService { c.status AS "status" FROM challenges."Challenge" c ${joinClause} - WHERE ${whereClause} + WHERE ${rowWhereClause} ORDER BY ${orderClause} LIMIT ${perPage} OFFSET ${offset} diff --git a/src/api/scorecard/scorecard.service.ts b/src/api/scorecard/scorecard.service.ts index fee2e4b..ee56280 100644 --- a/src/api/scorecard/scorecard.service.ts +++ b/src/api/scorecard/scorecard.service.ts @@ -307,22 +307,45 @@ export class ScoreCardService { */ async viewScorecard(id: string): Promise { try { - const data = await this.prisma.scorecard.findUniqueOrThrow({ - where: { id }, - include: { - scorecardGroups: { - include: { - sections: { - include: { - questions: true, - }, + const include = { + scorecardGroups: { + include: { + sections: { + include: { + questions: true, }, }, }, }, + }; + + const scorecardById = await this.prisma.scorecard.findUnique({ + where: { id }, + include, + }); + + if (scorecardById) { + return scorecardById as ScorecardWithGroupResponseDto; + } + + const scorecardByLegacyId = await this.prisma.scorecard.findFirst({ + where: { legacyId: id }, + include, }); - return data as ScorecardWithGroupResponseDto; + + if (!scorecardByLegacyId) { + throw new NotFoundException({ + message: `Scorecard with ID ${id} not found. Please check the ID and try again.`, + details: { scorecardId: id }, + }); + } + + return scorecardByLegacyId as ScorecardWithGroupResponseDto; } catch (error) { + if (error instanceof NotFoundException) { + throw error; + } + const errorResponse = this.prismaErrorService.handleError( error, `viewing scorecard with ID: ${id}`, diff --git a/src/api/submission/submission.service.ts b/src/api/submission/submission.service.ts index 19ea7d7..00d3c0e 100644 --- a/src/api/submission/submission.service.ts +++ b/src/api/submission/submission.service.ts @@ -60,6 +60,23 @@ type SubmissionMinimal = { url: string | null; }; +interface TopgearSubmissionEventPayload { + submissionId: string; + challengeId: string; + submissionUrl: string; + memberHandle: string; + memberId: string; + submittedDate: string; +} + +type TopgearSubmissionRecord = { + id: string; + challengeId: string | null; + memberId: string | null; + url: string | null; + createdAt: Date; +}; + type SubmissionBusPayloadSource = Prisma.submissionGetPayload<{ select: { id: true; @@ -1399,6 +1416,95 @@ export class SubmissionService { ); } + private async publishTopgearSubmissionEventIfEligible( + submission: TopgearSubmissionRecord, + ): Promise { + if (!submission.challengeId) { + this.logger.log( + `Submission ${submission.id} missing challengeId. Skipping Topgear event publish.`, + ); + return; + } + + const challenge = await this.challengeApiService.getChallengeDetail( + submission.challengeId, + ); + + if (!this.isTopgearTaskChallenge(challenge?.type)) { + this.logger.log( + `Challenge ${submission.challengeId} is not Topgear Task. Skipping immediate Topgear event for submission ${submission.id}.`, + ); + return; + } + + if (!submission.url) { + throw new InternalServerErrorException({ + message: + 'Updated submission does not contain a URL required for Topgear event payload.', + code: 'TOPGEAR_SUBMISSION_URL_MISSING', + details: { submissionId: submission.id }, + }); + } + + if (!submission.memberId) { + throw new InternalServerErrorException({ + message: + 'Submission is missing memberId. Cannot publish Topgear event.', + code: 'TOPGEAR_SUBMISSION_MEMBER_MISSING', + details: { submissionId: submission.id }, + }); + } + + const memberHandle = await this.lookupMemberHandle( + submission.challengeId, + submission.memberId, + ); + + if (!memberHandle) { + throw new InternalServerErrorException({ + message: 'Unable to locate member handle for Topgear event payload.', + code: 'TOPGEAR_MEMBER_HANDLE_MISSING', + details: { + submissionId: submission.id, + challengeId: submission.challengeId, + memberId: submission.memberId, + }, + }); + } + + const payload: TopgearSubmissionEventPayload = { + submissionId: submission.id, + challengeId: submission.challengeId, + submissionUrl: submission.url, + memberHandle, + memberId: submission.memberId, + submittedDate: submission.createdAt.toISOString(), + }; + + await this.eventBusService.publish('topgear.submission.received', payload); + this.logger.log( + `Published topgear.submission.received event for submission ${submission.id} immediately after creation.`, + ); + } + + private isTopgearTaskChallenge(typeName?: string): boolean { + return (typeName ?? '').trim().toLowerCase() === 'topgear task'; + } + + private async lookupMemberHandle( + challengeId: string, + memberId: string, + ): Promise { + const resource = await this.resourcePrisma.resource.findFirst({ + where: { + challengeId, + memberId, + }, + }); + + return resource?.memberHandle ?? null; + } + async createSubmission( authUser: JwtUser, body: SubmissionRequestDto, @@ -1543,7 +1649,10 @@ export class SubmissionService { !!file && ((typeof file.size === 'number' && file.size > 0) || (file.buffer && file.buffer.length > 0)); - const isFileSubmission = hasUploadedFile; + const hasS3Url = + typeof body.url === 'string' && + body.url.includes('https://s3.amazonaws.com'); + const isFileSubmission = hasUploadedFile || hasS3Url; // Derive common metadata if available let systemFileName: string | undefined; @@ -1588,6 +1697,13 @@ export class SubmissionService { this.logger.log( `Skipping AV scan event for submission ${data.id} because it is not a file-based submission.`, ); + await this.publishTopgearSubmissionEventIfEligible({ + id: data.id, + challengeId: data.challengeId, + memberId: data.memberId, + url: data.url, + createdAt: data.createdAt, + }); } // Increment challenge submission counters if challengeId present if (body.challengeId) {