From b8042be64a2bc48e5a142c1d1594c81e0576a349 Mon Sep 17 00:00:00 2001 From: billsedison Date: Wed, 2 Jul 2025 00:01:53 +0800 Subject: [PATCH 1/5] Submissions API init --- .gitignore | 3 + package.json | 5 +- pnpm-lock.yaml | 39 + prisma/migrate.ts | 199 +++++- .../migration.sql | 87 +++ prisma/schema.prisma | 63 +- src/api/api.module.ts | 10 +- src/api/appeal/appeal.controller.ts | 96 ++- src/api/contact/contactRequests.controller.ts | 4 +- .../projectResult.controller.ts | 33 +- .../review-summation.controller.ts | 362 ++++++++++ src/api/review-type/review-type.controller.ts | 348 +++++++++ src/api/review/review.controller.ts | 207 ++++-- src/api/scorecard/scorecard.controller.ts | 15 +- src/api/submission/submission.controller.ts | 667 ++++++++++++++++++ src/dto/artifacts.dto.ts | 17 + src/dto/pagination.dto.ts | 2 +- src/dto/review.dto.ts | 173 ++++- src/dto/reviewSummation.dto.ts | 256 +++++++ src/dto/reviewType.dto.ts | 83 +++ src/dto/sort.dto.ts | 24 + src/dto/submission.dto.ts | 319 +++++++++ src/main.ts | 62 +- src/shared/config/auth.config.ts | 8 +- src/shared/decorators/scopes.decorator.ts | 2 +- src/shared/enums/scopes.enum.ts | 49 +- src/shared/enums/userRole.enum.ts | 2 +- src/shared/guards/tokenRoles.guard.ts | 46 +- .../modules/global/file-upload.module.ts | 24 + src/shared/modules/global/jwt.service.ts | 73 +- src/shared/modules/global/logger.service.ts | 15 +- .../modules/global/prisma-error.service.ts | 55 +- src/shared/modules/global/prisma.service.ts | 49 +- uploads/artifact-123.zip | Bin 0 -> 3894 bytes uploads/submission-123.zip | Bin 0 -> 3894 bytes 35 files changed, 3100 insertions(+), 297 deletions(-) create mode 100644 prisma/migrations/20250608071437_add_submission/migration.sql create mode 100644 src/api/review-summation/review-summation.controller.ts create mode 100644 src/api/review-type/review-type.controller.ts create mode 100644 src/api/submission/submission.controller.ts create mode 100644 src/dto/artifacts.dto.ts create mode 100644 src/dto/reviewSummation.dto.ts create mode 100644 src/dto/reviewType.dto.ts create mode 100644 src/dto/sort.dto.ts create mode 100644 src/dto/submission.dto.ts create mode 100644 src/shared/modules/global/file-upload.module.ts create mode 100644 uploads/artifact-123.zip create mode 100644 uploads/submission-123.zip diff --git a/.gitignore b/.gitignore index 4b56acf..64bd2a8 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,6 @@ pids # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json +uploads/* +!uploads/artifact-123.zip +!uploads/submission-123.zip \ No newline at end of file diff --git a/package.json b/package.json index dbc945f..d4d32b1 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "cors": "^2.8.5", "jsonwebtoken": "^9.0.2", "jwks-rsa": "^3.2.0", + "multer": "^2.0.1", "nanoid": "~5.1.2", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" @@ -46,6 +47,7 @@ "@swc/core": "^1.10.7", "@types/express": "^5.0.0", "@types/jest": "^29.5.14", + "@types/multer": "^1.4.13", "@types/node": "^22.10.7", "@types/supertest": "^6.0.2", "eslint": "^9.18.0", @@ -65,7 +67,8 @@ "typescript-eslint": "^8.20.0" }, "prisma": { - "seed": "ts-node prisma/migrate.ts" + "seed": "ts-node prisma/migrate.ts", + "seed222": "ts-node prisma/seed.ts" }, "jest": { "moduleFileExtensions": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 74f5600..6bafca6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: jwks-rsa: specifier: ^3.2.0 version: 3.2.0 + multer: + specifier: ^2.0.1 + version: 2.0.1 nanoid: specifier: ~5.1.2 version: 5.1.2 @@ -78,6 +81,9 @@ importers: '@types/jest': specifier: ^29.5.14 version: 29.5.14 + '@types/multer': + specifier: ^1.4.13 + version: 1.4.13 '@types/node': specifier: ^22.10.7 version: 22.13.5 @@ -1211,6 +1217,9 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/multer@1.4.13': + resolution: {integrity: sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==} + '@types/node@22.13.5': resolution: {integrity: sha512-+lTU0PxZXn0Dr1NBtC7Y8cR21AJr87dLLU953CWA6pMxxv/UDc7jYAY90upcrie1nRcD6XNG5HOYEDtgW5TxAg==} @@ -1731,6 +1740,10 @@ packages: resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} engines: {'0': node >= 0.8} + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} + consola@3.4.0: resolution: {integrity: sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==} engines: {node: ^14.18.0 || >=16.10.0} @@ -2867,6 +2880,11 @@ packages: multer@1.4.5-lts.1: resolution: {integrity: sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ==} engines: {node: '>= 6.0.0'} + deprecated: Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version. + + multer@2.0.1: + resolution: {integrity: sha512-Ug8bXeTIUlxurg8xLTEskKShvcKDZALo1THEX5E41pYCD2sCVub5/kIRIGqWNoqV6szyLyQKV6mD4QUrWE5GCQ==} + engines: {node: '>= 10.16.0'} mute-stream@2.0.0: resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} @@ -4841,6 +4859,10 @@ snapshots: '@types/ms@2.1.0': {} + '@types/multer@1.4.13': + dependencies: + '@types/express': 5.0.0 + '@types/node@22.13.5': dependencies: undici-types: 6.20.0 @@ -5472,6 +5494,13 @@ snapshots: readable-stream: 2.3.8 typedarray: 0.0.6 + concat-stream@2.0.0: + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 3.6.2 + typedarray: 0.0.6 + consola@3.4.0: {} content-disposition@0.5.4: @@ -6800,6 +6829,16 @@ snapshots: type-is: 1.6.18 xtend: 4.0.2 + multer@2.0.1: + dependencies: + append-field: 1.0.0 + busboy: 1.6.0 + concat-stream: 2.0.0 + mkdirp: 0.5.6 + object-assign: 4.1.1 + type-is: 1.6.18 + xtend: 4.0.2 + mute-stream@2.0.0: {} nanoid@5.1.2: {} diff --git a/prisma/migrate.ts b/prisma/migrate.ts index 633c679..bcbb689 100644 --- a/prisma/migrate.ts +++ b/prisma/migrate.ts @@ -1,4 +1,4 @@ -import { PrismaClient } from '@prisma/client'; +import { Prisma, PrismaClient } from '@prisma/client'; import * as path from 'path'; import * as fs from 'fs'; import { @@ -45,6 +45,120 @@ const lookupKeys: string[] = [ 'project_category_lu', ]; +const reviewTypeData: Prisma.reviewTypeCreateInput[] = [ + { + id: 'c56a4180-65aa-42ec-a945-5fd21dec0501', + name: 'Screening', + isActive: true, + }, + { + id: 'c56a4180-65aa-42ec-a945-5fd21dec0502', + name: 'Checkpoint Review', + isActive: true, + }, + { + id: 'c56a4180-65aa-42ec-a945-5fd21dec0503', + name: 'Review', + isActive: true, + }, + { + id: 'c56a4180-65aa-42ec-a945-5fd21dec0504', + name: 'Appeals Response', + isActive: true, + }, + { + id: 'c56a4180-65aa-42ec-a945-5fd21dec0505', + name: 'Iterative Review', + isActive: true, + }, + { + id: 'f28b2725-ef90-4495-af59-ceb2bd98fc10', + name: 'AV Scan', + isActive: false, + }, +]; + +const scorecardData: Prisma.scorecardCreateInput = { + status: 'ACTIVE', + type: 'REVIEW', + challengeTrack: 'DEVELOPMENT', + challengeType: 'Code Development', + name: 'Topcoder Review API - Migrate Submissions API into the new Review API', + version: '1.0', + minScore: 80.0, + maxScore: 100.0, + createdAt: '2018-05-20T07:00:30.123Z', + updatedAt: '2018-06-01T07:36:28.178Z', + createdBy: 'admin', + updatedBy: 'admin', +}; + +const reviewData = [ + { + id: 'd24d4180-65aa-42ec-a945-5fd21dec0501', + resourceId: 'c56a4180-65aa-42ec-a001-5fd21dec0001', + phaseId: 'c56a4180-65aa-42ec-a001-5fd21dec0001', + initialScore: 95.5, + finalScore: 96.5, + typeId: 'c56a4180-65aa-42ec-a945-5fd21dec0503', + status: 'Review', + reviewDate: '2025-05-20T07:00:30.123Z', + createdAt: '2018-05-20T07:00:30.123Z', + updatedAt: '2018-06-01T07:36:28.178Z', + createdBy: 'admin', + updatedBy: 'admin', + }, + { + id: 'd24d4180-65aa-42ec-a945-5fd21dec0502', + resourceId: 'c56a4180-65aa-42ec-a001-5fd21dec0001', + phaseId: 'c56a4180-65aa-42ec-a001-5fd21dec0001', + initialScore: 92.0, + finalScore: 93.5, + typeId: 'c56a4180-65aa-42ec-a945-5fd21dec0501', + metadata: { + public: 'public data', + private: 'private data', + }, + status: 'Appeal', + reviewDate: '2025-05-20T07:00:30.123Z', + createdAt: '2018-05-20T07:00:30.123Z', + updatedAt: '2018-06-01T07:36:28.178Z', + createdBy: 'admin', + updatedBy: 'admin', + }, +]; + +const reviewSummationData: Prisma.reviewSummationCreateWithoutSubmissionInput[] = + [ + { + id: 'e45e4180-65aa-42ec-a945-5fd21dec1504', + aggregateScore: 99.0, + scorecardId: '123456789', + isPassing: true, + isFinal: false, + reviewedDate: '2025-06-01T07:36:28.178Z', + createdAt: '2018-05-20T07:00:30.123Z', + updatedAt: '2018-06-01T07:36:28.178Z', + createdBy: 'copilot', + updatedBy: 'copilot', + }, + ]; + +const submissionData: Prisma.submissionCreateInput[] = [ + { + id: 'a12a4180-65aa-42ec-a945-5fd21dec0501', + type: 'ContestSubmission', + url: 'https://software.topcoder.com/review/actions/DownloadContestSubmission?uid=123456', + memberId: 'b24d4180-65aa-42ec-a945-5fd21dec0501', + challengeId: 'c3564180-65aa-42ec-a945-5fd21dec0502', + submittedDate: '2018-05-20T07:00:30.123Z', + createdAt: '2018-05-20T07:00:30.123Z', + updatedAt: '2018-06-01T07:36:28.178Z', + createdBy: 'topcoder user', + updatedBy: 'topcoder user', + }, +]; + // Global lookup maps let scorecardStatusMap: Record = {}; let scorecardTypeMap: Record = {}; @@ -53,7 +167,7 @@ let projectCategoryMap: Record = {}; let reviewItemCommentTypeMap: Record = {}; // Global submission map to store submission information. -let submissionMap: Record> = {}; +const submissionMap: Record> = {}; // Data lookup maps // Initialize maps from files if they exist, otherwise create new maps @@ -283,6 +397,7 @@ function processLookupFiles() { /** * Read submission data from resource_xxx.json, upload_xxx.json and submission_xxx.json. */ +// eslint-disable-next-line @typescript-eslint/require-await async function initSubmissionMap() { // read submission_x.json, read {uploadId -> submissionId} map. const submissionRegex = new RegExp(`^submission_\\d+\\.json`); @@ -291,7 +406,7 @@ async function initSubmissionMap() { const submissionFiles: string[] = []; const uploadFiles: string[] = []; const resourceFiles: string[] = []; - fs.readdirSync(DATA_DIR).filter(f => { + fs.readdirSync(DATA_DIR).filter((f) => { if (submissionRegex.test(f)) { submissionFiles.push(f); } @@ -309,14 +424,14 @@ async function initSubmissionMap() { const filePath = path.join(DATA_DIR, f); console.log(`Reading submission data from ${f}`); const jsonData = readJson(filePath)['submission']; - for (let d of jsonData) { + for (const d of jsonData) { if (d['submission_status_id'] === '1' && d['upload_id']) { submissionCount += 1; // find submission has score and most recent const item = { score: d['screening_score'] || d['initial_score'] || d['final_score'], created: d['create_date'], - submissionId: d['submission_id'] + submissionId: d['submission_id'], }; if (uploadSubmissionMap[d['upload_id']]) { // existing submission info @@ -338,12 +453,17 @@ async function initSubmissionMap() { const filePath = path.join(DATA_DIR, f); console.log(`Reading upload data from ${f}`); const jsonData = readJson(filePath)['upload']; - for (let d of jsonData) { - if (d['upload_status_id'] === '1' && d['upload_type_id'] === '1' && d['resource_id']) { + for (const d of jsonData) { + if ( + d['upload_status_id'] === '1' && + d['upload_type_id'] === '1' && + d['resource_id'] + ) { // get submission info - uploadCount += 1 + uploadCount += 1; if (uploadSubmissionMap[d['upload_id']]) { - resourceSubmissionMap[d['resource_id']] = uploadSubmissionMap[d['upload_id']]; + resourceSubmissionMap[d['resource_id']] = + uploadSubmissionMap[d['upload_id']]; } } } @@ -357,7 +477,7 @@ async function initSubmissionMap() { const filePath = path.join(DATA_DIR, f); console.log(`Reading resource data from ${f}`); const jsonData = readJson(filePath)['resource']; - for (let d of jsonData) { + for (const d of jsonData) { const projectId = d['project_id']; const userId = d['user_id']; const resourceId = d['resource_id']; @@ -383,16 +503,17 @@ async function initSubmissionMap() { } } } - console.log(`Read resource count: ${resourceCount}, submission resource count: ${validResourceCount}`); + console.log( + `Read resource count: ${resourceCount}, submission resource count: ${validResourceCount}`, + ); // print summary let totalSubmissions = 0; - Object.keys(submissionMap).forEach(c => { + Object.keys(submissionMap).forEach((c) => { totalSubmissions += Object.keys(submissionMap[c]).length; }); console.log(`Found total submissions: ${totalSubmissions}`); } - // Process a single type: find matching files, transform them one by one, and then insert in batches. async function processType(type: string, subtype?: string) { const regex = new RegExp(`^${type}_\\d+\\.json$`); @@ -1039,6 +1160,53 @@ async function processAllTypes() { } } +async function seed() { + // console.log('Start seeding ...'); + + // delete existing data + await prisma.reviewSummation.deleteMany({}); + await prisma.review.deleteMany({}); + await prisma.submission.deleteMany({}); + await prisma.scorecard.deleteMany({}); + await prisma.reviewType.deleteMany({}); + + await prisma.reviewType.createMany({ + data: reviewTypeData, + }); + console.log('Created reviewType data successfully'); + + const scorecardIns = await prisma.scorecard.create({ + data: scorecardData, + }); + console.log(`Created scorecard with id: ${scorecardIns.id}`); + + const submissionIns = await prisma.submission.createManyAndReturn({ + data: submissionData, + }); + console.log('Created submission data successfully'); + + for (const rd of reviewData) { + const review = await prisma.review.create({ + data: { + ...rd, + submissionId: submissionIns[0].id, + scorecardId: scorecardIns.id, + }, + }); + console.log(`Created review with id: ${review.id}`); + } + + for (const rsd of reviewSummationData) { + const reviewSummation = await prisma.reviewSummation.create({ + data: { + ...rsd, + submissionId: submissionIns[0].id, + }, + }); + console.log(`Created review summationData with id: ${reviewSummation.id}`); + } +} + async function migrate() { console.log('Starting lookup import...'); processLookupFiles(); @@ -1052,6 +1220,11 @@ async function migrate() { console.log('Starting data import...'); await processAllTypes(); console.log('Data import completed.'); + + // init submission data + console.log('Start seeding ...'); + await seed(); + console.log('Seeding finished.'); } migrate() diff --git a/prisma/migrations/20250608071437_add_submission/migration.sql b/prisma/migrations/20250608071437_add_submission/migration.sql new file mode 100644 index 0000000..d5053b0 --- /dev/null +++ b/prisma/migrations/20250608071437_add_submission/migration.sql @@ -0,0 +1,87 @@ +/* + Warnings: + + - The primary key for the `review` table will be changed. If it partially fails, the table could be left without primary key constraint. + - Added the required column `typeId` to the `review` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "review" +ADD COLUMN "metadata" JSONB, +ADD COLUMN "typeId" TEXT NOT NULL, +ADD COLUMN "status" TEXT NOT NULL, +ADD COLUMN "reviewDate" TIMESTAMP(3) NOT NULL, +ALTER COLUMN "id" SET DEFAULT gen_random_uuid(), +ALTER COLUMN "id" SET DATA TYPE VARCHAR(36); + +-- CreateTable +CREATE TABLE "reviewType" ( + "id" VARCHAR(36) NOT NULL DEFAULT gen_random_uuid(), + "name" TEXT NOT NULL, + "isActive" BOOLEAN NOT NULL, + + CONSTRAINT "reviewType_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "reviewSummation" ( + "id" VARCHAR(36) NOT NULL DEFAULT gen_random_uuid(), + "submissionId" TEXT NOT NULL, + "aggregateScore" DOUBLE PRECISION NOT NULL, + "scorecardId" TEXT NOT NULL, + "isPassing" BOOLEAN NOT NULL, + "isFinal" BOOLEAN NOT NULL, + "reviewedDate" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdBy" TEXT NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + "updatedBy" TEXT NOT NULL, + + CONSTRAINT "reviewSummation_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "submission" ( + "id" VARCHAR(36) NOT NULL DEFAULT gen_random_uuid(), + "type" TEXT NOT NULL, + "url" TEXT NOT NULL, + "memberId" TEXT NOT NULL, + "challengeId" TEXT NOT NULL, + "legacySubmissionId" TEXT, + "legacyUploadId" TEXT, + "submissionPhaseId" TEXT, + "submittedDate" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdBy" TEXT NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + "updatedBy" TEXT NOT NULL, + + CONSTRAINT "submission_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "reviewType_id_idx" ON "reviewType"("id"); + +-- CreateIndex +CREATE INDEX "reviewType_name_idx" ON "reviewType"("name"); + +-- CreateIndex +CREATE INDEX "reviewType_isActive_idx" ON "reviewType"("isActive"); + +-- CreateIndex +CREATE INDEX "reviewSummation_id_idx" ON "reviewSummation"("id"); + +-- CreateIndex +CREATE INDEX "reviewSummation_submissionId_idx" ON "reviewSummation"("submissionId"); + +-- CreateIndex +CREATE INDEX "reviewSummation_scorecardId_idx" ON "reviewSummation"("scorecardId"); + +-- CreateIndex +CREATE INDEX "submission_id_idx" ON "submission"("id"); + +-- AddForeignKey +ALTER TABLE "review" ADD CONSTRAINT "review_submissionId_fkey" FOREIGN KEY ("submissionId") REFERENCES "submission"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "reviewSummation" ADD CONSTRAINT "reviewSummation_submissionId_fkey" FOREIGN KEY ("submissionId") REFERENCES "submission"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 50bb426..97d80a4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -140,21 +140,26 @@ model scorecardQuestion { } model review { - id String @id @default(dbgenerated("nanoid()")) @db.VarChar(14) + id String @id @default(dbgenerated("gen_random_uuid()")) @db.VarChar(36) legacyId String? resourceId String phaseId String - submissionId String - scorecardId String + submissionId String // Associated submission + scorecardId String // Associated scorecard committed Boolean @default(false) finalScore Float initialScore Float + typeId String + metadata Json? + status String + reviewDate DateTime createdAt DateTime @default(now()) createdBy String updatedAt DateTime @updatedAt updatedBy String scorecard scorecard @relation(fields: [scorecardId], references: [id], onDelete: Cascade) + submission submission @relation(fields: [submissionId], references: [id], onDelete: Cascade) reviewItems reviewItem[] @@index([committed]) // Index for filtering by committed status @@ -301,3 +306,55 @@ model contactRequest { @@index([resourceId]) // Index for filtering by resource (requester) @@index([challengeId]) // Index for filtering by challenge } + +model reviewType { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.VarChar(36) + name String + isActive Boolean + + @@index([id]) // Index for direct ID lookups + // Indexes for faster searches + @@index([name]) + @@index([isActive]) +} + +model reviewSummation { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.VarChar(36) + submissionId String // Associated submission + aggregateScore Float + scorecardId String + isPassing Boolean + isFinal Boolean + reviewedDate DateTime + createdAt DateTime @default(now()) + createdBy String + updatedAt DateTime @updatedAt + updatedBy String + + submission submission @relation(fields: [submissionId], references: [id], onDelete: Cascade) + + @@index([id]) // Index for direct ID lookups + @@index([submissionId]) // Index for joining with submission table + @@index([scorecardId]) // Index for joining with scorecard table +} + +model submission { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.VarChar(36) + type String + url String + memberId String + challengeId String + legacySubmissionId String? + legacyUploadId String? + submissionPhaseId String? + submittedDate DateTime + createdAt DateTime @default(now()) + createdBy String + updatedAt DateTime @updatedAt + updatedBy String + + review review[] + reviewSummation reviewSummation[] + + @@index([id]) // Index for direct ID lookups +} \ No newline at end of file diff --git a/src/api/api.module.ts b/src/api/api.module.ts index 7d22f8b..ebf4aa5 100644 --- a/src/api/api.module.ts +++ b/src/api/api.module.ts @@ -1,14 +1,18 @@ import { Module } from '@nestjs/common'; import { GlobalProvidersModule } from 'src/shared/modules/global/globalProviders.module'; +import { FileUploadModule } from 'src/shared/modules/global/file-upload.module'; import { HealthCheckController } from './health-check/healthCheck.controller'; import { ScorecardController } from './scorecard/scorecard.controller'; import { AppealController } from './appeal/appeal.controller'; import { ContactRequestsController } from './contact/contactRequests.controller'; import { ReviewController } from './review/review.controller'; import { ProjectResultController } from './project-result/projectResult.controller'; +import { ReviewTypeController } from './review-type/review-type.controller'; +import { SubmissionController } from './submission/submission.controller'; +import { ReviewSummationController } from './review-summation/review-summation.controller'; @Module({ - imports: [GlobalProvidersModule], + imports: [GlobalProvidersModule, FileUploadModule], controllers: [ HealthCheckController, ScorecardController, @@ -16,7 +20,11 @@ import { ProjectResultController } from './project-result/projectResult.controll ContactRequestsController, ReviewController, ProjectResultController, + ReviewTypeController, + SubmissionController, + ReviewSummationController, ], providers: [], + exports: [], }) export class ApiModule {} diff --git a/src/api/appeal/appeal.controller.ts b/src/api/appeal/appeal.controller.ts index 9069e9e..72c8c9c 100644 --- a/src/api/appeal/appeal.controller.ts +++ b/src/api/appeal/appeal.controller.ts @@ -74,7 +74,10 @@ export class AppealController { this.logger.log(`Appeal created with ID: ${data.id}`); return data as AppealResponseDto; } catch (error) { - const errorResponse = this.prismaErrorService.handleError(error, 'creating appeal'); + const errorResponse = this.prismaErrorService.handleError( + error, + 'creating appeal', + ); throw new InternalServerErrorException({ message: errorResponse.message, code: errorResponse.code, @@ -111,15 +114,18 @@ export class AppealController { this.logger.log(`Appeal updated successfully: ${appealId}`); return data as AppealResponseDto; } catch (error) { - const errorResponse = this.prismaErrorService.handleError(error, `updating appeal ${appealId}`); - + const errorResponse = this.prismaErrorService.handleError( + error, + `updating appeal ${appealId}`, + ); + if (errorResponse.code === 'RECORD_NOT_FOUND') { - throw new NotFoundException({ - message: `Appeal with ID ${appealId} was not found`, - code: errorResponse.code + throw new NotFoundException({ + message: `Appeal with ID ${appealId} was not found`, + code: errorResponse.code, }); } - + throw new InternalServerErrorException({ message: errorResponse.message, code: errorResponse.code, @@ -147,15 +153,18 @@ export class AppealController { this.logger.log(`Appeal deleted successfully: ${appealId}`); return { message: `Appeal ${appealId} deleted successfully.` }; } catch (error) { - const errorResponse = this.prismaErrorService.handleError(error, `deleting appeal ${appealId}`); - + const errorResponse = this.prismaErrorService.handleError( + error, + `deleting appeal ${appealId}`, + ); + if (errorResponse.code === 'RECORD_NOT_FOUND') { - throw new NotFoundException({ - message: `Appeal with ID ${appealId} was not found`, - code: errorResponse.code + throw new NotFoundException({ + message: `Appeal with ID ${appealId} was not found`, + code: errorResponse.code, }); } - + throw new InternalServerErrorException({ message: errorResponse.message, code: errorResponse.code, @@ -205,15 +214,18 @@ export class AppealController { this.logger.log(`Appeal response created for appeal ID: ${appealId}`); return data.appealResponse as AppealResponseResponseDto; } catch (error) { - const errorResponse = this.prismaErrorService.handleError(error, `creating response for appeal ${appealId}`); - + const errorResponse = this.prismaErrorService.handleError( + error, + `creating response for appeal ${appealId}`, + ); + if (errorResponse.code === 'RECORD_NOT_FOUND') { - throw new NotFoundException({ - message: `Appeal with ID ${appealId} was not found`, - code: errorResponse.code + throw new NotFoundException({ + message: `Appeal with ID ${appealId} was not found`, + code: errorResponse.code, }); } - + throw new InternalServerErrorException({ message: errorResponse.message, code: errorResponse.code, @@ -253,18 +265,23 @@ export class AppealController { where: { id: appealResponseId }, data: mapAppealResponseRequestToDto(body), }); - this.logger.log(`Appeal response updated successfully: ${appealResponseId}`); + this.logger.log( + `Appeal response updated successfully: ${appealResponseId}`, + ); return data as AppealResponseRequestDto; } catch (error) { - const errorResponse = this.prismaErrorService.handleError(error, `updating appeal response ${appealResponseId}`); - + const errorResponse = this.prismaErrorService.handleError( + error, + `updating appeal response ${appealResponseId}`, + ); + if (errorResponse.code === 'RECORD_NOT_FOUND') { - throw new NotFoundException({ - message: `Appeal response with ID ${appealResponseId} was not found`, - code: errorResponse.code + throw new NotFoundException({ + message: `Appeal response with ID ${appealResponseId} was not found`, + code: errorResponse.code, }); } - + throw new InternalServerErrorException({ message: errorResponse.message, code: errorResponse.code, @@ -306,18 +323,20 @@ export class AppealController { @Query('reviewId') reviewId?: string, @Query() paginationDto?: PaginationDto, ): Promise> { - this.logger.log(`Getting appeals with filters - resourceId: ${resourceId}, challengeId: ${challengeId}, reviewId: ${reviewId}`); - + this.logger.log( + `Getting appeals with filters - resourceId: ${resourceId}, challengeId: ${challengeId}, reviewId: ${reviewId}`, + ); + const { page = 1, perPage = 10 } = paginationDto || {}; const skip = (page - 1) * perPage; - + try { // Build where clause for filtering const whereClause: any = {}; if (resourceId) whereClause.resourceId = resourceId; if (challengeId) whereClause.challengeId = challengeId; if (reviewId) whereClause.appealId = reviewId; - + const [appeals, totalCount] = await Promise.all([ this.prisma.appealResponse.findMany({ where: whereClause, @@ -326,11 +345,13 @@ export class AppealController { }), this.prisma.appealResponse.count({ where: whereClause, - }) + }), ]); - - this.logger.log(`Found ${appeals.length} appeals (page ${page} of ${Math.ceil(totalCount / perPage)})`); - + + this.logger.log( + `Found ${appeals.length} appeals (page ${page} of ${Math.ceil(totalCount / perPage)})`, + ); + return { data: appeals.map((appeal) => ({ ...appeal, @@ -341,10 +362,13 @@ export class AppealController { perPage, totalCount, totalPages: Math.ceil(totalCount / perPage), - } + }, }; } catch (error) { - const errorResponse = this.prismaErrorService.handleError(error, 'fetching appeals'); + const errorResponse = this.prismaErrorService.handleError( + error, + 'fetching appeals', + ); throw new InternalServerErrorException({ message: errorResponse.message, code: errorResponse.code, diff --git a/src/api/contact/contactRequests.controller.ts b/src/api/contact/contactRequests.controller.ts index 42713b3..e473003 100644 --- a/src/api/contact/contactRequests.controller.ts +++ b/src/api/contact/contactRequests.controller.ts @@ -26,9 +26,9 @@ export class ContactRequestsController { @Post('/contact-requests') @Roles(UserRole.Submitter, UserRole.Reviewer) @Scopes(Scope.CreateContactRequest) - @ApiOperation({ + @ApiOperation({ summary: 'Create a new contact request', - description: 'Roles: Submitter, Reviewer | Scopes: create:contact-request' + description: 'Roles: Submitter, Reviewer | Scopes: create:contact-request', }) @ApiBody({ description: 'Contact request body', type: ContactRequestDto }) @ApiResponse({ diff --git a/src/api/project-result/projectResult.controller.ts b/src/api/project-result/projectResult.controller.ts index 207645e..f8e746a 100644 --- a/src/api/project-result/projectResult.controller.ts +++ b/src/api/project-result/projectResult.controller.ts @@ -1,4 +1,9 @@ -import { Controller, Get, Query, InternalServerErrorException } from '@nestjs/common'; +import { + Controller, + Get, + Query, + InternalServerErrorException, +} from '@nestjs/common'; import { ApiTags, ApiOperation, @@ -34,7 +39,8 @@ export class ProjectResultController { @Scopes(Scope.ReadProjectResult) @ApiOperation({ summary: 'Get project results', - description: 'Roles: Reviewer, Copilot, Submitter | Scopes: read:project-result', + description: + 'Roles: Reviewer, Copilot, Submitter | Scopes: read:project-result', }) @ApiQuery({ name: 'challengeId', @@ -52,10 +58,10 @@ export class ProjectResultController { @Query() paginationDto?: PaginationDto, ): Promise> { this.logger.log(`Getting project results for challengeId: ${challengeId}`); - + const { page = 1, perPage = 10 } = paginationDto || {}; const skip = (page - 1) * perPage; - + try { const [projectResults, totalCount] = await Promise.all([ this.prisma.challengeResult.findMany({ @@ -65,22 +71,27 @@ export class ProjectResultController { }), this.prisma.challengeResult.count({ where: { challengeId }, - }) + }), ]); - - this.logger.log(`Found ${projectResults.length} project results (page ${page} of ${Math.ceil(totalCount / perPage)})`); - + + this.logger.log( + `Found ${projectResults.length} project results (page ${page} of ${Math.ceil(totalCount / perPage)})`, + ); + return { data: projectResults as ProjectResultResponseDto[], meta: { page, - perPage, + perPage, totalCount, totalPages: Math.ceil(totalCount / perPage), - } + }, }; } catch (error) { - const errorResponse = this.prismaErrorService.handleError(error, `fetching project results for challenge ${challengeId}`); + const errorResponse = this.prismaErrorService.handleError( + error, + `fetching project results for challenge ${challengeId}`, + ); throw new InternalServerErrorException({ message: errorResponse.message, code: errorResponse.code, diff --git a/src/api/review-summation/review-summation.controller.ts b/src/api/review-summation/review-summation.controller.ts new file mode 100644 index 0000000..186e5f8 --- /dev/null +++ b/src/api/review-summation/review-summation.controller.ts @@ -0,0 +1,362 @@ +import { + Controller, + Post, + Patch, + Put, + Get, + Delete, + Body, + Param, + Query, + NotFoundException, + InternalServerErrorException, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, + ApiBody, + ApiBearerAuth, +} from '@nestjs/swagger'; + +import { Roles } from 'src/shared/guards/tokenRoles.guard'; +import { UserRole } from 'src/shared/enums/userRole.enum'; +import { Scopes } from 'src/shared/decorators/scopes.decorator'; +import { Scope } from 'src/shared/enums/scopes.enum'; +import { + ReviewSummationQueryDto, + ReviewSummationResponseDto, + ReviewSummationRequestDto, + ReviewSummationPutRequestDto, + ReviewSummationUpdateRequestDto, +} from 'src/dto/reviewSummation.dto'; +import { PrismaService } from '../../shared/modules/global/prisma.service'; +import { LoggerService } from '../../shared/modules/global/logger.service'; +import { PaginatedResponse, PaginationDto } from '../../dto/pagination.dto'; +import { SortDto } from '../../dto/sort.dto'; +import { PrismaErrorService } from '../../shared/modules/global/prisma-error.service'; + +@ApiTags('ReviewSummations') +@ApiBearerAuth() +@Controller('/api/reviewSummations') +export class ReviewSummationController { + private readonly logger: LoggerService; + + constructor( + private readonly prisma: PrismaService, + private readonly prismaErrorService: PrismaErrorService, + ) { + this.logger = LoggerService.forRoot('ReviewSummationController'); + } + + @Post() + @Roles(UserRole.Admin, UserRole.Copilot) + @Scopes(Scope.CreateReviewSummation) + @ApiOperation({ + summary: 'Create a new review summation', + description: 'Roles: Admin, Copilot | Scopes: create:review_summation', + }) + @ApiBody({ description: 'Review type data', type: ReviewSummationRequestDto }) + @ApiResponse({ + status: 201, + description: 'Review type created successfully.', + type: ReviewSummationResponseDto, + }) + async createReviewSummation( + @Body() body: ReviewSummationRequestDto, + ): Promise { + this.logger.log( + `Creating review summation with request boy: ${JSON.stringify(body)}`, + ); + try { + const data = await this.prisma.reviewSummation.create({ + data: body, + }); + this.logger.log(`Review type created with ID: ${data.id}`); + return data as ReviewSummationResponseDto; + } catch (error) { + const errorResponse = this.prismaErrorService.handleError( + error, + 'creating review summation', + ); + throw new InternalServerErrorException({ + message: errorResponse.message, + code: errorResponse.code, + }); + } + } + + @Patch('/:reviewSummationId') + @Roles(UserRole.Admin) + @Scopes(Scope.UpdateReviewSummation) + @ApiOperation({ + summary: 'Update a review summation partially', + description: 'Roles: Admin | Scopes: update:review_summation', + }) + @ApiParam({ + name: 'reviewSummationId', + description: 'The ID of the review summation', + }) + @ApiBody({ + description: 'Review type data', + type: ReviewSummationUpdateRequestDto, + }) + @ApiResponse({ + status: 200, + description: 'Review type updated successfully.', + type: ReviewSummationResponseDto, + }) + @ApiResponse({ status: 404, description: 'Review type not found.' }) + async patchReviewSummation( + @Param('reviewSummationId') reviewSummationId: string, + @Body() body: ReviewSummationUpdateRequestDto, + ): Promise { + return this._updateReviewSummation(reviewSummationId, body); + } + + @Put('/:reviewSummationId') + @Roles(UserRole.Admin) + @Scopes(Scope.UpdateReviewSummation) + @ApiOperation({ + summary: 'Update a review summation', + description: 'Roles: Admin | Scopes: update:review_summation', + }) + @ApiParam({ + name: 'reviewSummationId', + description: 'The ID of the review summation', + }) + @ApiBody({ + description: 'Review type data', + type: ReviewSummationPutRequestDto, + }) + @ApiResponse({ + status: 200, + description: 'Review type updated successfully.', + type: ReviewSummationResponseDto, + }) + @ApiResponse({ status: 404, description: 'Review type not found.' }) + async updateReviewSummation( + @Param('reviewSummationId') reviewSummationId: string, + @Body() body: ReviewSummationPutRequestDto, + ): Promise { + return this._updateReviewSummation(reviewSummationId, body); + } + + /** + * The inner update method for entity + */ + async _updateReviewSummation( + reviewSummationId: string, + body: ReviewSummationUpdateRequestDto, + ): Promise { + this.logger.log(`Updating review summation with ID: ${reviewSummationId}`); + try { + const data = await this.prisma.reviewSummation.update({ + where: { id: reviewSummationId }, + data: body, + }); + this.logger.log(`Review type updated successfully: ${reviewSummationId}`); + return data as ReviewSummationResponseDto; + } catch (error) { + throw this._rethrowError( + error, + reviewSummationId, + `updating review summation ${reviewSummationId}`, + ); + } + } + + @Get() + @Roles(UserRole.Copilot, UserRole.Admin) + @Scopes(Scope.ReadReviewSummation) + @ApiOperation({ + summary: 'Search for review summations', + description: 'Roles: Copilot, Admin. | Scopes: read:review_summation', + }) + @ApiResponse({ + status: 200, + description: 'List of review summations.', + type: [ReviewSummationResponseDto], + }) + async listReviewSummations( + @Query() queryDto: ReviewSummationQueryDto, + @Query() paginationDto?: PaginationDto, + @Query() sortDto?: SortDto, + ): Promise> { + this.logger.log( + `Getting review summations with filters - ${JSON.stringify(queryDto)}`, + ); + + const { page = 1, perPage = 10 } = paginationDto || {}; + const skip = (page - 1) * perPage; + let orderBy; + + if (sortDto && sortDto.orderBy && sortDto.sortBy) { + orderBy = { + [sortDto.sortBy]: sortDto.orderBy.toLowerCase(), + }; + } + + try { + // Build the where clause for review summations based on available filter parameters + const reviewSummationWhereClause: any = {}; + if (queryDto.submissionId) { + reviewSummationWhereClause.submissionId = queryDto.submissionId; + } + if (queryDto.aggregateScore) { + reviewSummationWhereClause.aggregateScore = parseFloat( + queryDto.aggregateScore, + ); + } + if (queryDto.scorecardId) { + reviewSummationWhereClause.scorecardId = queryDto.scorecardId; + } + if (queryDto.isPassing !== undefined) { + reviewSummationWhereClause.isPassing = + queryDto.isPassing.toLowerCase() === 'true'; + } + if (queryDto.isFinal !== undefined) { + reviewSummationWhereClause.isFinal = + queryDto.isFinal.toLowerCase() === 'true'; + } + + // find entities by filters + const reviewSummations = await this.prisma.reviewSummation.findMany({ + where: { + ...reviewSummationWhereClause, + }, + skip, + take: perPage, + orderBy, + }); + + // Count total entities matching the filter for pagination metadata + const totalCount = await this.prisma.reviewSummation.count({ + where: { + ...reviewSummationWhereClause, + }, + }); + + this.logger.log( + `Found ${reviewSummations.length} review summations (page ${page} of ${Math.ceil(totalCount / perPage)})`, + ); + + return { + data: reviewSummations as ReviewSummationResponseDto[], + meta: { + page, + perPage, + totalCount, + totalPages: Math.ceil(totalCount / perPage), + }, + }; + } catch (error) { + const errorResponse = this.prismaErrorService.handleError( + error, + 'fetching review summations', + ); + throw new InternalServerErrorException({ + message: errorResponse.message, + code: errorResponse.code, + }); + } + } + + @Get('/:reviewSummationId') + @Roles(UserRole.Copilot, UserRole.Admin) + @Scopes(Scope.ReadReviewSummation) + @ApiOperation({ + summary: 'View a specific review summation', + description: 'Roles: Copilot, Admin | Scopes: read:review_summation', + }) + @ApiParam({ + name: 'reviewSummationId', + description: 'The ID of the review summation', + }) + @ApiResponse({ + status: 200, + description: 'Review type retrieved successfully.', + type: ReviewSummationResponseDto, + }) + @ApiResponse({ status: 404, description: 'Review type not found.' }) + async getReviewSummation( + @Param('reviewSummationId') reviewSummationId: string, + ): Promise { + this.logger.log(`Getting review summation with ID: ${reviewSummationId}`); + try { + const data = await this.prisma.reviewSummation.findUniqueOrThrow({ + where: { id: reviewSummationId }, + }); + + this.logger.log(`Review summation found: ${reviewSummationId}`); + return data as ReviewSummationResponseDto; + } catch (error) { + throw this._rethrowError( + error, + reviewSummationId, + `fetching review summation ${reviewSummationId}`, + ); + } + } + + @Delete('/:reviewSummationId') + @Roles(UserRole.Admin) + @Scopes(Scope.DeleteReviewSummation) + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ + summary: 'Delete a review summation', + description: 'Roles: Admin | Scopes: delete:review_summation', + }) + @ApiParam({ + name: 'reviewSummationId', + description: 'The ID of the review summation', + }) + @ApiResponse({ + status: 200, + description: 'Review type deleted successfully.', + }) + @ApiResponse({ status: 403, description: 'Forbidden.' }) + @ApiResponse({ status: 404, description: 'Review not found.' }) + async deleteReviewSummation( + @Param('reviewSummationId') reviewSummationId: string, + ) { + this.logger.log(`Deleting review summation with ID: ${reviewSummationId}`); + try { + await this.prisma.reviewSummation.delete({ + where: { id: reviewSummationId }, + }); + this.logger.log(`Review type deleted successfully: ${reviewSummationId}`); + return { + message: `Review type ${reviewSummationId} deleted successfully.`, + }; + } catch (error) { + throw this._rethrowError( + error, + reviewSummationId, + `deleting review summation ${reviewSummationId}`, + ); + } + } + + /** + * Build exception by error code + */ + _rethrowError(error: any, reviewSummationId: string, message: string) { + const errorResponse = this.prismaErrorService.handleError(error, message); + + if (errorResponse.code === 'RECORD_NOT_FOUND') { + return new NotFoundException({ + message: `Review type with ID ${reviewSummationId} was not found`, + code: errorResponse.code, + }); + } + + return new InternalServerErrorException({ + message: errorResponse.message, + code: errorResponse.code, + }); + } +} diff --git a/src/api/review-type/review-type.controller.ts b/src/api/review-type/review-type.controller.ts new file mode 100644 index 0000000..2ba6f9b --- /dev/null +++ b/src/api/review-type/review-type.controller.ts @@ -0,0 +1,348 @@ +import { + Controller, + Post, + Patch, + Put, + Get, + Delete, + Body, + Param, + Query, + NotFoundException, + InternalServerErrorException, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, + ApiBody, + ApiBearerAuth, +} from '@nestjs/swagger'; + +import { Roles } from 'src/shared/guards/tokenRoles.guard'; +import { UserRole } from 'src/shared/enums/userRole.enum'; +import { Scopes } from 'src/shared/decorators/scopes.decorator'; +import { Scope } from 'src/shared/enums/scopes.enum'; +import { + ReviewTypeQueryDto, + ReviewTypeResponseDto, + ReviewTypeRequestDto, + ReviewTypeUpdateRequestDto, +} from 'src/dto/reviewType.dto'; +import { PrismaService } from '../../shared/modules/global/prisma.service'; +import { LoggerService } from '../../shared/modules/global/logger.service'; +import { PaginatedResponse, PaginationDto } from '../../dto/pagination.dto'; +import { SortDto } from '../../dto/sort.dto'; +import { PrismaErrorService } from '../../shared/modules/global/prisma-error.service'; + +@ApiTags('ReviewTypes') +@ApiBearerAuth() +@Controller('/api/reviewTypes') +export class ReviewTypeController { + private readonly logger: LoggerService; + + constructor( + private readonly prisma: PrismaService, + private readonly prismaErrorService: PrismaErrorService, + ) { + this.logger = LoggerService.forRoot('ReviewTypeController'); + } + + @Post() + @Roles(UserRole.Admin, UserRole.Copilot) + @Scopes(Scope.CreateReviewType) + @ApiOperation({ + summary: 'Create a new review type', + description: 'Roles: Admin, Copilot | Scopes: create:review_type', + }) + @ApiBody({ description: 'Review type data', type: ReviewTypeRequestDto }) + @ApiResponse({ + status: 201, + description: 'Review type created successfully.', + type: ReviewTypeResponseDto, + }) + async createReviewType( + @Body() body: ReviewTypeRequestDto, + ): Promise { + this.logger.log( + `Creating review type with request boy: ${JSON.stringify(body)}`, + ); + try { + const data = await this.prisma.reviewType.create({ + data: body, + }); + this.logger.log(`Review type created with ID: ${data.id}`); + return data as ReviewTypeResponseDto; + } catch (error) { + const errorResponse = this.prismaErrorService.handleError( + error, + 'creating review type', + ); + throw new InternalServerErrorException({ + message: errorResponse.message, + code: errorResponse.code, + }); + } + } + + @Patch('/:reviewTypeId') + @Roles(UserRole.Admin) + @Scopes(Scope.UpdateReviewType) + @ApiOperation({ + summary: 'Update a review type partially', + description: 'Roles: Admin | Scopes: update:review_type', + }) + @ApiParam({ + name: 'reviewTypeId', + description: 'The ID of the review type', + }) + @ApiBody({ + description: 'Review type data', + type: ReviewTypeUpdateRequestDto, + }) + @ApiResponse({ + status: 200, + description: 'Review type updated successfully.', + type: ReviewTypeResponseDto, + }) + @ApiResponse({ status: 404, description: 'Review type not found.' }) + async patchReviewType( + @Param('reviewTypeId') reviewTypeId: string, + @Body() body: ReviewTypeUpdateRequestDto, + ): Promise { + return this._updateReviewType(reviewTypeId, body); + } + + @Put('/:reviewTypeId') + @Roles(UserRole.Admin) + @Scopes(Scope.UpdateReviewType) + @ApiOperation({ + summary: 'Update a review type', + description: 'Roles: Admin | Scopes: update:review_type', + }) + @ApiParam({ + name: 'reviewTypeId', + description: 'The ID of the review type', + }) + @ApiBody({ description: 'Review type data', type: ReviewTypeRequestDto }) + @ApiResponse({ + status: 200, + description: 'Review type updated successfully.', + type: ReviewTypeResponseDto, + }) + @ApiResponse({ status: 404, description: 'Review type not found.' }) + async updateReviewType( + @Param('reviewTypeId') reviewTypeId: string, + @Body() body: ReviewTypeRequestDto, + ): Promise { + return this._updateReviewType(reviewTypeId, body); + } + + /** + * The inner update method for entity + */ + async _updateReviewType( + reviewTypeId: string, + body: ReviewTypeUpdateRequestDto, + ): Promise { + this.logger.log(`Updating review type with ID: ${reviewTypeId}`); + try { + const data = await this.prisma.reviewType.update({ + where: { id: reviewTypeId }, + data: body, + }); + this.logger.log(`Review type updated successfully: ${reviewTypeId}`); + return data as ReviewTypeResponseDto; + } catch (error) { + throw this._rethrowError( + error, + reviewTypeId, + `updating review type ${reviewTypeId}`, + ); + } + } + + @Get() + @Roles( + UserRole.Copilot, + UserRole.Admin, + UserRole.Submitter, + UserRole.Reviewer, + ) + @Scopes(Scope.ReadReviewType) + @ApiOperation({ + summary: 'Search for review types', + description: + 'Roles: Copilot, Admin, Submitter, Reviewer. | Scopes: read:review_type', + }) + @ApiResponse({ + status: 200, + description: 'List of review types.', + type: [ReviewTypeResponseDto], + }) + async listReviewTypes( + @Query() queryDto: ReviewTypeQueryDto, + @Query() paginationDto?: PaginationDto, + @Query() sortDto?: SortDto, + ): Promise> { + this.logger.log( + `Getting review types with filters - ${JSON.stringify(queryDto)}`, + ); + + const { page = 1, perPage = 10 } = paginationDto || {}; + const skip = (page - 1) * perPage; + let orderBy; + + if (sortDto && sortDto.orderBy && sortDto.sortBy) { + orderBy = { + [sortDto.sortBy]: sortDto.orderBy.toLowerCase(), + }; + } + + try { + // Build the where clause for review types based on available filter parameters + const reviewTypeWhereClause: any = {}; + if (queryDto.name) { + reviewTypeWhereClause.name = queryDto.name; + } + if (queryDto.isActive !== undefined) { + reviewTypeWhereClause.isActive = + queryDto.isActive.toLowerCase() === 'true'; + } + + // find entities by filters + const reviewTypes = await this.prisma.reviewType.findMany({ + where: { + ...reviewTypeWhereClause, + }, + skip, + take: perPage, + orderBy, + }); + + // Count total entities matching the filter for pagination metadata + const totalCount = await this.prisma.reviewType.count({ + where: { + ...reviewTypeWhereClause, + }, + }); + + this.logger.log( + `Found ${reviewTypes.length} review types (page ${page} of ${Math.ceil(totalCount / perPage)})`, + ); + + return { + data: reviewTypes as ReviewTypeResponseDto[], + meta: { + page, + perPage, + totalCount, + totalPages: Math.ceil(totalCount / perPage), + }, + }; + } catch (error) { + const errorResponse = this.prismaErrorService.handleError( + error, + 'fetching review types', + ); + throw new InternalServerErrorException({ + message: errorResponse.message, + code: errorResponse.code, + }); + } + } + + @Get('/:reviewTypeId') + @Roles(UserRole.Copilot, UserRole.Admin) + @Scopes(Scope.ReadReviewType) + @ApiOperation({ + summary: 'View a specific review type', + description: 'Roles: Copilot, Admin | Scopes: read:review_type', + }) + @ApiParam({ + name: 'reviewTypeId', + description: 'The ID of the review type', + }) + @ApiResponse({ + status: 200, + description: 'Review type retrieved successfully.', + type: ReviewTypeResponseDto, + }) + @ApiResponse({ status: 404, description: 'Review type not found.' }) + async getReviewType( + @Param('reviewTypeId') reviewTypeId: string, + ): Promise { + this.logger.log(`Getting review type with ID: ${reviewTypeId}`); + try { + const data = await this.prisma.reviewType.findUniqueOrThrow({ + where: { id: reviewTypeId }, + }); + + this.logger.log(`Review type found: ${reviewTypeId}`); + return data as ReviewTypeResponseDto; + } catch (error) { + throw this._rethrowError( + error, + reviewTypeId, + `fetching review type ${reviewTypeId}`, + ); + } + } + + @Delete('/:reviewTypeId') + @Roles(UserRole.Admin) + @Scopes(Scope.DeleteReviewType) + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ + summary: 'Delete a review type', + description: 'Roles: Admin | Scopes: delete:review_type', + }) + @ApiParam({ + name: 'reviewTypeId', + description: 'The ID of the review type', + }) + @ApiResponse({ + status: 204, + description: 'Review type deleted successfully.', + }) + @ApiResponse({ status: 403, description: 'Forbidden.' }) + @ApiResponse({ status: 404, description: 'Review not found.' }) + async deleteReviewType(@Param('reviewTypeId') reviewTypeId: string) { + this.logger.log(`Deleting review type with ID: ${reviewTypeId}`); + try { + await this.prisma.reviewType.delete({ + where: { id: reviewTypeId }, + }); + this.logger.log(`Review type deleted successfully: ${reviewTypeId}`); + return { message: `Review type ${reviewTypeId} deleted successfully.` }; + } catch (error) { + throw this._rethrowError( + error, + reviewTypeId, + `deleting review type ${reviewTypeId}`, + ); + } + } + + /** + * Build exception by error code + */ + _rethrowError(error: any, reviewTypeId: string, message: string) { + const errorResponse = this.prismaErrorService.handleError(error, message); + + if (errorResponse.code === 'RECORD_NOT_FOUND') { + return new NotFoundException({ + message: `Review type with ID ${reviewTypeId} was not found`, + code: errorResponse.code, + }); + } + + return new InternalServerErrorException({ + message: errorResponse.message, + code: errorResponse.code, + }); + } +} diff --git a/src/api/review/review.controller.ts b/src/api/review/review.controller.ts index d4291a7..bc282c6 100644 --- a/src/api/review/review.controller.ts +++ b/src/api/review/review.controller.ts @@ -2,6 +2,7 @@ import { Controller, Post, Patch, + Put, Get, Delete, Body, @@ -25,6 +26,8 @@ import { Scopes } from 'src/shared/decorators/scopes.decorator'; import { Scope } from 'src/shared/enums/scopes.enum'; import { ReviewRequestDto, + ReviewPutRequestDto, + ReviewPatchRequestDto, ReviewResponseDto, ReviewItemRequestDto, ReviewItemResponseDto, @@ -69,7 +72,7 @@ export class ReviewController { this.logger.log(`Creating review for submissionId: ${body.submissionId}`); try { const data = await this.prisma.review.create({ - data: mapReviewRequestToDto(body), + data: mapReviewRequestToDto(body) as any, include: { reviewItems: { include: { @@ -81,7 +84,10 @@ export class ReviewController { this.logger.log(`Review created with ID: ${data.id}`); return data as unknown as ReviewResponseDto; } catch (error) { - const errorResponse = this.prismaErrorService.handleError(error, 'creating review'); + const errorResponse = this.prismaErrorService.handleError( + error, + 'creating review', + ); throw new InternalServerErrorException({ message: errorResponse.message, code: errorResponse.code, @@ -116,7 +122,10 @@ export class ReviewController { this.logger.log(`Review item created with ID: ${data.id}`); return data as unknown as ReviewItemResponseDto; } catch (error) { - const errorResponse = this.prismaErrorService.handleError(error, 'creating review item'); + const errorResponse = this.prismaErrorService.handleError( + error, + 'creating review item', + ); throw new InternalServerErrorException({ message: errorResponse.message, code: errorResponse.code, @@ -124,7 +133,7 @@ export class ReviewController { } } - @Patch('/:id') + @Patch('/:reviewId') @Roles(UserRole.Reviewer) @Scopes(Scope.UpdateReview) @ApiOperation({ @@ -132,11 +141,11 @@ export class ReviewController { description: 'Roles: Reviewer | Scopes: update:review', }) @ApiParam({ - name: 'id', + name: 'reviewId', description: 'The ID of the review', example: 'review123', }) - @ApiBody({ description: 'Review data', type: ReviewRequestDto }) + @ApiBody({ description: 'Review data', type: ReviewPatchRequestDto }) @ApiResponse({ status: 200, description: 'Review updated successfully.', @@ -144,14 +153,50 @@ export class ReviewController { }) @ApiResponse({ status: 404, description: 'Review not found.' }) async updateReview( - @Param('id') id: string, - @Body() body: ReviewRequestDto, + @Param('reviewId') reviewId: string, + @Body() body: ReviewPatchRequestDto, + ): Promise { + return this._updateReview(reviewId, body); + } + + @Put('/:reviewId') + @Roles(UserRole.Reviewer) + @Scopes(Scope.UpdateReview) + @ApiOperation({ + summary: 'Update a review partially', + description: 'Roles: Reviewer | Scopes: update:review', + }) + @ApiParam({ + name: 'reviewId', + description: 'The ID of the review', + example: 'review123', + }) + @ApiBody({ description: 'Review data', type: ReviewPutRequestDto }) + @ApiResponse({ + status: 200, + description: 'Review updated successfully.', + type: ReviewResponseDto, + }) + @ApiResponse({ status: 404, description: 'Review not found.' }) + async updatePutReview( + @Param('reviewId') reviewId: string, + @Body() body: ReviewPutRequestDto, ): Promise { + return this._updateReview(reviewId, body); + } + + /** + * The inner update method for entity + */ + async _updateReview( + id: string, + body: ReviewPatchRequestDto | ReviewPutRequestDto, + ) { this.logger.log(`Updating review with ID: ${id}`); try { const data = await this.prisma.review.update({ where: { id }, - data: mapReviewRequestToDto(body), + data: mapReviewRequestToDto(body as ReviewPatchRequestDto), include: { reviewItems: { include: { @@ -163,15 +208,18 @@ export class ReviewController { this.logger.log(`Review updated successfully: ${id}`); return data as unknown as ReviewResponseDto; } catch (error) { - const errorResponse = this.prismaErrorService.handleError(error, `updating review ${id}`); - + const errorResponse = this.prismaErrorService.handleError( + error, + `updating review ${id}`, + ); + if (errorResponse.code === 'RECORD_NOT_FOUND') { - throw new NotFoundException({ - message: `Review with ID ${id} was not found`, - code: errorResponse.code + throw new NotFoundException({ + message: `Review with ID ${id} was not found`, + code: errorResponse.code, }); } - + throw new InternalServerErrorException({ message: errorResponse.message, code: errorResponse.code, @@ -215,15 +263,18 @@ export class ReviewController { this.logger.log(`Review item updated successfully: ${itemId}`); return data as unknown as ReviewItemResponseDto; } catch (error) { - const errorResponse = this.prismaErrorService.handleError(error, `updating review item ${itemId}`); - + const errorResponse = this.prismaErrorService.handleError( + error, + `updating review item ${itemId}`, + ); + if (errorResponse.code === 'RECORD_NOT_FOUND') { - throw new NotFoundException({ - message: `Review item with ID ${itemId} was not found`, - code: errorResponse.code + throw new NotFoundException({ + message: `Review item with ID ${itemId} was not found`, + code: errorResponse.code, }); } - + throw new InternalServerErrorException({ message: errorResponse.message, code: errorResponse.code, @@ -272,7 +323,9 @@ export class ReviewController { @Query('submissionId') submissionId?: string, @Query() paginationDto?: PaginationDto, ): Promise> { - this.logger.log(`Getting pending reviews with filters - status: ${status}, challengeId: ${challengeId}, submissionId: ${submissionId}`); + this.logger.log( + `Getting pending reviews with filters - status: ${status}, challengeId: ${challengeId}, submissionId: ${submissionId}`, + ); const { page = 1, perPage = 10 } = paginationDto || {}; const skip = (page - 1) * perPage; @@ -302,11 +355,11 @@ export class ReviewController { }, }, }); - + reviews = scorecards.flatMap((d) => d.reviews); - + // Count total reviews matching the filter for pagination metadata - const scorecardIds = scorecards.map(s => s.id); + const scorecardIds = scorecards.map((s) => s.id); totalCount = await this.prisma.review.count({ where: { ...reviewWhereClause, @@ -322,8 +375,8 @@ export class ReviewController { where: { challengeId }, }); - const submissionIds = challengeResults.map(c => c.submissionId); - + const submissionIds = challengeResults.map((c) => c.submissionId); + if (submissionIds.length > 0) { const challengeReviews = await this.prisma.review.findMany({ where: { @@ -340,9 +393,9 @@ export class ReviewController { }, }, }); - + reviews = [...reviews, ...challengeReviews]; - + // Count total for this condition separately const challengeReviewCount = await this.prisma.review.count({ where: { @@ -350,11 +403,11 @@ export class ReviewController { ...reviewWhereClause, }, }); - + totalCount += challengeReviewCount; } } - + // If no specific filters, get all reviews with pagination if (!status && !challengeId && !submissionId) { this.logger.debug('Fetching all reviews with pagination'); @@ -369,7 +422,7 @@ export class ReviewController { }, }, }); - + totalCount = await this.prisma.review.count(); } @@ -379,12 +432,14 @@ export class ReviewController { if (!acc[review.id]) { acc[review.id] = review; } - return acc; - }, {}) + return acc; // eslint-disable-line @typescript-eslint/no-unsafe-return + }, {}), + ); + + this.logger.log( + `Found ${uniqueReviews.length} reviews (page ${page} of ${Math.ceil(totalCount / perPage)})`, ); - this.logger.log(`Found ${uniqueReviews.length} reviews (page ${page} of ${Math.ceil(totalCount / perPage)})`); - return { data: uniqueReviews as ReviewResponseDto[], meta: { @@ -395,7 +450,10 @@ export class ReviewController { }, }; } catch (error) { - const errorResponse = this.prismaErrorService.handleError(error, 'fetching reviews'); + const errorResponse = this.prismaErrorService.handleError( + error, + 'fetching reviews', + ); throw new InternalServerErrorException({ message: errorResponse.message, code: errorResponse.code, @@ -403,7 +461,7 @@ export class ReviewController { } } - @Get('/:id') + @Get('/:reviewId') @Roles( UserRole.Reviewer, UserRole.Copilot, @@ -417,7 +475,7 @@ export class ReviewController { 'Roles: Reviewer, Copilot, Submitter, Admin | Scopes: read:review', }) @ApiParam({ - name: 'id', + name: 'reviewId', description: 'The ID of the review', example: 'review123', }) @@ -427,11 +485,13 @@ export class ReviewController { type: ReviewResponseDto, }) @ApiResponse({ status: 404, description: 'Review not found.' }) - async getReview(@Param('id') id: string): Promise { - this.logger.log(`Getting review with ID: ${id}`); + async getReview( + @Param('reviewId') reviewId: string, + ): Promise { + this.logger.log(`Getting review with ID: ${reviewId}`); try { const data = await this.prisma.review.findUniqueOrThrow({ - where: { id }, + where: { id: reviewId }, include: { reviewItems: { include: { @@ -440,19 +500,22 @@ export class ReviewController { }, }, }); - - this.logger.log(`Review found: ${id}`); + + this.logger.log(`Review found: ${reviewId}`); return data as ReviewResponseDto; } catch (error) { - const errorResponse = this.prismaErrorService.handleError(error, `fetching review ${id}`); - + const errorResponse = this.prismaErrorService.handleError( + error, + `fetching review ${reviewId}`, + ); + if (errorResponse.code === 'RECORD_NOT_FOUND') { - throw new NotFoundException({ - message: `Review with ID ${id} was not found`, - code: errorResponse.code + throw new NotFoundException({ + message: `Review with ID ${reviewId} was not found`, + code: errorResponse.code, }); } - + throw new InternalServerErrorException({ message: errorResponse.message, code: errorResponse.code, @@ -460,7 +523,7 @@ export class ReviewController { } } - @Delete('/:id') + @Delete('/:reviewId') @Roles(UserRole.Copilot, UserRole.Admin) @Scopes(Scope.DeleteReview) @ApiOperation({ @@ -468,7 +531,7 @@ export class ReviewController { description: 'Roles: Copilot, Admin | Scopes: delete:review', }) @ApiParam({ - name: 'id', + name: 'reviewId', description: 'The ID of the review', example: 'mock-review-id', }) @@ -478,24 +541,27 @@ export class ReviewController { }) @ApiResponse({ status: 403, description: 'Forbidden.' }) @ApiResponse({ status: 404, description: 'Review not found.' }) - async deleteReview(@Param('id') id: string) { - this.logger.log(`Deleting review with ID: ${id}`); + async deleteReview(@Param('reviewId') reviewId: string) { + this.logger.log(`Deleting review with ID: ${reviewId}`); try { await this.prisma.review.delete({ - where: { id }, + where: { id: reviewId }, }); - this.logger.log(`Review deleted successfully: ${id}`); - return { message: `Review ${id} deleted successfully.` }; + this.logger.log(`Review deleted successfully: ${reviewId}`); + return { message: `Review ${reviewId} deleted successfully.` }; } catch (error) { - const errorResponse = this.prismaErrorService.handleError(error, `deleting review ${id}`); - + const errorResponse = this.prismaErrorService.handleError( + error, + `deleting review ${reviewId}`, + ); + if (errorResponse.code === 'RECORD_NOT_FOUND') { - throw new NotFoundException({ - message: `Review with ID ${id} was not found`, - code: errorResponse.code + throw new NotFoundException({ + message: `Review with ID ${reviewId} was not found`, + code: errorResponse.code, }); } - + throw new InternalServerErrorException({ message: errorResponse.message, code: errorResponse.code, @@ -530,15 +596,18 @@ export class ReviewController { this.logger.log(`Review item deleted successfully: ${itemId}`); return { message: `Review item ${itemId} deleted successfully.` }; } catch (error) { - const errorResponse = this.prismaErrorService.handleError(error, `deleting review item ${itemId}`); - + const errorResponse = this.prismaErrorService.handleError( + error, + `deleting review item ${itemId}`, + ); + if (errorResponse.code === 'RECORD_NOT_FOUND') { - throw new NotFoundException({ - message: `Review item with ID ${itemId} was not found`, - code: errorResponse.code + throw new NotFoundException({ + message: `Review item with ID ${itemId} was not found`, + code: errorResponse.code, }); } - + throw new InternalServerErrorException({ message: errorResponse.message, code: errorResponse.code, diff --git a/src/api/scorecard/scorecard.controller.ts b/src/api/scorecard/scorecard.controller.ts index 7ae7532..361a6b9 100644 --- a/src/api/scorecard/scorecard.controller.ts +++ b/src/api/scorecard/scorecard.controller.ts @@ -40,9 +40,9 @@ export class ScorecardController { @Post() @Roles(UserRole.Admin) @Scopes(Scope.CreateScorecard) - @ApiOperation({ - summary: 'Add a new scorecard', - description: 'Roles: Admin | Scopes: create:scorecard' + @ApiOperation({ + summary: 'Add a new scorecard', + description: 'Roles: Admin | Scopes: create:scorecard', }) @ApiBody({ description: 'Scorecard data', type: ScorecardRequestDto }) @ApiResponse({ @@ -127,9 +127,9 @@ export class ScorecardController { @Delete(':id') @Roles(UserRole.Admin) @Scopes(Scope.DeleteScorecard) - @ApiOperation({ - summary: 'Delete a scorecard', - description: 'Roles: Admin | Scopes: delete:scorecard' + @ApiOperation({ + summary: 'Delete a scorecard', + description: 'Roles: Admin | Scopes: delete:scorecard', }) @ApiParam({ name: 'id', @@ -208,7 +208,8 @@ export class ScorecardController { @Scopes(Scope.ReadScorecard) @ApiOperation({ summary: 'Search scorecards', - description: 'Search by challenge track, challenge type, or name. Roles: Admin, Copilot | Scopes: read:scorecard', + description: + 'Search by challenge track, challenge type, or name. Roles: Admin, Copilot | Scopes: read:scorecard', }) @ApiQuery({ name: 'challengeTrack', diff --git a/src/api/submission/submission.controller.ts b/src/api/submission/submission.controller.ts new file mode 100644 index 0000000..2e215c4 --- /dev/null +++ b/src/api/submission/submission.controller.ts @@ -0,0 +1,667 @@ +import { + Controller, + Post, + Patch, + Put, + Get, + Delete, + Body, + Param, + Query, + NotFoundException, + InternalServerErrorException, + UseInterceptors, + UploadedFile, + StreamableFile, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { + ApiOperation, + ApiParam, + ApiResponse, + ApiTags, + ApiBody, + ApiBearerAuth, + ApiConsumes, +} from '@nestjs/swagger'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { createReadStream } from 'fs'; +import { join } from 'path'; + +import { Roles } from 'src/shared/guards/tokenRoles.guard'; +import { UserRole } from 'src/shared/enums/userRole.enum'; +import { Scopes } from 'src/shared/decorators/scopes.decorator'; +import { Scope } from 'src/shared/enums/scopes.enum'; +import { + SubmissionQueryDto, + SubmissionResponseDto, + SubmissionRequestDto, + SubmissionPutRequestDto, + SubmissionUpdateRequestDto, +} from 'src/dto/submission.dto'; +import { + // ArtifactsCreateRequestDto, + ArtifactsCreateResponseDto, + ArtifactsListResponseDto, +} from 'src/dto/artifacts.dto'; +import { PrismaService } from '../../shared/modules/global/prisma.service'; +import { LoggerService } from '../../shared/modules/global/logger.service'; +import { PaginatedResponse, PaginationDto } from '../../dto/pagination.dto'; +import { SortDto } from '../../dto/sort.dto'; +import { PrismaErrorService } from '../../shared/modules/global/prisma-error.service'; + +@ApiTags('Submissions') +@ApiBearerAuth() +@Controller('/api/submissions') +export class SubmissionController { + private readonly logger: LoggerService; + + constructor( + private readonly prisma: PrismaService, + private readonly prismaErrorService: PrismaErrorService, + ) { + this.logger = LoggerService.forRoot('SubmissionController'); + } + + @Post() + @Roles( + UserRole.Admin, + UserRole.Copilot, + UserRole.Submitter, + UserRole.Reviewer, + ) + @Scopes(Scope.CreateSubmission) + @ApiOperation({ + summary: 'Create a new submission', + description: + 'Roles: Admin, Copilot, Submitter, Reviewer | Scopes: create:submission', + }) + @ApiBody({ description: 'Submission data', type: SubmissionRequestDto }) + @ApiResponse({ + status: 201, + description: 'Submission created successfully.', + type: SubmissionResponseDto, + }) + async createSubmission( + @Body() body: SubmissionRequestDto, + ): Promise { + this.logger.log( + `Creating submission with request boy: ${JSON.stringify(body)}`, + ); + try { + const data = await this.prisma.submission.create({ + data: body, + }); + this.logger.log(`Submission created with ID: ${data.id}`); + return data as SubmissionResponseDto; + } catch (error) { + const errorResponse = this.prismaErrorService.handleError( + error, + 'creating submission', + ); + throw new InternalServerErrorException({ + message: errorResponse.message, + code: errorResponse.code, + }); + } + } + + @Patch('/:submissionId') + @Roles(UserRole.Admin) + @Scopes(Scope.UpdateSubmission) + @ApiOperation({ + summary: 'Update a submission partially', + description: 'Roles: Admin | Scopes: update:submission', + }) + @ApiParam({ + name: 'submissionId', + description: 'The ID of the submission', + }) + @ApiBody({ description: 'submission data', type: SubmissionUpdateRequestDto }) + @ApiResponse({ + status: 200, + description: 'Submission updated successfully.', + type: SubmissionUpdateRequestDto, + }) + @ApiResponse({ status: 404, description: 'Submission not found.' }) + async patchSubmission( + @Param('submissionId') submissionId: string, + @Body() body: SubmissionUpdateRequestDto, + ): Promise { + return this._updateSubmission(submissionId, body); + } + + @Put('/:submissionId') + @Roles(UserRole.Admin) + @Scopes(Scope.UpdateSubmission) + @ApiOperation({ + summary: 'Update a submission', + description: 'Roles: Admin | Scopes: update:submission', + }) + @ApiParam({ + name: 'submissionId', + description: 'The ID of the submission', + }) + @ApiBody({ description: 'Review type data', type: SubmissionPutRequestDto }) + @ApiResponse({ + status: 200, + description: 'Submission updated successfully.', + type: SubmissionRequestDto, + }) + @ApiResponse({ status: 404, description: 'Submission not found.' }) + async updateSubmission( + @Param('submissionId') submissionId: string, + @Body() body: SubmissionPutRequestDto, + ): Promise { + return this._updateSubmission(submissionId, body); + } + + /** + * The inner update method for entity + */ + async _updateSubmission( + submissionId: string, + body: SubmissionUpdateRequestDto, + ): Promise { + this.logger.log(`Updating submission with ID: ${submissionId}`); + try { + const data = await this.prisma.submission.update({ + where: { id: submissionId }, + data: body, + }); + this.logger.log(`Submission updated successfully: ${submissionId}`); + return data as SubmissionResponseDto; + } catch (error) { + throw this._rethrowError( + error, + submissionId, + `updating submission ${submissionId}`, + ); + } + } + + @Get() + @Roles( + UserRole.Copilot, + UserRole.Admin, + UserRole.Submitter, + UserRole.Reviewer, + ) + @Scopes(Scope.ReadSubmission) + @ApiOperation({ + summary: 'Search for submissions', + description: + 'Roles: Copilot, Admin, Submitter, Reviewer. | Scopes: read:submission', + }) + @ApiResponse({ + status: 200, + description: 'List of submissions', + type: [SubmissionResponseDto], + }) + async listSubmissions( + @Query() queryDto: SubmissionQueryDto, + @Query() paginationDto?: PaginationDto, + @Query() sortDto?: SortDto, + ): Promise> { + this.logger.log( + `Getting submissions with filters - ${JSON.stringify(queryDto)}`, + ); + + const { page = 1, perPage = 10 } = paginationDto || {}; + const skip = (page - 1) * perPage; + let orderBy; + + if (sortDto && sortDto.orderBy && sortDto.sortBy) { + orderBy = { + [sortDto.sortBy]: sortDto.orderBy.toLowerCase(), + }; + } + + try { + // Build the where clause for submissions based on available filter parameters + const submissionWhereClause: any = {}; + if (queryDto.type) { + submissionWhereClause.type = queryDto.type; + } + if (queryDto.url) { + submissionWhereClause.url = queryDto.url; + } + if (queryDto.challengeId) { + submissionWhereClause.challengeId = queryDto.challengeId; + } + if (queryDto.legacySubmissionId) { + submissionWhereClause.legacySubmissionId = queryDto.legacySubmissionId; + } + if (queryDto.legacyUploadId) { + submissionWhereClause.legacyUploadId = queryDto.legacyUploadId; + } + if (queryDto.submissionPhaseId) { + submissionWhereClause.submissionPhaseId = queryDto.submissionPhaseId; + } + + // find entities by filters + const submissions = await this.prisma.submission.findMany({ + where: { + ...submissionWhereClause, + }, + include: { + review: {}, + reviewSummation: {}, + }, + skip, + take: perPage, + orderBy, + }); + + // Count total entities matching the filter for pagination metadata + const totalCount = await this.prisma.submission.count({ + where: { + ...submissionWhereClause, + }, + }); + + this.logger.log( + `Found ${submissions.length} submissions (page ${page} of ${Math.ceil(totalCount / perPage)})`, + ); + + return { + data: submissions as SubmissionResponseDto[], + meta: { + page, + perPage, + totalCount, + totalPages: Math.ceil(totalCount / perPage), + }, + }; + } catch (error) { + const errorResponse = this.prismaErrorService.handleError( + error, + 'fetching submissions', + ); + throw new InternalServerErrorException({ + message: errorResponse.message, + code: errorResponse.code, + }); + } + } + + @Get('/:submissionId') + @Roles( + UserRole.Copilot, + UserRole.Admin, + UserRole.Submitter, + UserRole.Reviewer, + ) + @Scopes(Scope.ReadSubmission) + @ApiOperation({ + summary: 'View a specific submission', + description: + 'Roles: Copilot, Admin, Submitter, Reviewer | Scopes: read:submission', + }) + @ApiParam({ + name: 'submissionId', + description: 'The ID of the submission', + }) + @ApiResponse({ + status: 200, + description: 'Submission retrieved successfully.', + type: SubmissionResponseDto, + }) + @ApiResponse({ status: 404, description: 'Submission not found.' }) + async getSubmission( + @Param('submissionId') submissionId: string, + ): Promise { + this.logger.log(`Getting submission with ID: ${submissionId}`); + try { + const data = await this.prisma.submission.findUniqueOrThrow({ + where: { id: submissionId }, + include: { + review: {}, + reviewSummation: {}, + }, + }); + + this.logger.log(`Review type found: ${submissionId}`); + return data as SubmissionResponseDto; + } catch (error) { + throw this._rethrowError( + error, + submissionId, + `fetching submission ${submissionId}`, + ); + } + } + + @Delete('/:submissionId') + @Roles( + UserRole.Admin, + UserRole.Copilot, + UserRole.Submitter, + UserRole.Reviewer, + ) + @Scopes(Scope.DeleteSubmission) + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ + summary: 'Delete a submission', + description: + 'Roles: Admin, Copilot, Submitter, Reviewer | Scopes: delete:submission', + }) + @ApiParam({ + name: 'submissionId', + description: 'The ID of the submission', + }) + @ApiResponse({ + status: 204, + description: 'Submission deleted successfully.', + }) + @ApiResponse({ status: 403, description: 'Forbidden.' }) + @ApiResponse({ status: 404, description: 'Submission not found.' }) + async deleteSubmission(@Param('submissionId') submissionId: string) { + this.logger.log(`Deleting review type with ID: ${submissionId}`); + try { + await this.prisma.submission.delete({ + where: { id: submissionId }, + }); + this.logger.log(`Submission deleted successfully: ${submissionId}`); + return { message: `Submission ${submissionId} deleted successfully.` }; + } catch (error) { + throw this._rethrowError( + error, + submissionId, + `deleting submission ${submissionId}`, + ); + } + } + + @Get('/:submissionId/download') + @Roles( + UserRole.Copilot, + UserRole.Admin, + UserRole.Submitter, + UserRole.Reviewer, + ) + @Scopes(Scope.ReadSubmission) + @ApiOperation({ + summary: 'Download the submission', + description: + 'Roles: Copilot, Admin, Submitter Reviewer. | Scopes: read:submission', + }) + @ApiParam({ + name: 'submissionId', + description: 'The ID of the submission', + }) + @ApiResponse({ + status: 200, + description: 'Submission file', + schema: { + type: 'string', // Indicate binary data + format: 'binary', // Use binary format + }, + }) + async downloadSubmission( + @Param('submissionId') submissionId: string, // eslint-disable-line @typescript-eslint/no-unused-vars + ): Promise { + // The artifact file is from S3 in original codes + // Not data from DB + // So just return mock data now. + const file = createReadStream( + join(process.cwd(), 'uploads/submission-123.zip'), + ); + return Promise.resolve( + new StreamableFile(file, { + type: 'application/zip', + disposition: 'attachment; filename="submission-123.zip"', + }), + ); + } + + /** + * Build exception by error code + */ + _rethrowError(error: any, submissionId: string, message: string) { + const errorResponse = this.prismaErrorService.handleError(error, message); + + if (errorResponse.code === 'RECORD_NOT_FOUND') { + return new NotFoundException({ + message: `Review type with ID ${submissionId} was not found`, + code: errorResponse.code, + }); + } + + return new InternalServerErrorException({ + message: errorResponse.message, + code: errorResponse.code, + }); + } + + @Post('/:submissionId/artifacts') + @Roles( + UserRole.Admin, + UserRole.Copilot, + UserRole.Submitter, + UserRole.Reviewer, + ) + @Scopes(Scope.CreateSubmission) + @UseInterceptors(FileInterceptor('file')) + @ApiOperation({ + summary: 'Create artifact for the given submission ID', + description: + 'Roles: Admin, Copilot, Submitter, Reviewer | Scopes: create:submission', + }) + @ApiParam({ + name: 'submissionId', + description: 'The ID of the submission', + }) + @ApiConsumes('multipart/form-data') + @ApiBody({ + required: true, + type: 'multipart/form-data', + schema: { + type: 'object', + properties: { + file: { + type: 'string', + format: 'binary', + }, + }, + }, + }) + @ApiResponse({ + status: 201, + description: 'Submission created successfully.', + type: ArtifactsCreateResponseDto, + }) + async createArtifact( + @Param('submissionId') submissionId: string, + @UploadedFile() file: Express.Multer.File, + ): Promise { + const fileName = file.filename; + return Promise.resolve({ + artifacts: fileName.substring(fileName.lastIndexOf('/') + 1), + }); + } + + @Get('/:submissionId/artifacts') + @Roles( + UserRole.Copilot, + UserRole.Admin, + UserRole.Submitter, + UserRole.Reviewer, + ) + @Scopes(Scope.ReadSubmission) + @ApiOperation({ + summary: 'List artifacts for the given Submission ID', + description: + 'Roles: Copilot, Admin, Submitter, Reviewer. | Scopes: read:submission', + }) + @ApiParam({ + name: 'submissionId', + description: 'The ID of the submission', + }) + @ApiResponse({ + status: 200, + description: 'List of artifacts', + type: [ArtifactsListResponseDto], + }) + async listArtifacts( + @Param('submissionId') submissionId: string, // eslint-disable-line @typescript-eslint/no-unused-vars + ): Promise { + // These artifacts are from S3 in original codes + // Not data from DB + // So just return mock data now. + const mockData = { + artifacts: ['c56a4180-65aa-42ec-a945-5fd21dec0503'], + }; + return Promise.resolve(mockData); + } + + @Get('/:submissionId/artifacts/:artifactId/download') + @Roles( + UserRole.Copilot, + UserRole.Admin, + UserRole.Submitter, + UserRole.Reviewer, + ) + @Scopes(Scope.ReadSubmission) + @ApiOperation({ + summary: 'Download artifact using Submission ID and Artifact ID', + description: + 'Roles: Copilot, Admin, Submitter, Reviewer. | Scopes: read:submission', + }) + @ApiParam({ + name: 'submissionId', + description: 'The ID of the submission', + }) + @ApiParam({ + name: 'artifactId', + description: 'The ID of the artifact', + }) + @ApiResponse({ + status: 200, + description: 'Artifact file', + schema: { + type: 'string', // Indicate binary data + format: 'binary', // Use binary format + }, + }) + async downloadArtifacts( + @Param('submissionId') submissionId: string, // eslint-disable-line @typescript-eslint/no-unused-vars + @Param('artifactId') artifactId: string, // eslint-disable-line @typescript-eslint/no-unused-vars + ): Promise { + // The artifact file is from S3 in original codes + // Not data from DB + // So just return mock data now. + const file = createReadStream( + join(process.cwd(), 'uploads/artifact-123.zip'), + ); + return Promise.resolve( + new StreamableFile(file, { + type: 'application/zip', + disposition: 'attachment; filename="artifact-123.zip"', + }), + ); + } + + @Delete('/:submissionId/artifacts/:artifactId') + @Roles(UserRole.Admin) + @Scopes(Scope.DeleteSubmission) + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ + summary: 'Delete a artifact', + description: 'Roles: Admin | Scopes: delete:submission', + }) + @ApiParam({ + name: 'submissionId', + description: 'The ID of the submission', + }) + @ApiParam({ + name: 'artifactId', + description: 'The ID of the artifact', + }) + @ApiResponse({ + status: 204, + description: 'Submission deleted successfully.', + }) + @ApiResponse({ status: 403, description: 'Forbidden.' }) + @ApiResponse({ status: 404, description: 'Submission not found.' }) + // eslint-disable-next-line @typescript-eslint/require-await + async deleteArtifact( + @Param('submissionId') submissionId: string, // eslint-disable-line @typescript-eslint/no-unused-vars + @Param('artifactId') artifactId: string, // eslint-disable-line @typescript-eslint/no-unused-vars + ) { + return; + } + + @Get('/:challengeId/count') + @Roles( + UserRole.Copilot, + UserRole.Admin, + UserRole.Submitter, + UserRole.Reviewer, + ) + @Scopes(Scope.ReadSubmission) + @ApiOperation({ + summary: 'Get submission count for the given Challenge ID', + description: + 'Roles: Copilot, Admin, Submitter, Reviewer. | Scopes: read:submission', + }) + @ApiParam({ + name: 'challengeId', + description: 'The ID of the challenge', + }) + @ApiResponse({ + status: 200, + description: 'Count of submissions', + }) + async countSubmissions( + @Param('challengeId') challengeId: string, // eslint-disable-line @typescript-eslint/no-unused-vars + ): Promise { + // These artifacts are from S3 in original codes + // Not data from DB + // So just return mock data now. + return Promise.resolve(3); + } + + @Get('/download/:challengeId') + @Roles( + UserRole.Copilot, + UserRole.Admin, + UserRole.Submitter, + UserRole.Reviewer, + ) + @Scopes(Scope.ReadSubmission) + @ApiOperation({ + summary: 'Download all submissions for a challenge as a ZIP file', + description: + 'Roles: Copilot, Admin, Submitter, Reviewer. | Scopes: read:submission', + }) + @ApiParam({ + name: 'challengeId', + description: 'The ID of the challenge', + }) + @ApiResponse({ + status: 200, + description: 'Submission files', + schema: { + type: 'string', // Indicate binary data + format: 'binary', // Use binary format + }, + }) + async downloadAllSubmission( + @Param('challengeId') challengeId: string, // eslint-disable-line @typescript-eslint/no-unused-vars + ): Promise { + // The artifact file is from S3 in original codes + // Not data from DB + // So just return mock data now. + const file = createReadStream( + join(process.cwd(), 'uploads/submission-123.zip'), + ); + return Promise.resolve( + new StreamableFile(file, { + type: 'application/zip', + disposition: 'attachment; filename="submission-123.zip"', + }), + ); + } +} diff --git a/src/dto/artifacts.dto.ts b/src/dto/artifacts.dto.ts new file mode 100644 index 0000000..7f122b0 --- /dev/null +++ b/src/dto/artifacts.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class ArtifactsCreateResponseDto { + @ApiProperty({ + description: 'The ID of artifact', + example: 'c56a4180-65aa-42ec-a945-5fd21dec0501', + }) + artifacts: string; +} + +export class ArtifactsListResponseDto { + @ApiProperty({ + description: 'The ID of artifacts', + example: ['c56a4180-65aa-42ec-a945-5fd21dec0501'], + }) + artifacts: string[]; +} diff --git a/src/dto/pagination.dto.ts b/src/dto/pagination.dto.ts index 52edd0a..9add674 100644 --- a/src/dto/pagination.dto.ts +++ b/src/dto/pagination.dto.ts @@ -36,4 +36,4 @@ export interface PaginatedResponse { totalCount: number; totalPages: number; }; -} \ No newline at end of file +} diff --git a/src/dto/review.dto.ts b/src/dto/review.dto.ts index c669aad..96164fa 100644 --- a/src/dto/review.dto.ts +++ b/src/dto/review.dto.ts @@ -1,4 +1,13 @@ import { ApiProperty } from '@nestjs/swagger'; +import { + IsString, + IsNumber, + IsBoolean, + IsNotEmpty, + IsOptional, + IsDateString, + IsObject, +} from 'class-validator'; export enum ReviewItemCommentType { COMMENT = 'COMMENT', @@ -149,39 +158,79 @@ export class ReviewBaseDto { description: 'Resource ID associated with the review', example: 'resource123', }) + @IsString() + @IsNotEmpty() resourceId: string; @ApiProperty({ description: 'Phase ID of the challenge', example: 'phase456', }) + @IsString() + @IsNotEmpty() phaseId: string; @ApiProperty({ description: 'Submission ID being reviewed', example: 'submission789', }) + @IsString() + @IsNotEmpty() submissionId: string; @ApiProperty({ description: 'Scorecard ID used for the review', example: 'scorecard101', }) + @IsString() + @IsNotEmpty() scorecardId: string; @ApiProperty({ description: 'Final score of the review', example: 85.5 }) + @IsNumber() finalScore: number; @ApiProperty({ description: 'Initial score before finalization', example: 80.0, }) + @IsNumber() initialScore: number; + @ApiProperty({ + description: 'Type ID used for the review', + example: 'type101', + }) + @IsString() + @IsNotEmpty() + typeId: string; + + @ApiProperty({ + description: 'The metadata for the review', + }) + @IsOptional() + @IsObject() + metadata?: object; + + @ApiProperty({ + description: 'Status for the review', + example: 'Review', + }) + @IsString() + @IsNotEmpty() + status: string; + + @ApiProperty({ + description: 'Review date for the review', + example: '2023-10-01T00:00:00Z', + }) + @IsDateString() + reviewDate: Date; + @ApiProperty({ description: 'Is the review committed?', example: false }) + @IsOptional() + @IsBoolean() committed?: boolean; - - reviewItems?: any[]; } export class ReviewRequestDto extends ReviewBaseDto { @@ -196,6 +245,97 @@ export class ReviewRequestDto extends ReviewBaseDto { reviewItems?: ReviewItemRequestDto[]; } +export class ReviewPutRequestDto extends ReviewBaseDto {} + +export class ReviewPatchRequestDto { + @ApiProperty({ + description: 'Resource ID associated with the review', + example: 'resource123', + }) + @IsOptional() + @IsString() + @IsNotEmpty() + resourceId?: string; + + @ApiProperty({ + description: 'Phase ID of the challenge', + example: 'phase456', + }) + @IsOptional() + @IsString() + @IsNotEmpty() + phaseId?: string; + + @ApiProperty({ + description: 'Submission ID being reviewed', + example: 'submission789', + }) + @IsOptional() + @IsString() + @IsNotEmpty() + submissionId?: string; + + @ApiProperty({ + description: 'Scorecard ID used for the review', + example: 'scorecard101', + }) + @IsOptional() + @IsString() + @IsNotEmpty() + scorecardId?: string; + + @ApiProperty({ description: 'Final score of the review', example: 85.5 }) + @IsOptional() + @IsNumber() + finalScore?: number; + + @ApiProperty({ + description: 'Initial score before finalization', + example: 80.0, + }) + @IsOptional() + @IsNumber() + initialScore?: number; + + @ApiProperty({ + description: 'Type ID used for the review', + example: 'type101', + }) + @IsOptional() + @IsString() + @IsNotEmpty() + typeId?: string; + + @ApiProperty({ + description: 'Status for the review', + example: 'Review', + }) + @IsOptional() + @IsString() + @IsNotEmpty() + status?: string; + + @ApiProperty({ + description: 'Review date for the review', + example: '2023-10-01T00:00:00Z', + }) + @IsOptional() + @IsDateString() + reviewDate?: string; + + @ApiProperty({ + description: 'The metadata for the review', + }) + @IsOptional() + @IsObject() + metadata?: object; + + @ApiProperty({ description: 'Is the review committed?', example: false }) + @IsOptional() + @IsBoolean() + committed?: boolean; +} + export class ReviewResponseDto extends ReviewBaseDto { @ApiProperty({ description: 'The ID of the review', example: '123' }) id: string; @@ -232,21 +372,30 @@ export class ReviewResponseDto extends ReviewBaseDto { updatedBy: string; } -export function mapReviewRequestToDto(request: ReviewRequestDto) { +export function mapReviewRequestToDto( + request: ReviewRequestDto | ReviewPatchRequestDto, +) { const userFields = { createdBy: '', updatedBy: '', }; - return { - ...request, - ...userFields, - reviewItems: { - create: request.reviewItems?.map((item) => - mapReviewItemRequestToDto(item), - ), - }, - }; + if (request instanceof ReviewRequestDto) { + return { + ...request, + ...userFields, + reviewItems: { + create: request.reviewItems?.map((item) => + mapReviewItemRequestToDto(item), + ), + }, + }; + } else { + return { + ...request, + ...userFields, + }; + } } export function mapReviewItemRequestToDto(request: ReviewItemRequestDto) { diff --git a/src/dto/reviewSummation.dto.ts b/src/dto/reviewSummation.dto.ts new file mode 100644 index 0000000..e07e246 --- /dev/null +++ b/src/dto/reviewSummation.dto.ts @@ -0,0 +1,256 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsString, + IsNumber, + IsNumberString, + IsBoolean, + IsNotEmpty, + IsOptional, + IsBooleanString, + IsDateString, +} from 'class-validator'; + +export class ReviewSummationQueryDto { + @ApiProperty({ + description: 'The submission id', + required: false, + }) + @IsOptional() + @IsString() + @IsNotEmpty() + submissionId?: string; + + @ApiProperty({ + description: 'The aggregate score', + required: false, + }) + @IsOptional() + @IsNumberString() + aggregateScore?: string; + + @ApiProperty({ + description: 'The scorecard id', + required: false, + }) + @IsOptional() + @IsString() + @IsNotEmpty() + scorecardId?: string; + + @ApiProperty({ + description: 'The isPassing flag for review summation', + required: false, + }) + @IsOptional() + @IsBooleanString() + isPassing?: string; + + @ApiProperty({ + description: 'The isFinal flag for review summation', + required: false, + }) + @IsOptional() + @IsBooleanString() + isFinal?: string; +} + +export class ReviewSummationBaseRequestDto { + @ApiProperty({ + description: 'The submission id', + example: 'd24d4180-65aa-42ec-a945-5fd21dec0501', + }) + @IsString() + @IsNotEmpty() + submissionId: string; + + @ApiProperty({ + description: 'The aggregate score', + example: 97.8, + }) + @IsNumber() + aggregateScore: number; + + @ApiProperty({ + description: 'The scorecard id', + example: 'd24d4180-65aa-42ec-a945-5fd21dec0501', + }) + @IsString() + @IsNotEmpty() + scorecardId: string; + + @ApiProperty({ + description: 'The isPassing flag for review summation', + }) + @IsBoolean() + isPassing: boolean; + + @ApiProperty({ + description: 'The isFinal flag for review summation', + }) + @IsBoolean() + isFinal: boolean; + + @ApiProperty({ + description: 'The reviewed date', + example: '2024-10-01T00:00:00Z', + }) + @IsDateString() + reviewedDate: string; +} + +export class ReviewSummationRequestDto extends ReviewSummationBaseRequestDto { + @ApiProperty({ + description: 'The user who created the review summation', + example: 'user123', + }) + @IsString() + @IsNotEmpty() + createdBy: string; + + @ApiProperty({ + description: 'The user who last updated the review summation', + example: 'user456', + }) + @IsString() + @IsNotEmpty() + updatedBy: string; +} + +export class ReviewSummationPutRequestDto extends ReviewSummationBaseRequestDto { + @ApiProperty({ + description: 'The user who last updated the review summation', + example: 'user456', + }) + @IsString() + @IsNotEmpty() + updatedBy: string; +} + +export class ReviewSummationUpdateRequestDto { + @ApiProperty({ + description: 'The submission id', + example: 'd24d4180-65aa-42ec-a945-5fd21dec0501', + required: false, + }) + @IsOptional() + @IsString() + @IsNotEmpty() + submissionId?: string; + + @ApiProperty({ + description: 'The aggregate score', + example: 97.8, + required: false, + }) + @IsOptional() + @IsNumber() + aggregateScore?: number; + + @ApiProperty({ + description: 'The scorecard id', + example: 'd24d4180-65aa-42ec-a945-5fd21dec0501', + required: false, + }) + @IsOptional() + @IsString() + @IsNotEmpty() + scorecardId?: string; + + @ApiProperty({ + description: 'The isPassing flag for review summation', + required: false, + }) + @IsOptional() + @IsBoolean() + isPassing?: boolean; + + @ApiProperty({ + description: 'The isFinal flag for review summation', + required: false, + }) + @IsOptional() + @IsBoolean() + isFinal?: boolean; + + @ApiProperty({ + description: 'The reviewed date', + required: false, + }) + @IsOptional() + @IsDateString() + reviewedDate?: string; + + @ApiProperty({ + description: 'The user who last updated the submission', + example: 'user456', + }) + @IsString() + @IsNotEmpty() + updatedBy: string; +} + +export class ReviewSummationResponseDto { + @ApiProperty({ + description: 'The ID of the review summation', + example: 'c56a4180-65aa-42ec-a945-5fd21dec0501', + }) + id: string; + + @ApiProperty({ + description: 'The submission id', + example: 'id123', + }) + submissionId: string; + + @ApiProperty({ + description: 'The aggregate score', + example: 97.8, + }) + aggregateScore: number; + + @ApiProperty({ + description: 'The scorecard id', + example: 'd24d4180-65aa-42ec-a945-5fd21dec0501', + }) + scorecardId: string; + + @ApiProperty({ + description: 'The isPassing flag for review summation', + }) + isPassing: boolean; + + @ApiProperty({ + description: 'The isFinal flag for review summation', + }) + isFinal: boolean; + + @ApiProperty({ + description: 'The reviewed date', + example: '2024-10-01T00:00:00Z', + }) + reviewedDate: Date; + + @ApiProperty({ + description: 'The creation timestamp', + example: '2023-10-01T00:00:00Z', + }) + createdAt: Date; + + @ApiProperty({ + description: 'The user who created the review summation', + example: 'user123', + }) + createdBy: string; + + @ApiProperty({ + description: 'The last update timestamp', + example: '2023-10-01T00:00:00Z', + }) + updatedAt: Date; + + @ApiProperty({ + description: 'The user who last updated the review summation', + example: 'user456', + }) + updatedBy: string; +} diff --git a/src/dto/reviewType.dto.ts b/src/dto/reviewType.dto.ts new file mode 100644 index 0000000..c56796c --- /dev/null +++ b/src/dto/reviewType.dto.ts @@ -0,0 +1,83 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsString, + IsBoolean, + IsNotEmpty, + IsOptional, + IsBooleanString, +} from 'class-validator'; + +export class ReviewTypeQueryDto { + @ApiProperty({ + name: 'name', + description: 'The review type name to filter by', + required: false, + }) + @IsOptional() + @IsString() + @IsNotEmpty() + name?: string; + + @ApiProperty({ + name: 'isActive', + description: 'The review type active flag to filter by', + required: false, + }) + @IsOptional() + @IsBooleanString() + isActive?: string; +} + +export class ReviewTypeRequestDto { + @ApiProperty({ + description: 'The review type name', + }) + @IsString() + @IsNotEmpty() + name: string; + + @ApiProperty({ + description: 'The active flag for review type', + }) + @IsBoolean() + isActive: boolean; +} + +export class ReviewTypeUpdateRequestDto { + @ApiProperty({ + description: 'The review type name', + required: false, + }) + @IsOptional() + @IsString() + @IsNotEmpty() + name?: string; + + @ApiProperty({ + description: 'The active flag for review type', + required: false, + }) + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class ReviewTypeResponseDto { + @ApiProperty({ + description: 'The ID of the review type', + example: 'c56a4180-65aa-42ec-a945-5fd21dec0501', + }) + id: string; + + @ApiProperty({ + description: 'The review type name', + example: 'Screening', + }) + name: string; + + @ApiProperty({ + description: 'The active flag for review type', + example: 'true', + }) + isActive: boolean; +} diff --git a/src/dto/sort.dto.ts b/src/dto/sort.dto.ts new file mode 100644 index 0000000..e2d202a --- /dev/null +++ b/src/dto/sort.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsIn, IsNotEmpty, IsOptional } from 'class-validator'; + +const ORDER_STRINGS = ['asc', 'desc', 'ASC', 'DESC']; + +export class SortDto { + @ApiProperty({ + description: 'orderBy parameter', + required: false, + type: String, + }) + @IsOptional() + @IsIn(ORDER_STRINGS) + orderBy?: 'asc' | 'desc' | 'ASC' | 'DESC'; + + @ApiProperty({ + description: 'sortBy parameter', + required: false, + type: String, + }) + @IsOptional() + @IsNotEmpty() + sortBy?: string; +} diff --git a/src/dto/submission.dto.ts b/src/dto/submission.dto.ts new file mode 100644 index 0000000..df7f0c4 --- /dev/null +++ b/src/dto/submission.dto.ts @@ -0,0 +1,319 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsString, + IsNotEmpty, + IsOptional, + IsDateString, +} from 'class-validator'; + +import { ReviewResponseDto } from './review.dto'; + +export class SubmissionQueryDto { + @ApiProperty({ + name: 'type', + description: 'The submission type to filter by', + required: false, + }) + @IsOptional() + @IsString() + @IsNotEmpty() + type?: string; + + @ApiProperty({ + name: 'url', + description: 'The submission file url to filter by', + required: false, + }) + @IsOptional() + @IsString() + @IsNotEmpty() + url?: string; + + @ApiProperty({ + name: 'challengeId', + description: 'The challenge id to filter by', + required: false, + }) + @IsOptional() + @IsString() + @IsNotEmpty() + challengeId?: string; + + @ApiProperty({ + name: 'legacySubmissionId', + description: 'The legacy submission id to filter by', + required: false, + }) + @IsOptional() + @IsString() + @IsNotEmpty() + legacySubmissionId?: string; + + @ApiProperty({ + name: 'legacyUploadId', + description: 'The legacy upload id to filter by', + required: false, + }) + @IsOptional() + @IsString() + @IsNotEmpty() + legacyUploadId?: string; + + @ApiProperty({ + name: 'submissionPhaseId', + description: 'The submission phase id to filter by', + required: false, + }) + @IsOptional() + @IsString() + @IsNotEmpty() + submissionPhaseId?: string; +} + +export class SubmissionRequestBaseDto { + @ApiProperty({ + description: 'The submission type', + example: 'ContestSubmission', + }) + @IsString() + @IsNotEmpty() + type: string; + + @ApiProperty({ + description: 'The submission url', + }) + @IsString() + @IsNotEmpty() + url: string; + + @ApiProperty({ + description: 'The member id', + }) + @IsString() + @IsNotEmpty() + memberId: string; + + @ApiProperty({ + description: 'The challenge id', + }) + @IsString() + @IsNotEmpty() + challengeId: string; + + @ApiProperty({ + description: 'The legacy submission id', + }) + @IsOptional() + @IsString() + @IsNotEmpty() + legacySubmissionId?: string; + + @ApiProperty({ + description: 'The legacy upload id', + }) + @IsOptional() + @IsString() + @IsNotEmpty() + legacyUploadId?: string; + + @ApiProperty({ + description: 'The submission phase id', + }) + @IsOptional() + @IsString() + @IsNotEmpty() + submissionPhaseId?: string; + + @ApiProperty({ + description: 'The submitted date', + example: '2024-10-01T00:00:00Z', + }) + @IsDateString() + submittedDate: string; +} + +export class SubmissionRequestDto extends SubmissionRequestBaseDto { + @ApiProperty({ + description: 'The user who created the submission', + example: 'user123', + }) + @IsString() + @IsNotEmpty() + createdBy: string; + + @ApiProperty({ + description: 'The user who last updated the submission', + example: 'user456', + }) + @IsString() + @IsNotEmpty() + updatedBy: string; +} + +export class SubmissionPutRequestDto extends SubmissionRequestBaseDto { + @ApiProperty({ + description: 'The user who last updated the submission', + example: 'user456', + }) + @IsString() + @IsNotEmpty() + updatedBy: string; +} + +export class SubmissionUpdateRequestDto { + @ApiProperty({ + description: 'The submission type', + example: 'ContestSubmission', + required: false, + }) + @IsOptional() + @IsString() + @IsNotEmpty() + type?: string; + + @ApiProperty({ + description: 'The submission url', + required: false, + }) + @IsOptional() + @IsString() + @IsNotEmpty() + url?: string; + + @ApiProperty({ + description: 'The member id', + required: false, + }) + @IsOptional() + @IsString() + @IsNotEmpty() + memberId?: string; + + @ApiProperty({ + description: 'The challenge id', + required: false, + }) + @IsOptional() + @IsString() + @IsNotEmpty() + challengeId?: string; + + @ApiProperty({ + description: 'The legacy submission id', + required: false, + }) + @IsOptional() + @IsString() + @IsNotEmpty() + legacySubmissionId?: string; + + @ApiProperty({ + description: 'The legacy upload id', + required: false, + }) + @IsOptional() + @IsString() + @IsNotEmpty() + legacyUploadId?: string; + + @ApiProperty({ + description: 'The submission phase id', + required: false, + }) + @IsOptional() + @IsString() + @IsNotEmpty() + submissionPhaseId?: string; + + @ApiProperty({ + description: 'The submitted date', + required: false, + }) + @IsOptional() + @IsDateString() + submittedDate?: string; + + @ApiProperty({ + description: 'The user who last updated the submission', + example: 'user456', + }) + @IsString() + @IsNotEmpty() + updatedBy: string; +} + +export class SubmissionResponseDto { + @ApiProperty({ + description: 'The ID of the submission', + example: 'c56a4180-65aa-42ec-a945-5fd21dec0501', + }) + id: string; + + @ApiProperty({ + description: 'The submission type', + example: 'ContestSubmission', + }) + type: string; + + @ApiProperty({ + description: 'The submission url', + }) + url: string; + + @ApiProperty({ + description: 'The member id', + }) + memberId: string; + + @ApiProperty({ + description: 'The challenge id', + }) + challengeId: string; + + @ApiProperty({ + description: 'The legacy submission id', + }) + legacySubmissionId?: string; + + @ApiProperty({ + description: 'The legacy upload id', + }) + legacyUploadId?: string; + + @ApiProperty({ + description: 'The submission phase id', + }) + submissionPhaseId?: string; + + @ApiProperty({ + description: 'The submitted date', + }) + submittedDate: Date; + + @ApiProperty({ + description: 'The creation timestamp', + example: '2023-10-01T00:00:00Z', + }) + createdAt: Date; + + @ApiProperty({ + description: 'The user who created the submission', + example: 'user123', + }) + createdBy: string; + + @ApiProperty({ + description: 'The last update timestamp', + example: '2023-10-01T00:00:00Z', + }) + updatedAt: Date; + + @ApiProperty({ + description: 'The user who last updated the submission', + example: 'user456', + }) + updatedBy: string; + + review?: ReviewResponseDto[]; + reviewSummation?: any[]; +} diff --git a/src/main.ts b/src/main.ts index c9aae9d..0b986a3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,6 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; -import { ValidationPipe, Logger } from '@nestjs/common'; +import { ValidationPipe } from '@nestjs/common'; import * as cors from 'cors'; import { NestExpressApplication } from '@nestjs/platform-express'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; @@ -22,6 +22,12 @@ async function bootstrap() { logger.log('Setting global prefix to /v5/review in production mode'); } + console.log( + '===============process.env.NODE_EN============', + process.env.NODE_ENV, + ); + logger.log('=========================' + process.env.NODE_ENV); + // CORS related settings const corsConfig: cors.CorsOptions = { allowedHeaders: @@ -40,7 +46,7 @@ async function bootstrap() { const requestLogger = LoggerService.forRoot('HttpRequest'); const startTime = Date.now(); const { method, originalUrl, ip, headers } = req; - + // Log request requestLogger.log({ type: 'request', @@ -49,13 +55,13 @@ async function bootstrap() { ip, userAgent: headers['user-agent'], }); - + // Intercept response to log it const originalSend = res.send; - res.send = function(body) { + res.send = function (body) { const responseTime = Date.now() - startTime; const statusCode = res.statusCode; - + // Log response requestLogger.log({ type: 'response', @@ -64,16 +70,17 @@ async function bootstrap() { url: originalUrl, responseTime: `${responseTime}ms`, }); - + // If there's a 500+ error, log it as an error if (statusCode >= 500) { let responseBody; try { responseBody = typeof body === 'string' ? JSON.parse(body) : body; + // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (error) { responseBody = body; } - + requestLogger.error({ message: 'Server error response', statusCode, @@ -81,10 +88,10 @@ async function bootstrap() { body: responseBody, }); } - - return originalSend.call(this, body); + + return originalSend.call(this, body); // eslint-disable-line @typescript-eslint/no-unsafe-return }; - + next(); }); @@ -100,23 +107,24 @@ async function bootstrap() { // TODO: finish this and make it so this block only runs in non-prod const config = new DocumentBuilder() .setTitle('Topcoder Review API') - .setDescription(` + .setDescription( + ` Topcoder Review API Documentation - + Authentication - + The API supports two authentication methods: - + User Token (JWT) - Regular user authentication using role-based access control - Tokens should include 'roles' claim with the appropriate role(s) - Available roles: Admin, Copilot, Reviewer, Submitter - + Machine-to-Machine (M2M) Token - For service-to-service authentication - Uses scope-based access control - - Available scopes: create:appeal, read:appeal, update:appeal, delete:appeal, create:appeal-response, update:appeal-response, all:appeal, create:contact-request, all:contact-request, read:project-result, all:project-result, create:review, read:review, update:review, delete:review, create:review-item, update:review-item, delete:review-item, all:review, create:scorecard, read:scorecard, update:scorecard, delete:scorecard, all:scorecard - + - Available scopes: create:appeal, read:appeal, update:appeal, delete:appeal, create:appeal-response, update:appeal-response, all:appeal, create:contact-request, all:contact-request, read:project-result, all:project-result, create:review, read:review, update:review, delete:review, create:review-item, update:review-item, delete:review-item, all:review, create:scorecard, read:scorecard, update:scorecard, delete:scorecard, all:scorecard create:review_type read:review_type update:review_type delete:review_type all:review_type create:review_summation read:review_summation update:review_summation delete:review_summation all:review_summation create:submission read:submission update:submission delete:submission all:submission + To get an M2M token (example for development environment): curl --request POST \\ @@ -124,7 +132,8 @@ async function bootstrap() { --header 'content-type: application/json' \\ --data '{"client_id":"your-client-id","client_secret":"your-client-secret","audience":"https://m2m.topcoder-dev.com/","grant_type":"client_credentials"}' - `) + `, + ) .setVersion('1.0') .addTag('TC Review') .addBearerAuth({ @@ -139,20 +148,25 @@ async function bootstrap() { const document = SwaggerModule.createDocument(app, config, { include: [ApiModule], }); - SwaggerModule.setup('/v5/review/api-docs', app, document); + if (process.env.NODE_ENV === 'production') { + SwaggerModule.setup('/v5/review/api-docs', app, document); + } else { + SwaggerModule.setup('/api-docs', app, document); + } logger.log('Swagger documentation configured'); // Add an event handler to log uncaught promise rejections and prevent the server from crashing process.on('unhandledRejection', (reason, promise) => { - logger.error(`Unhandled Promise Rejection at: ${promise}`, reason as string); + logger.error( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Unhandled Promise Rejection at: ${promise}`, // eslint-disable-line @typescript-eslint/no-base-to-string + reason as string, + ); }); // Add an event handler to log uncaught errors and prevent the server from crashing process.on('uncaughtException', (error: Error) => { - logger.error( - `Uncaught Exception: ${error.message}`, - error.stack, - ); + logger.error(`Uncaught Exception: ${error.message}`, error.stack); }); // Listen on port diff --git a/src/shared/config/auth.config.ts b/src/shared/config/auth.config.ts index ec64566..ba6c2af 100644 --- a/src/shared/config/auth.config.ts +++ b/src/shared/config/auth.config.ts @@ -6,14 +6,14 @@ export const AuthConfig = { jwt: { // The Auth0 issuer used to validate tokens issuer: process.env.AUTH0_ISSUER || 'https://topcoder-dev.auth0.com/', - + // The audience(s) that are valid for the token audience: process.env.TOKEN_AUDIENCE || 'https://m2m.topcoder-dev.com/', - + // Clock tolerance for token expiration time (in seconds) clockTolerance: 30, - + // Whether to enforce token expiration ignoreExpiration: process.env.NODE_ENV !== 'production', }, -}; \ No newline at end of file +}; diff --git a/src/shared/decorators/scopes.decorator.ts b/src/shared/decorators/scopes.decorator.ts index f9d2511..1c8343b 100644 --- a/src/shared/decorators/scopes.decorator.ts +++ b/src/shared/decorators/scopes.decorator.ts @@ -7,4 +7,4 @@ export const SCOPES_KEY = 'scopes'; * Decorator to define required scopes for an endpoint * @param scopes List of required scopes */ -export const Scopes = (...scopes: Scope[]) => SetMetadata(SCOPES_KEY, scopes); \ No newline at end of file +export const Scopes = (...scopes: Scope[]) => SetMetadata(SCOPES_KEY, scopes); diff --git a/src/shared/enums/scopes.enum.ts b/src/shared/enums/scopes.enum.ts index 2694c01..f45f0fd 100644 --- a/src/shared/enums/scopes.enum.ts +++ b/src/shared/enums/scopes.enum.ts @@ -35,6 +35,27 @@ export enum Scope { UpdateScorecard = 'update:scorecard', DeleteScorecard = 'delete:scorecard', AllScorecard = 'all:scorecard', + + // Review type scopes + CreateReviewType = 'create:review_type', + ReadReviewType = 'read:review_type', + UpdateReviewType = 'update:review_type', + DeleteReviewType = 'delete:review_type', + AllReviewType = 'all:review_type', + + // Review summation scopes + CreateReviewSummation = 'create:review_summation', + ReadReviewSummation = 'read:review_summation', + UpdateReviewSummation = 'update:review_summation', + DeleteReviewSummation = 'delete:review_summation', + AllReviewSummation = 'all:review_summation', + + // Submission scopes + CreateSubmission = 'create:submission', + ReadSubmission = 'read:submission', + UpdateSubmission = 'update:submission', + DeleteSubmission = 'delete:submission', + AllSubmission = 'all:submission', } /** @@ -49,12 +70,8 @@ export const ALL_SCOPE_MAPPINGS: Record = { Scope.CreateAppealResponse, Scope.UpdateAppealResponse, ], - [Scope.AllContactRequest]: [ - Scope.CreateContactRequest, - ], - [Scope.AllProjectResult]: [ - Scope.ReadProjectResult, - ], + [Scope.AllContactRequest]: [Scope.CreateContactRequest], + [Scope.AllProjectResult]: [Scope.ReadProjectResult], [Scope.AllReview]: [ Scope.CreateReview, Scope.ReadReview, @@ -70,4 +87,22 @@ export const ALL_SCOPE_MAPPINGS: Record = { Scope.UpdateScorecard, Scope.DeleteScorecard, ], -}; \ No newline at end of file + [Scope.AllReviewType]: [ + Scope.CreateReviewType, + Scope.ReadReviewType, + Scope.UpdateReviewType, + Scope.DeleteReviewType, + ], + [Scope.AllReviewSummation]: [ + Scope.CreateReviewSummation, + Scope.ReadReviewSummation, + Scope.UpdateReviewSummation, + Scope.DeleteReviewSummation, + ], + [Scope.AllSubmission]: [ + Scope.CreateSubmission, + Scope.ReadSubmission, + Scope.UpdateSubmission, + Scope.DeleteSubmission, + ], +}; diff --git a/src/shared/enums/userRole.enum.ts b/src/shared/enums/userRole.enum.ts index 44c2c2f..b5caa56 100644 --- a/src/shared/enums/userRole.enum.ts +++ b/src/shared/enums/userRole.enum.ts @@ -6,4 +6,4 @@ export enum UserRole { Copilot = 'Copilot', Reviewer = 'Reviewer', Submitter = 'Submitter', -} \ No newline at end of file +} diff --git a/src/shared/guards/tokenRoles.guard.ts b/src/shared/guards/tokenRoles.guard.ts index a2e5278..8ce26bc 100644 --- a/src/shared/guards/tokenRoles.guard.ts +++ b/src/shared/guards/tokenRoles.guard.ts @@ -23,16 +23,12 @@ export class TokenRolesGuard implements CanActivate { async canActivate(context: ExecutionContext): Promise { // Get required roles and scopes from decorators - const requiredRoles = this.reflector.get( - ROLES_KEY, - context.getHandler(), - ) || []; - - const requiredScopes = this.reflector.get( - SCOPES_KEY, - context.getHandler(), - ) || []; - + const requiredRoles = + this.reflector.get(ROLES_KEY, context.getHandler()) || []; + + const requiredScopes = + this.reflector.get(SCOPES_KEY, context.getHandler()) || []; + // If no roles or scopes are required, allow access if (requiredRoles.length === 0 && requiredScopes.length === 0) { return true; @@ -47,41 +43,49 @@ export class TokenRolesGuard implements CanActivate { 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; - + // Check role-based access for regular users if (user.roles && requiredRoles.length > 0) { - const hasRole = requiredRoles.some((role) => - user.roles ? user.roles.includes(role) : false + const hasRole = requiredRoles.some((role) => + user.roles ? user.roles.includes(role) : false, ); if (hasRole) { return true; } } - + // Check scope-based access for M2M tokens if (user.scopes && requiredScopes.length > 0) { - const hasScope = requiredScopes.some((scope) => - user.scopes ? user.scopes.includes(scope) : false + const hasScope = requiredScopes.some((scope) => + user.scopes ? user.scopes.includes(scope) : false, ); if (hasScope) { return true; } } - + // If M2M token has scopes but no required scopes, and the endpoint requires // only roles but no scopes, deny access (M2M tokens should only access endpoints // that explicitly define scope requirements) - if (user.scopes && !user.roles && requiredRoles.length > 0 && requiredScopes.length === 0) { + if ( + user.scopes && + !user.roles && + requiredRoles.length > 0 && + requiredScopes.length === 0 + ) { throw new ForbiddenException('M2M token not allowed for this endpoint'); } - + // Access denied - neither roles nor scopes match throw new ForbiddenException('Insufficient permissions'); } catch (error) { - if (error instanceof UnauthorizedException || error instanceof ForbiddenException) { + if ( + error instanceof UnauthorizedException || + error instanceof ForbiddenException + ) { throw error; } throw new UnauthorizedException('Invalid token'); diff --git a/src/shared/modules/global/file-upload.module.ts b/src/shared/modules/global/file-upload.module.ts new file mode 100644 index 0000000..9e3dd54 --- /dev/null +++ b/src/shared/modules/global/file-upload.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; +import { MulterModule } from '@nestjs/platform-express'; +import { diskStorage } from 'multer'; +import { extname } from 'path'; + +@Module({ + imports: [ + MulterModule.register({ + storage: diskStorage({ + destination: './uploads', + filename: (req, file, cb) => { + const uniqueSuffix = Date.now(); + const ext = extname(file.originalname); + const filename = `${file.originalname.replace(ext, '')}-${uniqueSuffix}${ext}`; + cb(null, filename); + }, + }), + }), + ], + controllers: [], + providers: [], + exports: [MulterModule], +}) +export class FileUploadModule {} diff --git a/src/shared/modules/global/jwt.service.ts b/src/shared/modules/global/jwt.service.ts index bfc0c51..c31981c 100644 --- a/src/shared/modules/global/jwt.service.ts +++ b/src/shared/modules/global/jwt.service.ts @@ -1,4 +1,8 @@ -import { Injectable, OnModuleInit, UnauthorizedException } from '@nestjs/common'; +import { + Injectable, + OnModuleInit, + UnauthorizedException, +} from '@nestjs/common'; import { decode, verify, VerifyOptions, Secret } from 'jsonwebtoken'; import * as jwksClient from 'jwks-rsa'; import { ALL_SCOPE_MAPPINGS, Scope } from '../../enums/scopes.enum'; @@ -26,23 +30,13 @@ const TEST_M2M_TOKENS: Record = { Scope.AllContactRequest, Scope.AllProjectResult, Scope.AllReview, - Scope.AllScorecard - ], - 'm2m-token-review': [ - Scope.AllReview - ], - 'm2m-token-scorecard': [ - Scope.AllScorecard - ], - 'm2m-token-appeal': [ - Scope.AllAppeal - ], - 'm2m-token-contact-request': [ - Scope.AllContactRequest - ], - 'm2m-token-project-result': [ - Scope.AllProjectResult + Scope.AllScorecard, ], + 'm2m-token-review': [Scope.AllReview], + 'm2m-token-scorecard': [Scope.AllScorecard], + 'm2m-token-appeal': [Scope.AllAppeal], + 'm2m-token-contact-request': [Scope.AllContactRequest], + 'm2m-token-project-result': [Scope.AllProjectResult], }; @Injectable() @@ -80,20 +74,20 @@ export class JwtService implements OnModuleInit { } let decodedToken: any; - + // In production, we verify the token if (process.env.NODE_ENV === 'production') { try { // First decode the token to get the kid (Key ID) const tokenHeader = decode(token, { complete: true })?.header; - + if (!tokenHeader || !tokenHeader.kid) { throw new UnauthorizedException('Invalid token: Missing key ID'); } - + // Get the signing key from Auth0 const signingKey = await this.getSigningKey(tokenHeader.kid); - + // Verify options const verifyOptions: VerifyOptions = { issuer: AuthConfig.jwt.issuer, @@ -101,10 +95,9 @@ export class JwtService implements OnModuleInit { clockTolerance: AuthConfig.jwt.clockTolerance, ignoreExpiration: AuthConfig.jwt.ignoreExpiration, }; - + // Verify the token decodedToken = verify(token, signingKey, verifyOptions); - } catch (error) { console.error('JWT verification failed:', error); throw new UnauthorizedException('Invalid token'); @@ -113,7 +106,7 @@ export class JwtService implements OnModuleInit { // In development, just decode the token without verification decodedToken = decode(token); } - + if (!decodedToken) { throw new UnauthorizedException('Invalid token'); } @@ -150,16 +143,24 @@ export class JwtService implements OnModuleInit { this.jwksClientInstance.getSigningKey(kid, (err, key) => { if (err || !key) { console.error('Error getting signing key:', err); - return reject(new UnauthorizedException('Invalid token: Unable to get signing key')); + return reject( + new UnauthorizedException( + 'Invalid token: Unable to get signing key', + ), + ); } - + // Get the public key using the proper method const signingKey = key.getPublicKey(); - + if (!signingKey) { - return reject(new UnauthorizedException('Invalid token: Unable to get public key')); + return reject( + new UnauthorizedException( + 'Invalid token: Unable to get public key', + ), + ); } - + resolve(signingKey); }); }); @@ -172,17 +173,17 @@ export class JwtService implements OnModuleInit { */ private expandScopes(scopes: string[]): string[] { const expandedScopes = new Set(); - + // Add all original scopes - scopes.forEach(scope => expandedScopes.add(scope)); - + scopes.forEach((scope) => expandedScopes.add(scope)); + // Expand all "all:*" scopes - scopes.forEach(scope => { + scopes.forEach((scope) => { if (ALL_SCOPE_MAPPINGS[scope]) { - ALL_SCOPE_MAPPINGS[scope].forEach(s => expandedScopes.add(s)); + ALL_SCOPE_MAPPINGS[scope].forEach((s) => expandedScopes.add(s)); } }); - + return Array.from(expandedScopes); } -} \ No newline at end of file +} diff --git a/src/shared/modules/global/logger.service.ts b/src/shared/modules/global/logger.service.ts index 27f7bd8..697e67f 100644 --- a/src/shared/modules/global/logger.service.ts +++ b/src/shared/modules/global/logger.service.ts @@ -1,9 +1,13 @@ -import { Injectable, LoggerService as NestLoggerService, LogLevel } from '@nestjs/common'; +import { + Injectable, + LoggerService as NestLoggerService, + LogLevel, +} from '@nestjs/common'; @Injectable() export class LoggerService implements NestLoggerService { private context?: string; - + constructor(context?: string) { this.context = context; } @@ -43,10 +47,11 @@ export class LoggerService implements NestLoggerService { private printMessage(level: LogLevel, message: any, context?: string) { const timestamp = new Date().toISOString(); let logMessage: string; - + if (typeof message === 'object') { try { logMessage = JSON.stringify(message); + // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (e) { logMessage = String(message); } @@ -55,7 +60,7 @@ export class LoggerService implements NestLoggerService { } const formattedMessage = `[${timestamp}] [${level.toUpperCase()}] ${context ? `[${context}] ` : ''}${logMessage}`; - + switch (level) { case 'error': console.error(formattedMessage); @@ -73,4 +78,4 @@ export class LoggerService implements NestLoggerService { console.log(formattedMessage); } } -} \ No newline at end of file +} diff --git a/src/shared/modules/global/prisma-error.service.ts b/src/shared/modules/global/prisma-error.service.ts index d1dcc88..065fe9b 100644 --- a/src/shared/modules/global/prisma-error.service.ts +++ b/src/shared/modules/global/prisma-error.service.ts @@ -19,39 +19,42 @@ export class PrismaErrorService { handleError(error: any, context?: string): { message: string; code: string } { // Log the original error for debugging this.logger.error( - `Prisma error ${context ? `while ${context}` : ''}: ${error.message}`, - error.stack + `Prisma error ${context ? `while ${context}` : ''}: ${error.message}`, + error.stack, ); // Handle known Prisma errors if (error instanceof Prisma.PrismaClientKnownRequestError) { return this.handleKnownRequestError(error); - } - + } + if (error instanceof Prisma.PrismaClientUnknownRequestError) { return { - message: 'An unexpected database error occurred. Please try again later.', + message: + 'An unexpected database error occurred. Please try again later.', code: 'DATABASE_ERROR', }; } - + if (error instanceof Prisma.PrismaClientRustPanicError) { return { message: 'A critical database error occurred. Please try again later.', code: 'CRITICAL_DATABASE_ERROR', }; } - + if (error instanceof Prisma.PrismaClientInitializationError) { return { - message: 'The application failed to connect to the database. Please try again later.', + message: + 'The application failed to connect to the database. Please try again later.', code: 'DATABASE_CONNECTION_ERROR', }; } - + if (error instanceof Prisma.PrismaClientValidationError) { return { - message: 'The request contains invalid data that could not be processed.', + message: + 'The request contains invalid data that could not be processed.', code: 'VALIDATION_ERROR', }; } @@ -69,13 +72,13 @@ export class PrismaErrorService { * @returns User-friendly error object with message and code */ private handleKnownRequestError( - error: Prisma.PrismaClientKnownRequestError + error: Prisma.PrismaClientKnownRequestError, ): { message: string; code: string } { // Extract target field information if available - const targetField = error.meta?.target ? - Array.isArray(error.meta.target) ? - error.meta.target.join(', ') : - String(error.meta.target) + const targetField = error.meta?.target + ? Array.isArray(error.meta.target) + ? error.meta.target.join(', ') + : String(error.meta.target) // eslint-disable-line @typescript-eslint/no-base-to-string : null; switch (error.code) { @@ -94,8 +97,8 @@ export class PrismaErrorService { // Unique constraint violations case 'P2002': return { - message: targetField - ? `A record with the same ${targetField} already exists.` + message: targetField + ? `A record with the same ${targetField} already exists.` : 'A record with the same unique fields already exists.', code: 'UNIQUE_CONSTRAINT_FAILED', }; @@ -103,8 +106,8 @@ export class PrismaErrorService { // Foreign key constraint failures case 'P2003': return { - message: targetField - ? `The operation failed because the referenced ${targetField} does not exist.` + message: targetField + ? `The operation failed because the referenced ${targetField} does not exist.` : 'The operation failed because a referenced record does not exist.', code: 'FOREIGN_KEY_CONSTRAINT_FAILED', }; @@ -140,20 +143,23 @@ export class PrismaErrorService { case 'P1008': return { - message: 'The operation timed out. Please try again later or with a simpler query.', + message: + 'The operation timed out. Please try again later or with a simpler query.', code: 'OPERATION_TIMEOUT', }; case 'P2024': case 'P2034': return { - message: 'The request timed out due to high database load. Please try again later.', + message: + 'The request timed out due to high database load. Please try again later.', code: 'TIMEOUT', }; case 'P2037': return { - message: 'The server is experiencing high load. Please try again later.', + message: + 'The server is experiencing high load. Please try again later.', code: 'TOO_MANY_CONNECTIONS', }; @@ -169,9 +175,10 @@ export class PrismaErrorService { default: return { - message: 'An unexpected database error occurred. Please try again later.', + message: + 'An unexpected database error occurred. Please try again later.', code: 'DATABASE_ERROR', }; } } -} \ No newline at end of file +} diff --git a/src/shared/modules/global/prisma.service.ts b/src/shared/modules/global/prisma.service.ts index 3a216cf..423cd03 100644 --- a/src/shared/modules/global/prisma.service.ts +++ b/src/shared/modules/global/prisma.service.ts @@ -4,9 +4,12 @@ import { LoggerService } from './logger.service'; import { PrismaErrorService } from './prisma-error.service'; @Injectable() -export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { +export class PrismaService + extends PrismaClient + implements OnModuleInit, OnModuleDestroy +{ private readonly logger: LoggerService; - + constructor(private readonly prismaErrorService?: PrismaErrorService) { super({ log: [ @@ -22,32 +25,36 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul }, }, }); - + this.logger = LoggerService.forRoot('PrismaService'); - + // Setup logging for Prisma queries and errors this.$on('query' as never, (e: Prisma.QueryEvent) => { const queryTime = e.duration; - + // Log query details - full query for dev, just time for production if (process.env.NODE_ENV !== 'production') { - this.logger.debug(`Query: ${e.query} | Params: ${e.params} | Duration: ${queryTime}ms`); + this.logger.debug( + `Query: ${e.query} | Params: ${e.params} | Duration: ${queryTime}ms`, + ); } else if (queryTime > 500) { // In production, only log slow queries (> 500ms) - this.logger.warn(`Slow query detected! Duration: ${queryTime}ms | Query: ${e.query}`); + this.logger.warn( + `Slow query detected! Duration: ${queryTime}ms | Query: ${e.query}`, + ); } }); - + this.$on('info' as never, (e: Prisma.LogEvent) => { this.logger.log(`Prisma Info: ${e.message}`); }); - + this.$on('warn' as never, (e: Prisma.LogEvent) => { this.logger.warn(`Prisma Warning: ${e.message}`); }); - + this.$on('error' as never, (e: Prisma.LogEvent) => { - this.logger.error(`Prisma Error: ${e.message}`, e.target as string); + this.logger.error(`Prisma Error: ${e.message}`, e.target); }); } @@ -56,7 +63,7 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul try { await this.$connect(); this.logger.log('Prisma connected successfully'); - + // Configure query performance if (process.env.NODE_ENV === 'production') { try { @@ -64,19 +71,25 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul await this.$executeRaw`SET max_connections = 100;`; this.logger.log('Database connection pool configured'); } catch (error) { - this.logger.warn(`Could not configure database connections: ${error.message}`); + this.logger.warn( + `Could not configure database connections: ${error.message}`, + ); } } } catch (error) { - const errorMsg = this.prismaErrorService - ? this.prismaErrorService.handleError(error, 'connecting to database').message + const errorMsg = this.prismaErrorService + ? this.prismaErrorService.handleError(error, 'connecting to database') + .message : error.message; - - this.logger.error(`Failed to connect to the database: ${errorMsg}`, error.stack); + + this.logger.error( + `Failed to connect to the database: ${errorMsg}`, + error.stack, + ); throw error; } } - + async onModuleDestroy() { this.logger.log('Disconnecting Prisma'); await this.$disconnect(); diff --git a/uploads/artifact-123.zip b/uploads/artifact-123.zip new file mode 100644 index 0000000000000000000000000000000000000000..9665f139adeff31b445336ed16faa300770326ee GIT binary patch literal 3894 zcmbW4XD}QLx5rlyViR>mStX)H?_G$JC5T>DC%U!Kttcx*L|?rVs|3-a6TSDgTGV6_ zy*xx`xq0r~ckcas&woytIdjhWc4mIs>i7g80P$bRR1KH_{>NuO%6$d)EYP{}4barNMHc?~5cO`yG=C1DhY zYxPtUZ!!i1?()W$XVjWYlPf3}!S=c zWBn?t`7q=UQj^yVAOnA-o2-fDsk_@oZ?EM=og)XU@mVHv>t(sylMOAy{8^M%+eMd# ztP3k$Ogx_k{e2`@$?^b3JHmw8@e28F$nK(Y!Q{@PE`~Dfd9z&djNA5OJiE%P&(q;% zBfkdF0WCGP>z~Hi{*=?GJThKR^`-d5=aWZ^{U*y)v*1jmvzm8Iaa8=|*husKrYOCf zydRY0VjD6XCF#%mu=VMZY=`fQS%14qk`if+S8srJJYN`gVhu|yX*JE9sW=#4Jws>3 zFq7ewxw{DCqeV>T6^YmA5K?)OWefFg&uoV3rEEs%+Hx}a_I?P5f#BMLEkB|BF5vJ~cPc|AjS5=rFF1`@`Njg%B zA0e4FUhn`_8ty^;53tKj@zF)FN+X6kc2_btp4?t`goV_BKLLm+>@@43pdD(W>@pbf z?k9LhMv5;6r%BS&4N>5bRGtU5u#uceL+bI!@?&`jI=V?5?ny?=jJOkXE)XdBI!Tho zlNQG6C($Ybt%&zZ^oL+s3N5(Ot_={ zs(?!jfMSJjQ<5g=c0IbPLlk$YDwutw72gyGmmxZZ@t{LO+Li(-DhO zba&O)fv|yR(+th@sBUUERtim!sjkp!2%6Fs@>aZvi9i}*{`A{h5_gwAr5D8PE`@sE zrCP|tL%E+Q=DhSt1CTvVQv>mvswJt41jThx7`k!tieu=kw1do&bjZQR1GN^>`f$Mz zlCtXBCm|U}#5CzyEGy!uk2_ln~XEhNz)*RtK~DP9xooNPj~;U(EfD^;ew4>IFVe}QroRuG``6RJ(n z<(jrqQocvg?Z`_2 z>H$6ZLr7`lNQj_#oFWn3*E%oq4$9|o=xhQeM@Tf_>~0ny?H11huNI_afB5 zm?0sYpGax|;^2~|fKT~@wG#@BCKs_YpB8c{vMN)CarH_ z>P(b#czEXLuJ4NcaqRSGoK3~O})`|5u+s$pZwlUdi_lR%AQ;T^PsDx-2RSd<#18!b@SqHf6Byo46c#Kcgt}wuuCMWT(gErG*zo2 z=SV5&B!5b*o$eC*WAWXqWO5acu>m#-KclR#FyNd|lLsL)VpzP;0GAu`nqepjSpi8V zWyDsePm~fd;uFUez0F5?w)t!mry8}ghkcED=o9PJ=40$jhN9k95Wt&yp0d-#RIsq&d#JEOwm zkBsdaH#LIcHy-cGXBMQsn;R6IEk4ZwJ-V=Wp;Rg0{l1dhIbl(m7>M^_F%hB_wLA6(F2Y`=<+*gH zOm78)OBbk0T&_8ty~~enM`0Sd4Q#_nPVj3jvDV&KAk8+6_SG9&-VX{fIDK&KXGen4 z@rIpSxza%K;_D)ln6aTYKbGKYjbfhVwmuzJ$5}1HeXBf!DPymvb;Cjv4mhhzjapX) z5+Ye2aoRsK&q~^aeg}GeV6_SEoh&%;&!^T0f#$R3Lf)oo)GIMIY5X)hfT znT!{!=&$NtG?>grqEdqFW=t?BZvGa^HVi`wS|FVL%JQx`zf7#Xue`C)cmVju!0;|& z=;J4*h06_QiT0m+76GNrm-}b!w{xu*2HMPqX<0@}z=kD~xzGL{okO3St~XH#@peW$ zg464Wc<5LzG{>>f9#jZ#$Ij(V9yj`SMwSo%^!j;svw<)|$l-?1l3fv%H;B!?n0-rR z<+$;mE^n7dVvnuMAH|(y7{aP%oS`3>yrHwM&T226V8Xm3KKG=!%Bm91#1N7~B|mqQ z2aaExzI{=>ZfBrcnLN{a+kCK5xD3oRJq&2VO=iN5!ZYUt;FfpNrG)-nAt|DxsM+dM ztc=XuU7J6)d|#_=srBabQ7+{#R&TiFwa4xUFd=mp{TNZc!NcOT^K2RkHm&>n1F%_2 zVu|*}rOVaw1M1PFOrxM?@5{b32P4D^&ft8gU60!#)#aNCV9iIsa?hgCkRe6m<7^Q6 z*|t#LPmh#IyNO?q)N9}-pZ|H@LpMhDVNm=Hdy<;}*VOrKC`kjTO9J=3Nk0o4Iq~Hx zMlbkIy8hhS&8L$aTuWbnl6tJ5;-fEI-F(@3z$~Y(R7X8+qwL-@K4!dms*}x;3a>awSL!3vbEek$q@h)ol(2Fls+JPwU@^k^OkueazcD}GZ}jW6r+r{U zZeQCcxkbYY+kFAFQ{<~{rdM*R#6}09%0B8A9fKw4g}gPX+kG-Gf}bjfhXEHBuPRXG z81~=pjQlm8o4|#G@v!*Q4*XY8<9^hx*x-0V%8-xzr}48cyy+}0MH{}?wN2;lv6)&P z+a~-G_Ip}h<<7#JVek2BjzETXxyX)`i`rg8P@N%iRp6y$YeG7RGx2LSGee!BC8q6g zT-3uBth5l{E-f=5PZsX^f|6NNte1#}BnNIJHn})+da>EW)+3@`e_C|dz#rIpJ@flu zX1w9f7oD27_TKU=Ka|Pc{`07e(>RNPHTmy%siJsJOxE@0?6XRSH+L<)jt6{$ZG)rL z)|a{V*1{%r{hE-HM1$g`JsEyixbT~_=m+t7s!j*xJa4_fAH^1sj&t)CBj))=?W<#? zU83PHhhNv|Bsvk!&5y0vi^SW?JBS>$Z+-v!I=#h_Q8m=lA~r2CQ}D#a-^xz)J8g)P z>v4(Zxr&sHvQ3QnwDy9Uno7$)LGA5ZpfQm0;cF36;RS$VC1X@f#CQO~%EUHS2PQ71 z{fcx%0kv@}l${w_Byg@3)EM-g>Bj1CtEwkpD}!%FI@F;`!}{yf{C5@aIopK=OAmdm z7;f62)lbsBzZHMfMC>k)?pp@Mmoq*FGca4QN5Pyh(}yj%h6BWB8w5GO0Ea2QI&}GG z00y_3-Koi9jzK3A)Df8nEpdPb%*)K#tKyA1!l*$Jw$q*c5U8w=ZNY}v14qRmhUWau zC<^q9DY_G-(@AwrdA%9}7Loa#N90i+=w1mxQlHU=t^PEcUxdK2oA|u@{=EFOYlInJ zj~V_k*={ijqxa6tSynq)Ics)q89&J`sShuS%msf#_8`Q>r1fX018&u)#%Gs4r;aPLnZ&EuBoAvvJer;O&fdux9{ zgNg5YftEbq-iiCeRSsDrYtx$7S|p*U4O~tCZNN8_+K*m;T=)FtN-W|z?zdSLlij%u zXBAh(F3dh^b}>8If;dy42_$BTtb@HplY5+#@spEs{!=ZALFKUYkfoT7RU>V;K~Whd z3>CW`8+pG&>7F)yFK&YF^}YRM(p?e6`{fA93*bf3#kI>yxW)v@OJKhn*RX2Kd$bg6 zw*OH#PLUoqEH3D&@Kx|^?Bk&M3iRon@m-zK#F!@#MM>=Peq1>w*;_`Tcp&Df0(GR@ zTTUa(0b!O)J#JSWU72f6 z2!92Vh_8|VlWew+7!cwE9Kd3IFc~9k|=o{ zKxPuK(-#Q7BhpqUB92?OeWXhY0Q`%m+Uj`sK=}WQuK&09FS`E6YJ>l+{@)<0txkCV R-vxMomStX)H?_G$JC5T>DC%U!Kttcx*L|?rVs|3-a6TSDgTGV6_ zy*xx`xq0r~ckcas&woytIdjhWc4mIs>i7g80P$bRR1KH_{>NuO%6$d)EYP{}4barNMHc?~5cO`yG=C1DhY zYxPtUZ!!i1?()W$XVjWYlPf3}!S=c zWBn?t`7q=UQj^yVAOnA-o2-fDsk_@oZ?EM=og)XU@mVHv>t(sylMOAy{8^M%+eMd# ztP3k$Ogx_k{e2`@$?^b3JHmw8@e28F$nK(Y!Q{@PE`~Dfd9z&djNA5OJiE%P&(q;% zBfkdF0WCGP>z~Hi{*=?GJThKR^`-d5=aWZ^{U*y)v*1jmvzm8Iaa8=|*husKrYOCf zydRY0VjD6XCF#%mu=VMZY=`fQS%14qk`if+S8srJJYN`gVhu|yX*JE9sW=#4Jws>3 zFq7ewxw{DCqeV>T6^YmA5K?)OWefFg&uoV3rEEs%+Hx}a_I?P5f#BMLEkB|BF5vJ~cPc|AjS5=rFF1`@`Njg%B zA0e4FUhn`_8ty^;53tKj@zF)FN+X6kc2_btp4?t`goV_BKLLm+>@@43pdD(W>@pbf z?k9LhMv5;6r%BS&4N>5bRGtU5u#uceL+bI!@?&`jI=V?5?ny?=jJOkXE)XdBI!Tho zlNQG6C($Ybt%&zZ^oL+s3N5(Ot_={ zs(?!jfMSJjQ<5g=c0IbPLlk$YDwutw72gyGmmxZZ@t{LO+Li(-DhO zba&O)fv|yR(+th@sBUUERtim!sjkp!2%6Fs@>aZvi9i}*{`A{h5_gwAr5D8PE`@sE zrCP|tL%E+Q=DhSt1CTvVQv>mvswJt41jThx7`k!tieu=kw1do&bjZQR1GN^>`f$Mz zlCtXBCm|U}#5CzyEGy!uk2_ln~XEhNz)*RtK~DP9xooNPj~;U(EfD^;ew4>IFVe}QroRuG``6RJ(n z<(jrqQocvg?Z`_2 z>H$6ZLr7`lNQj_#oFWn3*E%oq4$9|o=xhQeM@Tf_>~0ny?H11huNI_afB5 zm?0sYpGax|;^2~|fKT~@wG#@BCKs_YpB8c{vMN)CarH_ z>P(b#czEXLuJ4NcaqRSGoK3~O})`|5u+s$pZwlUdi_lR%AQ;T^PsDx-2RSd<#18!b@SqHf6Byo46c#Kcgt}wuuCMWT(gErG*zo2 z=SV5&B!5b*o$eC*WAWXqWO5acu>m#-KclR#FyNd|lLsL)VpzP;0GAu`nqepjSpi8V zWyDsePm~fd;uFUez0F5?w)t!mry8}ghkcED=o9PJ=40$jhN9k95Wt&yp0d-#RIsq&d#JEOwm zkBsdaH#LIcHy-cGXBMQsn;R6IEk4ZwJ-V=Wp;Rg0{l1dhIbl(m7>M^_F%hB_wLA6(F2Y`=<+*gH zOm78)OBbk0T&_8ty~~enM`0Sd4Q#_nPVj3jvDV&KAk8+6_SG9&-VX{fIDK&KXGen4 z@rIpSxza%K;_D)ln6aTYKbGKYjbfhVwmuzJ$5}1HeXBf!DPymvb;Cjv4mhhzjapX) z5+Ye2aoRsK&q~^aeg}GeV6_SEoh&%;&!^T0f#$R3Lf)oo)GIMIY5X)hfT znT!{!=&$NtG?>grqEdqFW=t?BZvGa^HVi`wS|FVL%JQx`zf7#Xue`C)cmVju!0;|& z=;J4*h06_QiT0m+76GNrm-}b!w{xu*2HMPqX<0@}z=kD~xzGL{okO3St~XH#@peW$ zg464Wc<5LzG{>>f9#jZ#$Ij(V9yj`SMwSo%^!j;svw<)|$l-?1l3fv%H;B!?n0-rR z<+$;mE^n7dVvnuMAH|(y7{aP%oS`3>yrHwM&T226V8Xm3KKG=!%Bm91#1N7~B|mqQ z2aaExzI{=>ZfBrcnLN{a+kCK5xD3oRJq&2VO=iN5!ZYUt;FfpNrG)-nAt|DxsM+dM ztc=XuU7J6)d|#_=srBabQ7+{#R&TiFwa4xUFd=mp{TNZc!NcOT^K2RkHm&>n1F%_2 zVu|*}rOVaw1M1PFOrxM?@5{b32P4D^&ft8gU60!#)#aNCV9iIsa?hgCkRe6m<7^Q6 z*|t#LPmh#IyNO?q)N9}-pZ|H@LpMhDVNm=Hdy<;}*VOrKC`kjTO9J=3Nk0o4Iq~Hx zMlbkIy8hhS&8L$aTuWbnl6tJ5;-fEI-F(@3z$~Y(R7X8+qwL-@K4!dms*}x;3a>awSL!3vbEek$q@h)ol(2Fls+JPwU@^k^OkueazcD}GZ}jW6r+r{U zZeQCcxkbYY+kFAFQ{<~{rdM*R#6}09%0B8A9fKw4g}gPX+kG-Gf}bjfhXEHBuPRXG z81~=pjQlm8o4|#G@v!*Q4*XY8<9^hx*x-0V%8-xzr}48cyy+}0MH{}?wN2;lv6)&P z+a~-G_Ip}h<<7#JVek2BjzETXxyX)`i`rg8P@N%iRp6y$YeG7RGx2LSGee!BC8q6g zT-3uBth5l{E-f=5PZsX^f|6NNte1#}BnNIJHn})+da>EW)+3@`e_C|dz#rIpJ@flu zX1w9f7oD27_TKU=Ka|Pc{`07e(>RNPHTmy%siJsJOxE@0?6XRSH+L<)jt6{$ZG)rL z)|a{V*1{%r{hE-HM1$g`JsEyixbT~_=m+t7s!j*xJa4_fAH^1sj&t)CBj))=?W<#? zU83PHhhNv|Bsvk!&5y0vi^SW?JBS>$Z+-v!I=#h_Q8m=lA~r2CQ}D#a-^xz)J8g)P z>v4(Zxr&sHvQ3QnwDy9Uno7$)LGA5ZpfQm0;cF36;RS$VC1X@f#CQO~%EUHS2PQ71 z{fcx%0kv@}l${w_Byg@3)EM-g>Bj1CtEwkpD}!%FI@F;`!}{yf{C5@aIopK=OAmdm z7;f62)lbsBzZHMfMC>k)?pp@Mmoq*FGca4QN5Pyh(}yj%h6BWB8w5GO0Ea2QI&}GG z00y_3-Koi9jzK3A)Df8nEpdPb%*)K#tKyA1!l*$Jw$q*c5U8w=ZNY}v14qRmhUWau zC<^q9DY_G-(@AwrdA%9}7Loa#N90i+=w1mxQlHU=t^PEcUxdK2oA|u@{=EFOYlInJ zj~V_k*={ijqxa6tSynq)Ics)q89&J`sShuS%msf#_8`Q>r1fX018&u)#%Gs4r;aPLnZ&EuBoAvvJer;O&fdux9{ zgNg5YftEbq-iiCeRSsDrYtx$7S|p*U4O~tCZNN8_+K*m;T=)FtN-W|z?zdSLlij%u zXBAh(F3dh^b}>8If;dy42_$BTtb@HplY5+#@spEs{!=ZALFKUYkfoT7RU>V;K~Whd z3>CW`8+pG&>7F)yFK&YF^}YRM(p?e6`{fA93*bf3#kI>yxW)v@OJKhn*RX2Kd$bg6 zw*OH#PLUoqEH3D&@Kx|^?Bk&M3iRon@m-zK#F!@#MM>=Peq1>w*;_`Tcp&Df0(GR@ zTTUa(0b!O)J#JSWU72f6 z2!92Vh_8|VlWew+7!cwE9Kd3IFc~9k|=o{ zKxPuK(-#Q7BhpqUB92?OeWXhY0Q`%m+Uj`sK=}WQuK&09FS`E6YJ>l+{@)<0txkCV R-vxMo Date: Tue, 5 Aug 2025 06:33:16 +0800 Subject: [PATCH 2/5] Submissions API data import --- mock/jwt.ts | 50 ++ package.json | 2 +- prisma/migrate.ts | 830 +++++++++++------- .../20250724133600_add_upload/migration.sql | 159 ++++ prisma/schema.prisma | 143 ++- src/api/api.module.ts | 10 +- .../review-summation.controller.ts | 195 +--- .../review-summation.service.ts | 136 +++ src/api/submission/submission.controller.ts | 192 +--- src/api/submission/submission.service.ts | 161 ++++ src/dto/reviewSummation.dto.ts | 49 +- src/dto/submission.dto.ts | 94 +- src/dto/upload.dto.ts | 12 + src/shared/modules/global/utils.service.ts | 8 + 14 files changed, 1284 insertions(+), 757 deletions(-) create mode 100644 mock/jwt.ts create mode 100644 prisma/migrations/20250724133600_add_upload/migration.sql create mode 100644 src/api/review-summation/review-summation.service.ts create mode 100644 src/api/submission/submission.service.ts create mode 100644 src/dto/upload.dto.ts create mode 100644 src/shared/modules/global/utils.service.ts diff --git a/mock/jwt.ts b/mock/jwt.ts new file mode 100644 index 0000000..e766dad --- /dev/null +++ b/mock/jwt.ts @@ -0,0 +1,50 @@ +import * as jwt from 'jsonwebtoken'; +import { Scope } from '../src/shared/enums/scopes.enum'; +import { UserRole } from '../src/shared/enums/userRole.enum'; + +const commonFields = { + exp: 1845393226, + iss: 'https://topcoder-dev.com' +}; + +const authSecret = 'secret'; + +const adminPayload = { + roles: [UserRole.Admin], + handle: 'admin', + userId: 123, + ...commonFields +}; + +console.log('------------- Admin Token -------------'); +console.log(jwt.sign(adminPayload, authSecret)); + +const m2mPayload = { + scope: `${Scope.AllReview} ${Scope.AllSubmission} ${Scope.AllAppeal} ${Scope.AllReviewSummation}`, + sub: 'auth0|clients', + ...commonFields +}; + +console.log('------------- Full M2M token -------------'); +console.log(jwt.sign(m2mPayload, authSecret)); + +const userPayload = { + roles: [UserRole.User], + handle: 'user', + userId: 124, + ...commonFields +}; + +console.log('------------- User Token -------------'); +console.log(jwt.sign(userPayload, authSecret)); + + +const reviewerPayload = { + roles: [UserRole.Reviewer], + handle: 'reviewer', + userId: 125, + ...commonFields +}; + +console.log('------------- Reviewer Token -------------'); +console.log(jwt.sign(reviewerPayload, authSecret)); diff --git a/package.json b/package.json index 10fded1..66986cd 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json", - "postinstall": "npx prisma generate" + "postinstall": "pnpm exec prisma generate" }, "dependencies": { "@nestjs/axios": "^4.0.0", diff --git a/prisma/migrate.ts b/prisma/migrate.ts index c78f90e..72e51d4 100644 --- a/prisma/migrate.ts +++ b/prisma/migrate.ts @@ -1,6 +1,7 @@ -import { Prisma, PrismaClient } from '@prisma/client'; +import { PrismaClient } from '@prisma/client'; import * as path from 'path'; import * as fs from 'fs'; +import * as readline from 'readline'; import { ScorecardStatus, ScorecardType, @@ -9,6 +10,8 @@ import { } from '../src/dto/scorecard.dto'; import { ReviewItemCommentType } from '../src/dto/review.dto'; import { nanoid } from 'nanoid'; +import { UploadType, UploadStatus } from '../src/dto/upload.dto'; +import { SubmissionStatus, SubmissionType } from '../src/dto/submission.dto'; interface QuestionTypeMap { name: QuestionType; @@ -28,6 +31,9 @@ console.log(`Using PostgreSQL schema: ${schema}`); const prisma = new PrismaClient(); const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'Scorecards'); const batchSize = 1000; +const logSize = 20000; +const esFileName = 'dev-submissions-api.data.json'; + const modelMappingKeys = [ 'project_result', 'scorecard', @@ -47,120 +53,10 @@ const lookupKeys: string[] = [ 'scorecard_question_type_lu', 'comment_type_lu', 'project_category_lu', -]; - -const reviewTypeData: Prisma.reviewTypeCreateInput[] = [ - { - id: 'c56a4180-65aa-42ec-a945-5fd21dec0501', - name: 'Screening', - isActive: true, - }, - { - id: 'c56a4180-65aa-42ec-a945-5fd21dec0502', - name: 'Checkpoint Review', - isActive: true, - }, - { - id: 'c56a4180-65aa-42ec-a945-5fd21dec0503', - name: 'Review', - isActive: true, - }, - { - id: 'c56a4180-65aa-42ec-a945-5fd21dec0504', - name: 'Appeals Response', - isActive: true, - }, - { - id: 'c56a4180-65aa-42ec-a945-5fd21dec0505', - name: 'Iterative Review', - isActive: true, - }, - { - id: 'f28b2725-ef90-4495-af59-ceb2bd98fc10', - name: 'AV Scan', - isActive: false, - }, -]; - -const scorecardData: Prisma.scorecardCreateInput = { - status: 'ACTIVE', - type: 'REVIEW', - challengeTrack: 'DEVELOPMENT', - challengeType: 'Code Development', - name: 'Topcoder Review API - Migrate Submissions API into the new Review API', - version: '1.0', - minScore: 80.0, - maxScore: 100.0, - createdAt: '2018-05-20T07:00:30.123Z', - updatedAt: '2018-06-01T07:36:28.178Z', - createdBy: 'admin', - updatedBy: 'admin', -}; - -const reviewData = [ - { - id: 'd24d4180-65aa-42ec-a945-5fd21dec0501', - resourceId: 'c56a4180-65aa-42ec-a001-5fd21dec0001', - phaseId: 'c56a4180-65aa-42ec-a001-5fd21dec0001', - initialScore: 95.5, - finalScore: 96.5, - typeId: 'c56a4180-65aa-42ec-a945-5fd21dec0503', - status: 'Review', - reviewDate: '2025-05-20T07:00:30.123Z', - createdAt: '2018-05-20T07:00:30.123Z', - updatedAt: '2018-06-01T07:36:28.178Z', - createdBy: 'admin', - updatedBy: 'admin', - }, - { - id: 'd24d4180-65aa-42ec-a945-5fd21dec0502', - resourceId: 'c56a4180-65aa-42ec-a001-5fd21dec0001', - phaseId: 'c56a4180-65aa-42ec-a001-5fd21dec0001', - initialScore: 92.0, - finalScore: 93.5, - typeId: 'c56a4180-65aa-42ec-a945-5fd21dec0501', - metadata: { - public: 'public data', - private: 'private data', - }, - status: 'Appeal', - reviewDate: '2025-05-20T07:00:30.123Z', - createdAt: '2018-05-20T07:00:30.123Z', - updatedAt: '2018-06-01T07:36:28.178Z', - createdBy: 'admin', - updatedBy: 'admin', - }, -]; - -const reviewSummationData: Prisma.reviewSummationCreateWithoutSubmissionInput[] = - [ - { - id: 'e45e4180-65aa-42ec-a945-5fd21dec1504', - aggregateScore: 99.0, - scorecardId: '123456789', - isPassing: true, - isFinal: false, - reviewedDate: '2025-06-01T07:36:28.178Z', - createdAt: '2018-05-20T07:00:30.123Z', - updatedAt: '2018-06-01T07:36:28.178Z', - createdBy: 'copilot', - updatedBy: 'copilot', - }, - ]; - -const submissionData: Prisma.submissionCreateInput[] = [ - { - id: 'a12a4180-65aa-42ec-a945-5fd21dec0501', - type: 'ContestSubmission', - url: 'https://software.topcoder.com/review/actions/DownloadContestSubmission?uid=123456', - memberId: 'b24d4180-65aa-42ec-a945-5fd21dec0501', - challengeId: 'c3564180-65aa-42ec-a945-5fd21dec0502', - submittedDate: '2018-05-20T07:00:30.123Z', - createdAt: '2018-05-20T07:00:30.123Z', - updatedAt: '2018-06-01T07:36:28.178Z', - createdBy: 'topcoder user', - updatedBy: 'topcoder user', - }, + 'upload_type_lu', + 'upload_status_lu', + 'submission_type_lu', + 'submission_status_lu', ]; // Global lookup maps @@ -169,111 +65,47 @@ let scorecardTypeMap: Record = {}; let questionTypeMap: Record = {}; let projectCategoryMap: Record = {}; let reviewItemCommentTypeMap: Record = {}; +let uploadTypeMap: Record = {}; +let uploadStatusMap: Record = {}; +let submissionTypeMap: Record = {}; +let submissionStatusMap: Record = {}; +let resourceSubmissionSet = new Set(); // Global submission map to store submission information. const submissionMap: Record> = {}; // Data lookup maps // Initialize maps from files if they exist, otherwise create new maps -const projectIdMap = fs.existsSync('.tmp/projectIdMap.json') - ? new Map( - Object.entries( - JSON.parse(fs.readFileSync('.tmp/projectIdMap.json', 'utf-8')), - ), - ) - : new Map(); - -const scorecardIdMap = fs.existsSync('.tmp/scorecardIdMap.json') - ? new Map( - Object.entries( - JSON.parse(fs.readFileSync('.tmp/scorecardIdMap.json', 'utf-8')), - ), - ) - : new Map(); - -const scorecardGroupIdMap = fs.existsSync('.tmp/scorecardGroupIdMap.json') - ? new Map( - Object.entries( - JSON.parse(fs.readFileSync('.tmp/scorecardGroupIdMap.json', 'utf-8')), - ), - ) - : new Map(); - -const scorecardSectionIdMap = fs.existsSync('.tmp/scorecardSectionIdMap.json') - ? new Map( - Object.entries( - JSON.parse(fs.readFileSync('.tmp/scorecardSectionIdMap.json', 'utf-8')), - ), - ) - : new Map(); - -const scorecardQuestionIdMap = fs.existsSync('.tmp/scorecardQuestionIdMap.json') - ? new Map( - Object.entries( - JSON.parse( - fs.readFileSync('.tmp/scorecardQuestionIdMap.json', 'utf-8'), - ), - ), - ) - : new Map(); - -const reviewIdMap = fs.existsSync('.tmp/reviewIdMap.json') - ? new Map( - Object.entries( - JSON.parse(fs.readFileSync('.tmp/reviewIdMap.json', 'utf-8')), - ), - ) - : new Map(); - -const reviewItemIdMap = fs.existsSync('.tmp/reviewItemIdMap.json') - ? new Map( - Object.entries( - JSON.parse(fs.readFileSync('.tmp/reviewItemIdMap.json', 'utf-8')), - ), - ) - : new Map(); - -const reviewItemCommentReviewItemCommentIdMap = fs.existsSync( - '.tmp/reviewItemCommentReviewItemCommentIdMap.json', -) +function readIdMap(filename) { + return fs.existsSync(`.tmp/${filename}.json`) ? new Map( Object.entries( - JSON.parse( - fs.readFileSync( - '.tmp/reviewItemCommentReviewItemCommentIdMap.json', - 'utf-8', - ), - ), + JSON.parse(fs.readFileSync(`.tmp/${filename}.json`, 'utf-8')), ), ) : new Map(); +} -const reviewItemCommentAppealIdMap = fs.existsSync( - '.tmp/reviewItemCommentAppealIdMap.json', -) - ? new Map( - Object.entries( - JSON.parse( - fs.readFileSync('.tmp/reviewItemCommentAppealIdMap.json', 'utf-8'), - ), - ), - ) - : new Map(); +const projectIdMap = readIdMap('projectIdMap'); +const scorecardIdMap = readIdMap('scorecardIdMap'); +const scorecardGroupIdMap = readIdMap('scorecardGroupIdMap'); +const scorecardSectionIdMap = readIdMap('scorecardSectionIdMap'); +const scorecardQuestionIdMap = readIdMap('scorecardQuestionIdMap'); +const reviewIdMap = readIdMap('reviewIdMap'); +const reviewItemIdMap = readIdMap('reviewItemIdMap'); +const reviewItemCommentReviewItemCommentIdMap = readIdMap('reviewItemCommentReviewItemCommentIdMap'); +const reviewItemCommentAppealIdMap = readIdMap('reviewItemCommentAppealIdMap'); +const reviewItemCommentAppealResponseIdMap = readIdMap('reviewItemCommentAppealResponseIdMap'); +const uploadIdMap = readIdMap('uploadIdMap'); +const submissionIdMap = readIdMap('submissionIdMap'); -const reviewItemCommentAppealResponseIdMap = fs.existsSync( - '.tmp/reviewItemCommentAppealResponseIdMap.json', -) - ? new Map( - Object.entries( - JSON.parse( - fs.readFileSync( - '.tmp/reviewItemCommentAppealResponseIdMap.json', - 'utf-8', - ), - ), - ), - ) - : new Map(); +// read resourceSubmissionSet +const rsSetFile = '.tmp/resourceSubmissionSet.json'; +if (fs.existsSync(rsSetFile)) { + resourceSubmissionSet = new Set([ + ...(JSON.parse(fs.readFileSync(rsSetFile, 'utf-8')) as string[]) + ]); +} // Legacy enum mappings enum LegacyScorecardStatus { @@ -314,6 +146,39 @@ enum LegacyCommentType { 'Specification Review Comment' = ReviewItemCommentType.SPECIFICATION_REVIEW_COMMENT, } +enum LegacyUploadType { + 'Submission' = UploadType.SUBMISSION, + 'Test Case' = UploadType.TEST_CASE, + 'Final Fix' = UploadType.FINAL_FIX, + 'Review Document' = UploadType.REVIEW_DOCUMENT, +} + +enum LegacyUploadStatus { + 'Active' = UploadStatus.ACTIVE, + 'Deleted' = UploadStatus.DELETED +} + +enum LegacySubmissionType { + // compatible for ES + 'ContestSubmission' = SubmissionType.CONTEST_SUBMISSION, + 'challengesubmission' = SubmissionType.CONTEST_SUBMISSION, + // enum values + 'Contest Submission' = SubmissionType.CONTEST_SUBMISSION, + 'Specification Submission' = SubmissionType.SPECIFICATION_SUBMISSION, + 'Checkpoint Submission' = SubmissionType.CHECKPOINT_SUBMISSION, + 'Studio Final Fix Submission' = SubmissionType.STUDIO_FINAL_FIX_SUBMISSION +} + +enum LegacySubmissionStatus { + 'Active' = SubmissionStatus.ACTIVE, + 'Failed Screening' = SubmissionStatus.FAILED_SCREENING, + 'Failed Review' = SubmissionStatus.FAILED_REVIEW, + 'Completed Without Win' = SubmissionStatus.COMPLETED_WITHOUT_WIN, + 'Deleted' = SubmissionStatus.DELETED, + 'Failed Checkpoint Screening' = SubmissionStatus.FAILED_CHECKPOINT_SCREENING, + 'Failed Checkpoint Review' = SubmissionStatus.FAILED_CHECKPOINT_REVIEW +} + const LegacyChallengeTrack: Record = { '1': ChallengeTrack.DEVELOPMENT, '2': ChallengeTrack.DATA_SCIENCE, @@ -394,6 +259,293 @@ function processLookupFiles() { ), ); break; + case 'upload_type_lu': + uploadTypeMap = Object.fromEntries( + (jsonData.upload_type_lu as any[]).map( + ({ upload_type_id, name }) => [ + upload_type_id, + LegacyUploadType[name] + ] + ) + ); + break; + case 'upload_status_lu': + uploadStatusMap = Object.fromEntries( + (jsonData.upload_status_lu as any[]).map( + ({ upload_status_id, name }) => [ + upload_status_id, + LegacyUploadStatus[name] + ] + ) + ); + break; + case 'submission_type_lu': + submissionTypeMap = Object.fromEntries( + (jsonData.submission_type_lu as any[]).map( + ({ submission_type_id, name }) => [ + submission_type_id, + LegacySubmissionType[name] + ] + ) + ); + break; + case 'submission_status_lu': + submissionStatusMap = Object.fromEntries( + (jsonData.submission_status_lu as any[]).map( + ({ submission_status_id, name }) => [ + submission_status_id, + LegacySubmissionStatus[name] + ] + ) + ); + break; + } + } +} + +const filenameComp = (a, b) => { + const numA = parseInt(a.match(/_(\d+)\.json$/)?.[1] || '0', 10); + const numB = parseInt(b.match(/_(\d+)\.json$/)?.[1] || '0', 10); + return numA - numB; +} + + +function convertSubmissionES(esData) { + let challengeId = null; + let legacyChallengeId = null; + if (esData.legacyChallengeId) { + legacyChallengeId = esData.legacyChallengeId; + } + if (esData.challengeId) { + if (typeof esData.challengeId === 'number') { + legacyChallengeId = esData.challengeId; + } else { + challengeId = esData.challengeId; + } + } + const submission: any = { + legacySubmissionId: String(esData.legacySubmissionId), + url: esData.url, + memberId: String(esData.memberId), + challengeId, + legacyChallengeId, + submissionPhaseId: String(esData.submissionPhaseId), + fileType: esData.fileType, + esId: esData.id, + submittedDate: esData.submittedDate ? new Date(esData.submittedDate) : null, + updatedBy: esData.updatedBy ?? null, + updatedAt: esData.updated ? new Date(esData.updated) : null, + }; + if (esData.reviewSummation && esData.reviewSummation.length > 0) { + const summation = esData.reviewSummation[0]; + submission.reviewSummation = { + create: { + id: summation.id, + legacySubmissionId: String(esData.legacySubmissionId), + aggregateScore: summation.aggregateScore, + scorecardId: scorecardIdMap.get(summation.scoreCardId), + scorecardLegacyId: String(summation.scoreCardId), + isPassing: summation.isPassing, + reviewedDate: summation.reviewedDate ? new Date(summation.reviewedDate) : null, + createdBy: summation.createdBy, + createdAt: new Date(summation.created), + updatedBy: summation.updatedBy, + updatedAt: summation.updated ? new Date(summation.updated) : null, + } + }; + } + return submission; +} + +async function migrateElasticSearch() { + // migrate elastic search data + const filepath = path.join(DATA_DIR, esFileName); + const fileStream = fs.createReadStream(filepath); + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity, + }); + // read file line by line and handle it + let lineCount = 0; + for await (const line of rl) { + lineCount += 1; + try { + const data = JSON.parse(line); + const source = data['_source']; + // only process 'submission' data for now + if (source['resource'] === 'submission') { + await handleElasticSearchSubmission(source); + } + } catch (err) { + console.log(`Failed to process ES line ${lineCount}`); + } + if (lineCount % logSize === 0) { + console.log(`ES data processed ${lineCount} lines`); + } + } + // migrate remaining submissions + await importSubmissionES(); + console.log(`ES data imported with total line: ${lineCount}`); +} + + +let currentSubmissions: any[] = []; +async function handleElasticSearchSubmission(item) { + // ignore records without legacySubmissionId field. + if (item['legacySubmissionId'] == null) { + return; + } + currentSubmissions.push(item); + // if we can batch insert data, + + if (currentSubmissions.length >= batchSize) { + await importSubmissionES(); + currentSubmissions = []; + } +} + +async function importSubmissionES() { + if (currentSubmissions.length === 0) { + return; + } + for (const item of currentSubmissions) { + const submission = convertSubmissionES(item); + + let newSubmission = false; + try { + if (submissionIdMap.has(submission.legacySubmissionId)) { + newSubmission = false; + await prisma.submission.update({ + data: submission, + where: { id: submissionIdMap.get(submission.legacySubmissionId) as string } + }); + } else { + newSubmission = true; + const newId = nanoid(14); + projectIdMap.set(submission.legacySubmissionId, newId); + let type = LegacySubmissionType[item.type]; + if (!LegacySubmissionType[item.type]) { + type = LegacySubmissionType.ContestSubmission; + } + await prisma.submission.create({ + data: { + ...submission, + id: newId, + status: SubmissionStatus.ACTIVE, + type, + createdBy: item.createdBy || 'migration', + createdAt: item.created ? new Date(item.created) : new Date() + }, + }); + } + } catch (err) { + if (newSubmission) { + projectIdMap.delete(submission.legacySubmissionId); + } + console.error(`Failed to import submission from ES: ${submission.esId}`); + } + } +} + +function convertUpload(jsonData) { + return { + id: nanoid(14), + legacyId: jsonData['upload_id'], + projectId: jsonData['project_id'], + resourceId: jsonData['resource_id'], + type: uploadTypeMap[jsonData['upload_type_id']], + status: uploadStatusMap[jsonData['upload_status_id']], + parameter: jsonData['parameter'], + url: jsonData['url'], + desc: jsonData['upload_desc'], + projectPhaseId: jsonData['project_phase_id'], + createdBy: jsonData['create_user'], + createdAt: new Date(jsonData['create_date']), + updatedBy: jsonData['modify_user'], + updatedAt: new Date(jsonData['modify_date']), + }; +} + +let uploadDataList: any[] = []; +async function importUploadData(uploadData) { + uploadDataList.push(uploadData); + if (uploadDataList.length >= batchSize) { + await doImportUploadData(); + uploadDataList = []; + } +} + +async function doImportUploadData() { + if (uploadDataList.length === 0) { + return; + } + try { + await prisma.upload.createMany({ + data: uploadDataList + }); + } catch { + // import data one by one + for (const u of uploadDataList) { + try { + await prisma.upload.create({ data: u }); + } catch (err) { + console.error(`Cannot import upload data id: ${u.legacyId}`); + uploadIdMap.delete(u.legacyId); + } + } + } +} + +function convertSubmission(jsonData) { + return { + id: nanoid(14), + legacySubmissionId: jsonData['submission_id'], + legacyUploadId: jsonData['upload_id'], + uploadId: uploadIdMap.get(jsonData['upload_id']), + status: submissionStatusMap[jsonData['submission_status_id']], + type: submissionTypeMap[jsonData['submission_type_id']], + screeningScore: jsonData['screening_score'], + initialScore: jsonData['initial_score'], + finalScore: jsonData['final_score'], + placement: jsonData['placement'] ? Number(jsonData['placement']) : null, + userRank: jsonData['user_rank'] ? Number(jsonData['user_rank']) : null, + markForPurchase: jsonData['mark_for_purchase'], + prizeId: jsonData['prize_id'], + fileSize: jsonData['file_size'] ? Number(jsonData['file_size']) : null, + viewCount: jsonData['view_count'] ? Number(jsonData['view_count']) : null, + systemFileName: jsonData['system_file_name'], + thurgoodJobId: jsonData['thurgood_job_id'], + createdBy: jsonData['create_user'], + createdAt: new Date(jsonData['create_date']), + updatedBy: jsonData['modify_user'], + updatedAt: new Date(jsonData['modify_date']), + }; +} + +let submissionDataList: any[] = []; +async function importSubmissionData(submissionData) { + submissionDataList.push(submissionData); + if (submissionDataList.length >= batchSize) { + await doImportSubmissionData(); + submissionDataList = []; + } +} + +async function doImportSubmissionData() { + if (submissionDataList.length === 0) { + return; + } + try { + await prisma.submission.createMany({ + data: submissionDataList + }); + } catch { + for (const s of submissionDataList) { + try { + await prisma.submission.create({ data: s }); + } catch (err) { + console.error(`Failed to import submission ${s.legacySubmissionId}`); + submissionIdMap.delete(s.legacySubmissionId); + } } } } @@ -410,7 +562,7 @@ async function initSubmissionMap() { const submissionFiles: string[] = []; const uploadFiles: string[] = []; const resourceFiles: string[] = []; - fs.readdirSync(DATA_DIR).filter((f) => { + fs.readdirSync(DATA_DIR).forEach((f) => { if (submissionRegex.test(f)) { submissionFiles.push(f); } @@ -421,58 +573,100 @@ async function initSubmissionMap() { resourceFiles.push(f); } }); - // read submission files. Get {upload_id -> submission} map. + // sort files by filename + uploadFiles.sort(filenameComp); + submissionFiles.sort(filenameComp); + // import upload data, get { resource_id -> upload_id } map. + const resourceUploadMap: Record = {}; + let uploadTotalCount = 0; + for (const f of uploadFiles) { + const filePath = path.join(DATA_DIR, f); + console.log(`Reading upload data from ${f}`); + const jsonData = readJson(filePath)['upload']; + let dataCount = 0; + for (const d of jsonData) { + dataCount += 1; + const uploadData = convertUpload(d); + // import upload data if any + if (!uploadIdMap.has(uploadData.legacyId)) { + uploadIdMap.set(uploadData.legacyId, uploadData.id); + await importUploadData(uploadData); + } + // collect data to resourceUploadMap + if ( + uploadData.type === UploadType.SUBMISSION && + uploadData.status === UploadStatus.ACTIVE && + uploadData.resourceId != null + ) { + resourceUploadMap[uploadData.resourceId] = uploadData.legacyId; + } + if (dataCount % logSize === 0) { + console.log(`Imported upload count: ${dataCount}`); + } + } + uploadTotalCount += dataCount; + } + // import remaining upload data + await doImportUploadData(); + console.log(`Upload data import complete. Total count: ${uploadTotalCount}`); + + // import submission data, get {upload_id -> submission} map const uploadSubmissionMap: Record = {}; - let submissionCount = 0; + let submissionTotalCount = 0; for (const f of submissionFiles) { const filePath = path.join(DATA_DIR, f); console.log(`Reading submission data from ${f}`); const jsonData = readJson(filePath)['submission']; + let dataCount = 0; for (const d of jsonData) { - if (d['submission_status_id'] === '1' && d['upload_id']) { - submissionCount += 1; - // find submission has score and most recent + dataCount += 1; + const dbData = convertSubmission(d); + if (!submissionIdMap.has(dbData.legacySubmissionId)) { + submissionIdMap.set(dbData.legacySubmissionId, dbData.id); + await importSubmissionData(dbData); + } + // collect data to uploadSubmissionMap + if (dbData.status === SubmissionStatus.ACTIVE && + dbData.legacyUploadId != null + ) { const item = { - score: d['screening_score'] || d['initial_score'] || d['final_score'], - created: d['create_date'], - submissionId: d['submission_id'], + score: dbData.screeningScore || dbData.initialScore || dbData.finalScore, + created: dbData.createdAt, + submissionId: dbData.legacySubmissionId, }; - if (uploadSubmissionMap[d['upload_id']]) { - // existing submission info - const existing = uploadSubmissionMap[d['upload_id']]; - if (!existing.score || item.created > existing.created) { - uploadSubmissionMap[d['upload_id']] = item; + // pick the latest valid submission for each upload + if (uploadSubmissionMap[dbData.legacyUploadId]) { + const existing = uploadSubmissionMap[dbData.legacyUploadId]; + if (!existing.score || + item.created.getTime() > existing.created.getTime() + ) { + uploadSubmissionMap[dbData.legacyUploadId] = item; } } else { - uploadSubmissionMap[d['upload_id']] = item; + uploadSubmissionMap[dbData.legacyUploadId] = item; } } - } - } - console.log(`Submission total count: ${submissionCount}`); - // read upload files. Get {resource_id -> submission_id} map. - let uploadCount = 0; - const resourceSubmissionMap: Record = {}; - for (const f of uploadFiles) { - const filePath = path.join(DATA_DIR, f); - console.log(`Reading upload data from ${f}`); - const jsonData = readJson(filePath)['upload']; - for (const d of jsonData) { - if ( - d['upload_status_id'] === '1' && - d['upload_type_id'] === '1' && - d['resource_id'] - ) { - // get submission info - uploadCount += 1; - if (uploadSubmissionMap[d['upload_id']]) { - resourceSubmissionMap[d['resource_id']] = - uploadSubmissionMap[d['upload_id']]; - } + if (dataCount % logSize === 0) { + console.log(`Imported submission count: ${dataCount}`); } } + submissionTotalCount += dataCount; } - console.log(`Upload total count: ${uploadCount}`); + // import remaining submission data + await doImportSubmissionData(); + console.log(`Submission total count: ${submissionTotalCount}`); + + // build {resource_id -> submission} map + const resourceSubmissionMap = Object.entries(resourceUploadMap).reduce( + (acc, [resourceId, uploadId]) => { + const submission = uploadSubmissionMap[uploadId]; + if (submission) { + acc[resourceId] = submission; + } + return acc; + }, {} as Record + ); + // read resource files const challengeSubmissionMap: Record> = {}; let resourceCount = 0; @@ -515,7 +709,7 @@ async function initSubmissionMap() { Object.keys(submissionMap).forEach((c) => { totalSubmissions += Object.keys(submissionMap[c]).length; }); - console.log(`Found total submissions: ${totalSubmissions}`); + console.log(`Found total project result submissions: ${totalSubmissions}`); } // Process a single type: find matching files, transform them one by one, and then insert in batches. @@ -524,11 +718,7 @@ async function processType(type: string, subtype?: string) { const files = fs .readdirSync(DATA_DIR) .filter((file) => regex.test(file)) - .sort((a, b) => { - const numA = parseInt(a.match(/_(\d+)\.json$/)?.[1] || '0', 10); - const numB = parseInt(b.match(/_(\d+)\.json$/)?.[1] || '0', 10); - return numA - numB; - }); + .sort(filenameComp); if (files.length === 0) { console.log(`[${type}] No files found.`); return; @@ -843,15 +1033,16 @@ async function processType(type: string, subtype?: string) { const id = nanoid(14); reviewIdMap.set(review.review_id, id); return { - id: id, + id, legacyId: review.review_id, resourceId: review.resource_id, phaseId: review.project_phase_id, - submissionId: review.submission_id || '', + submissionId: submissionIdMap.get(review.submission_id) || null, + legacySubmissionId: review.submission_id, scorecardId: scorecardIdMap.get(review.scorecard_id), committed: review.committed === '1', - finalScore: parseFloat(review.score || '0.0'), - initialScore: parseFloat(review.initial_score || '0.0'), + finalScore: review.score ? parseFloat(review.score) : null, + initialScore: review.initial_score ? parseFloat(review.initial_score) : null, createdAt: new Date(review.create_date), createdBy: review.create_user, updatedAt: new Date(review.modify_date), @@ -1164,51 +1355,72 @@ async function processAllTypes() { } } -async function seed() { - // console.log('Start seeding ...'); - - // delete existing data - await prisma.reviewSummation.deleteMany({}); - await prisma.review.deleteMany({}); - await prisma.submission.deleteMany({}); - await prisma.scorecard.deleteMany({}); - await prisma.reviewType.deleteMany({}); - - await prisma.reviewType.createMany({ - data: reviewTypeData, - }); - console.log('Created reviewType data successfully'); - - const scorecardIns = await prisma.scorecard.create({ - data: scorecardData, - }); - console.log(`Created scorecard with id: ${scorecardIns.id}`); +function convertResourceSubmission(jsonData) { + return { + id: nanoid(14), + resourceId: jsonData['resource_id'], + legacySubmissionId: jsonData['submission_id'], + submissionId: submissionIdMap[jsonData['submission_id']] || null, + createdAt: new Date(jsonData['create_date']), + createdBy: jsonData['create_user'], + updatedAt: new Date(jsonData['modify_date']), + updatedBy: jsonData['modify_user'] + }; +} - const submissionIns = await prisma.submission.createManyAndReturn({ - data: submissionData, - }); - console.log('Created submission data successfully'); +let resourceSubmissions: any[] = []; +async function handleResourceSubmission(data) { + resourceSubmissions.push(data); + if (resourceSubmissions.length > batchSize) { + await doImportResourceSubmission(); + resourceSubmissions = []; + } +} - for (const rd of reviewData) { - const review = await prisma.review.create({ - data: { - ...rd, - submissionId: submissionIns[0].id, - scorecardId: scorecardIns.id, - }, +async function doImportResourceSubmission() { + try { + await prisma.resourceSubmission.createMany({ + data: resourceSubmissions }); - console.log(`Created review with id: ${review.id}`); + } catch { + for (const rs of resourceSubmissions) { + try { + await prisma.resourceSubmission.create({ + data: rs + }); + } catch { + console.error(`Failed to import resource_submission ${rs.resourceId}_${rs.legacySubmissionId}`); + } + } } +} - for (const rsd of reviewSummationData) { - const reviewSummation = await prisma.reviewSummation.create({ - data: { - ...rsd, - submissionId: submissionIns[0].id, - }, - }); - console.log(`Created review summationData with id: ${reviewSummation.id}`); +async function migrateResourceSubmissions() { + const filenameRegex = new RegExp(`^resource_submission_\\d+\\.json`); + const filenames = fs.readdirSync(DATA_DIR).filter(f => filenameRegex.test(f)); + filenames.sort(filenameComp); + // start importing data + let totalCount = 0; + for (const f of filenames) { + const filePath = path.join(DATA_DIR, f); + console.log(`Reading resource_submission data from ${f}`); + const jsonData = readJson(filePath)['resource_submission']; + let dataCount = 0; + for (const d of jsonData) { + dataCount += 1; + const data = convertResourceSubmission(d); + const key = `${data.resourceId}:${data.legacySubmissionId}`; + if (!resourceSubmissionSet.has(key)) { + resourceSubmissionSet.add(key); + await handleResourceSubmission(data); + } + if (dataCount % logSize === 0) { + console.log(`Imported resource_submission count: ${dataCount}`); + } + } + totalCount += dataCount; } + console.log(`resource_submission total count: ${totalCount}`); } async function migrate() { @@ -1216,23 +1428,29 @@ async function migrate() { processLookupFiles(); console.log('Lookup import completed.'); - // init resource-submission data - console.log('Starting resource/submission import...'); + // import upload and submision data, init {challengeId -> submission} map + console.log('Starting submission import...'); await initSubmissionMap(); - console.log('Resource/Submission import completed.'); - - console.log('Starting data import...'); + console.log('Submission import completed.'); + + console.log('Starting review import...'); await processAllTypes(); - console.log('Data import completed.'); + console.log('Review data import completed.'); + + // import Elastic Search data + console.log('Starting Elastic Search data migration...'); + await migrateElasticSearch(); + console.log('Elastic Search data imported.'); - // init submission data - console.log('Start seeding ...'); - await seed(); - console.log('Seeding finished.'); + // import resource_submission data + console.log('Starting importing resource-submissions...'); + await migrateResourceSubmissions(); + console.log('Resource-submissions import completed.'); } migrate() .then(async () => { + console.log('---------------- ALL DONE ----------------'); await prisma.$disconnect(); }) .catch(async (e) => { @@ -1276,6 +1494,8 @@ migrate() key: 'reviewItemCommentAppealResponseIdMap', value: reviewItemCommentAppealResponseIdMap, }, + { key: 'uploadIdMap', value: uploadIdMap, }, + { key: 'submissionIdMap', value: submissionIdMap }, ].forEach((f) => { if (!fs.existsSync('.tmp')) { fs.mkdirSync('.tmp'); @@ -1285,4 +1505,6 @@ migrate() JSON.stringify(Object.fromEntries(f.value)), ); }); + // write resourceSubmissionSet to file + fs.writeFileSync(rsSetFile, JSON.stringify([...resourceSubmissionSet])); }); diff --git a/prisma/migrations/20250724133600_add_upload/migration.sql b/prisma/migrations/20250724133600_add_upload/migration.sql new file mode 100644 index 0000000..a153750 --- /dev/null +++ b/prisma/migrations/20250724133600_add_upload/migration.sql @@ -0,0 +1,159 @@ +/* + Warnings: + + - You are about to alter the column `submissionId` on the `review` table. The data in that column could be lost. The data in that column will be cast from `Text` to `VarChar(14)`. + - You are about to alter the column `submissionId` on the `reviewSummation` table. The data in that column could be lost. The data in that column will be cast from `Text` to `VarChar(14)`. + - The primary key for the `submission` table will be changed. If it partially fails, the table could be left without primary key constraint. + - You are about to alter the column `id` on the `submission` table. The data in that column could be lost. The data in that column will be cast from `VarChar(36)` to `VarChar(14)`. + - Added the required column `status` to the `submission` table without a default value. This is not possible if the table is not empty. + - Changed the type of `type` on the `submission` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. + +*/ +-- CreateEnum +CREATE TYPE "UploadType" AS ENUM ('SUBMISSION', 'TEST_CASE', 'FINAL_FIX', 'REVIEW_DOCUMENT'); + +-- CreateEnum +CREATE TYPE "UploadStatus" AS ENUM ('ACTIVE', 'DELETED'); + +-- CreateEnum +CREATE TYPE "SubmissionType" AS ENUM ('CONTEST_SUBMISSION', 'SPECIFICATION_SUBMISSION', 'CHECKPOINT_SUBMISSION', 'STUDIO_FINAL_FIX_SUBMISSION'); + +-- CreateEnum +CREATE TYPE "SubmissionStatus" AS ENUM ('ACTIVE', 'FAILED_SCREENING', 'FAILED_REVIEW', 'COMPLETED_WITHOUT_WIN', 'DELETED', 'FAILED_CHECKPOINT_SCREENING', 'FAILED_CHECKPOINT_REVIEW'); + +-- DropForeignKey +ALTER TABLE "review" DROP CONSTRAINT "review_submissionId_fkey"; + +-- DropForeignKey +ALTER TABLE "reviewApplication" DROP CONSTRAINT "reviewApplication_opportunityId_fkey"; + +-- DropForeignKey +ALTER TABLE "reviewSummation" DROP CONSTRAINT "reviewSummation_submissionId_fkey"; + +-- DropIndex +DROP INDEX "review_id_idx"; + +-- DropIndex +DROP INDEX "reviewSummation_id_idx"; + +-- DropIndex +DROP INDEX "submission_id_idx"; + +-- AlterTable +ALTER TABLE "review" ADD COLUMN "legacySubmissionId" TEXT, +ALTER COLUMN "submissionId" DROP NOT NULL, +ALTER COLUMN "submissionId" SET DATA TYPE VARCHAR(14), +ALTER COLUMN "finalScore" DROP NOT NULL, +ALTER COLUMN "initialScore" DROP NOT NULL, +ALTER COLUMN "typeId" DROP NOT NULL, +ALTER COLUMN "status" DROP NOT NULL, +ALTER COLUMN "reviewDate" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "reviewApplication" ALTER COLUMN "opportunityId" SET DATA TYPE TEXT, +ALTER COLUMN "updatedAt" DROP DEFAULT; + +-- AlterTable +ALTER TABLE "reviewOpportunity" ALTER COLUMN "updatedAt" DROP DEFAULT; + +-- AlterTable +ALTER TABLE "reviewSummation" ADD COLUMN "legacySubmissionId" TEXT, +ADD COLUMN "scorecardLegacyId" TEXT, +ALTER COLUMN "submissionId" SET DATA TYPE VARCHAR(14), +ALTER COLUMN "scorecardId" DROP NOT NULL, +ALTER COLUMN "isFinal" DROP NOT NULL, +ALTER COLUMN "reviewedDate" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "submission" DROP CONSTRAINT "submission_pkey", +ADD COLUMN "esId" UUID, +ADD COLUMN "fileSize" INTEGER, +ADD COLUMN "fileType" TEXT, +ADD COLUMN "finalScore" DECIMAL(65,30), +ADD COLUMN "initialScore" DECIMAL(65,30), +ADD COLUMN "legacyChallengeId" BIGINT, +ADD COLUMN "markForPurchase" BOOLEAN, +ADD COLUMN "placement" INTEGER, +ADD COLUMN "prizeId" BIGINT, +ADD COLUMN "screeningScore" DECIMAL(65,30), +ADD COLUMN "status" "SubmissionStatus" NOT NULL, +ADD COLUMN "systemFileName" TEXT, +ADD COLUMN "thurgoodJobId" TEXT, +ADD COLUMN "uploadId" VARCHAR(14), +ADD COLUMN "userRank" INTEGER, +ADD COLUMN "viewCount" INTEGER, +ALTER COLUMN "id" SET DEFAULT nanoid(), +ALTER COLUMN "id" SET DATA TYPE VARCHAR(14), +DROP COLUMN "type", +ADD COLUMN "type" "SubmissionType" NOT NULL, +ALTER COLUMN "url" DROP NOT NULL, +ALTER COLUMN "memberId" DROP NOT NULL, +ALTER COLUMN "challengeId" DROP NOT NULL, +ALTER COLUMN "submittedDate" DROP NOT NULL, +ALTER COLUMN "updatedAt" DROP NOT NULL, +ALTER COLUMN "updatedBy" DROP NOT NULL, +ADD CONSTRAINT "submission_pkey" PRIMARY KEY ("id"); + +-- CreateTable +CREATE TABLE "upload" ( + "id" VARCHAR(14) NOT NULL DEFAULT nanoid(), + "legacyId" TEXT, + "projectId" TEXT NOT NULL, + "resourceId" TEXT NOT NULL, + "type" "UploadType" NOT NULL, + "status" "UploadStatus" NOT NULL, + "parameter" TEXT, + "url" TEXT, + "desc" TEXT, + "projectPhaseId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdBy" TEXT NOT NULL, + "updatedAt" TIMESTAMP(3), + "updatedBy" TEXT, + + CONSTRAINT "upload_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "resourceSubmission" ( + "id" VARCHAR(14) NOT NULL DEFAULT nanoid(), + "resourceId" TEXT NOT NULL, + "submissionId" TEXT, + "legacySubmissionId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "createdBy" TEXT NOT NULL, + "updatedAt" TIMESTAMP(3), + "updatedBy" TEXT, + + CONSTRAINT "resourceSubmission_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "upload_projectId_idx" ON "upload"("projectId"); + +-- CreateIndex +CREATE INDEX "upload_legacyId_idx" ON "upload"("legacyId"); + +-- CreateIndex +CREATE INDEX "submission_memberId_idx" ON "submission"("memberId"); + +-- CreateIndex +CREATE INDEX "submission_challengeId_idx" ON "submission"("challengeId"); + +-- CreateIndex +CREATE INDEX "submission_legacySubmissionId_idx" ON "submission"("legacySubmissionId"); + +-- AddForeignKey +ALTER TABLE "review" ADD CONSTRAINT "review_submissionId_fkey" FOREIGN KEY ("submissionId") REFERENCES "submission"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "reviewSummation" ADD CONSTRAINT "reviewSummation_submissionId_fkey" FOREIGN KEY ("submissionId") REFERENCES "submission"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "submission" ADD CONSTRAINT "submission_uploadId_fkey" FOREIGN KEY ("uploadId") REFERENCES "upload"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "reviewApplication" ADD CONSTRAINT "reviewApplication_opportunityId_fkey" FOREIGN KEY ("opportunityId") REFERENCES "reviewOpportunity"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "resourceSubmission" ADD CONSTRAINT "resourceSubmission_submissionId_fkey" FOREIGN KEY ("submissionId") REFERENCES "submission"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 14a1580..9225dd2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -144,28 +144,28 @@ model review { legacyId String? resourceId String phaseId String - submissionId String // Associated submission + submissionId String? @db.VarChar(14) // Associated submission + legacySubmissionId String? scorecardId String // Associated scorecard committed Boolean @default(false) - finalScore Float - initialScore Float - typeId String + finalScore Float? + initialScore Float? + typeId String? metadata Json? - status String - reviewDate DateTime + status String? + reviewDate DateTime? createdAt DateTime @default(now()) createdBy String updatedAt DateTime @updatedAt updatedBy String scorecard scorecard @relation(fields: [scorecardId], references: [id], onDelete: Cascade) - submission submission @relation(fields: [submissionId], references: [id], onDelete: Cascade) + submission submission? @relation(fields: [submissionId], references: [id], onDelete: Cascade) reviewItems reviewItem[] @@index([committed]) // Index for filtering by committed status @@index([submissionId]) // Index for filtering by submission @@index([resourceId]) // Index for filtering by resource (reviewer) - @@index([id]) // Index for direct ID lookups @@index([phaseId]) // Index for filtering by phase @@index([scorecardId]) // Index for joining with scorecard table } @@ -320,12 +320,14 @@ model reviewType { model reviewSummation { id String @id @default(dbgenerated("gen_random_uuid()")) @db.VarChar(36) - submissionId String // Associated submission + submissionId String @db.VarChar(14) // Associated submission + legacySubmissionId String? aggregateScore Float - scorecardId String + scorecardId String? + scorecardLegacyId String? isPassing Boolean - isFinal Boolean - reviewedDate DateTime + isFinal Boolean? + reviewedDate DateTime? createdAt DateTime @default(now()) createdBy String updatedAt DateTime @updatedAt @@ -333,30 +335,55 @@ model reviewSummation { submission submission @relation(fields: [submissionId], references: [id], onDelete: Cascade) - @@index([id]) // Index for direct ID lookups @@index([submissionId]) // Index for joining with submission table @@index([scorecardId]) // Index for joining with scorecard table } model submission { - id String @id @default(dbgenerated("gen_random_uuid()")) @db.VarChar(36) - type String - url String - memberId String - challengeId String + id String @id @default(dbgenerated("nanoid()")) @db.VarChar(14) + // informix data legacySubmissionId String? - legacyUploadId String? + type SubmissionType + status SubmissionStatus + screeningScore Decimal? + initialScore Decimal? + finalScore Decimal? + placement Int? + userRank Int? + markForPurchase Boolean? + prizeId BigInt? + fileSize Int? + viewCount Int? + systemFileName String? + thurgoodJobId String? + + // ES data + url String? + memberId String? + challengeId String? + legacyChallengeId BigInt? submissionPhaseId String? - submittedDate DateTime + fileType String? + esId String? @db.Uuid + submittedDate DateTime? + createdAt DateTime @default(now()) createdBy String - updatedAt DateTime @updatedAt - updatedBy String + updatedAt DateTime? @updatedAt + updatedBy String? + + // relation + legacyUploadId String? + uploadId String? @db.VarChar(14) + upload upload? @relation(fields: [uploadId], references: [id]) review review[] reviewSummation reviewSummation[] + resourceSubmissions resourceSubmission[] - @@index([id]) // Index for direct ID lookups + @@index([memberId]) + @@index([challengeId]) + @@index([legacySubmissionId]) } enum ReviewOpportunityStatus { @@ -438,3 +465,73 @@ model reviewApplication { @@index([userId]) @@index([opportunityId]) } + + +enum UploadType { + SUBMISSION + TEST_CASE + FINAL_FIX + REVIEW_DOCUMENT +} + +enum UploadStatus { + ACTIVE + DELETED +} + +enum SubmissionType { + CONTEST_SUBMISSION + SPECIFICATION_SUBMISSION + CHECKPOINT_SUBMISSION + STUDIO_FINAL_FIX_SUBMISSION +} + +enum SubmissionStatus { + ACTIVE + FAILED_SCREENING + FAILED_REVIEW + COMPLETED_WITHOUT_WIN + DELETED + FAILED_CHECKPOINT_SCREENING + FAILED_CHECKPOINT_REVIEW +} + + +model upload { + id String @id @default(dbgenerated("nanoid()")) @db.VarChar(14) + legacyId String? + projectId String + resourceId String + type UploadType + status UploadStatus + parameter String? + url String? + desc String? + projectPhaseId String? + + createdAt DateTime @default(now()) + createdBy String + updatedAt DateTime? @updatedAt + updatedBy String? + + submissions submission[] + + @@index([projectId]) + @@index([legacyId]) +} + + +model resourceSubmission { + id String @id @default(dbgenerated("nanoid()")) @db.VarChar(14) + resourceId String + submissionId String? + legacySubmissionId String? + + createdAt DateTime @default(now()) + createdBy String + updatedAt DateTime? @updatedAt + updatedBy String? + + submissions submission? @relation(fields: [submissionId], references: [id]) +} + diff --git a/src/api/api.module.ts b/src/api/api.module.ts index 40e9268..5087b43 100644 --- a/src/api/api.module.ts +++ b/src/api/api.module.ts @@ -18,6 +18,8 @@ import { ReviewOpportunityService } from './review-opportunity/reviewOpportunity import { ReviewApplicationService } from './review-application/reviewApplication.service'; import { ReviewHistoryController } from './review-history/reviewHistory.controller'; import { ChallengeApiService } from 'src/shared/modules/global/challenge.service'; +import { SubmissionService } from './submission/submission.service'; +import { ReviewSummationService } from './review-summation/review-summation.service'; @Module({ imports: [HttpModule, GlobalProvidersModule, FileUploadModule], @@ -35,6 +37,12 @@ import { ChallengeApiService } from 'src/shared/modules/global/challenge.service ReviewApplicationController, ReviewHistoryController ], - providers: [ReviewOpportunityService, ReviewApplicationService, ChallengeApiService], + providers: [ + ReviewOpportunityService, + ReviewApplicationService, + ChallengeApiService, + SubmissionService, + ReviewSummationService, + ], }) export class ApiModule {} diff --git a/src/api/review-summation/review-summation.controller.ts b/src/api/review-summation/review-summation.controller.ts index 186e5f8..3766718 100644 --- a/src/api/review-summation/review-summation.controller.ts +++ b/src/api/review-summation/review-summation.controller.ts @@ -8,10 +8,9 @@ import { Body, Param, Query, - NotFoundException, - InternalServerErrorException, HttpCode, HttpStatus, + Req, } from '@nestjs/common'; import { ApiOperation, @@ -33,11 +32,11 @@ import { ReviewSummationPutRequestDto, ReviewSummationUpdateRequestDto, } from 'src/dto/reviewSummation.dto'; -import { PrismaService } from '../../shared/modules/global/prisma.service'; import { LoggerService } from '../../shared/modules/global/logger.service'; import { PaginatedResponse, PaginationDto } from '../../dto/pagination.dto'; import { SortDto } from '../../dto/sort.dto'; -import { PrismaErrorService } from '../../shared/modules/global/prisma-error.service'; +import { ReviewSummationService } from './review-summation.service'; +import { JwtUser } from 'src/shared/modules/global/jwt.service'; @ApiTags('ReviewSummations') @ApiBearerAuth() @@ -46,10 +45,9 @@ export class ReviewSummationController { private readonly logger: LoggerService; constructor( - private readonly prisma: PrismaService, - private readonly prismaErrorService: PrismaErrorService, + private readonly service: ReviewSummationService, ) { - this.logger = LoggerService.forRoot('ReviewSummationController'); + this.logger = LoggerService.forRoot(ReviewSummationController.name); } @Post() @@ -59,34 +57,21 @@ export class ReviewSummationController { summary: 'Create a new review summation', description: 'Roles: Admin, Copilot | Scopes: create:review_summation', }) - @ApiBody({ description: 'Review type data', type: ReviewSummationRequestDto }) + @ApiBody({ description: 'Review summation data', type: ReviewSummationRequestDto }) @ApiResponse({ status: 201, - description: 'Review type created successfully.', + description: 'Review summation created successfully.', type: ReviewSummationResponseDto, }) async createReviewSummation( + @Req() req: Request, @Body() body: ReviewSummationRequestDto, ): Promise { this.logger.log( `Creating review summation with request boy: ${JSON.stringify(body)}`, ); - try { - const data = await this.prisma.reviewSummation.create({ - data: body, - }); - this.logger.log(`Review type created with ID: ${data.id}`); - return data as ReviewSummationResponseDto; - } catch (error) { - const errorResponse = this.prismaErrorService.handleError( - error, - 'creating review summation', - ); - throw new InternalServerErrorException({ - message: errorResponse.message, - code: errorResponse.code, - }); - } + const authUser: JwtUser = req['user'] as JwtUser; + return this.service.createSummation(authUser, body); } @Patch('/:reviewSummationId') @@ -111,10 +96,12 @@ export class ReviewSummationController { }) @ApiResponse({ status: 404, description: 'Review type not found.' }) async patchReviewSummation( + @Req() req: Request, @Param('reviewSummationId') reviewSummationId: string, @Body() body: ReviewSummationUpdateRequestDto, ): Promise { - return this._updateReviewSummation(reviewSummationId, body); + const authUser: JwtUser = req['user'] as JwtUser; + return this.service.updateSummation(authUser, reviewSummationId, body); } @Put('/:reviewSummationId') @@ -139,34 +126,12 @@ export class ReviewSummationController { }) @ApiResponse({ status: 404, description: 'Review type not found.' }) async updateReviewSummation( + @Req() req: Request, @Param('reviewSummationId') reviewSummationId: string, @Body() body: ReviewSummationPutRequestDto, ): Promise { - return this._updateReviewSummation(reviewSummationId, body); - } - - /** - * The inner update method for entity - */ - async _updateReviewSummation( - reviewSummationId: string, - body: ReviewSummationUpdateRequestDto, - ): Promise { - this.logger.log(`Updating review summation with ID: ${reviewSummationId}`); - try { - const data = await this.prisma.reviewSummation.update({ - where: { id: reviewSummationId }, - data: body, - }); - this.logger.log(`Review type updated successfully: ${reviewSummationId}`); - return data as ReviewSummationResponseDto; - } catch (error) { - throw this._rethrowError( - error, - reviewSummationId, - `updating review summation ${reviewSummationId}`, - ); - } + const authUser: JwtUser = req['user'] as JwtUser; + return this.service.updateSummation(authUser, reviewSummationId, body); } @Get() @@ -189,80 +154,7 @@ export class ReviewSummationController { this.logger.log( `Getting review summations with filters - ${JSON.stringify(queryDto)}`, ); - - const { page = 1, perPage = 10 } = paginationDto || {}; - const skip = (page - 1) * perPage; - let orderBy; - - if (sortDto && sortDto.orderBy && sortDto.sortBy) { - orderBy = { - [sortDto.sortBy]: sortDto.orderBy.toLowerCase(), - }; - } - - try { - // Build the where clause for review summations based on available filter parameters - const reviewSummationWhereClause: any = {}; - if (queryDto.submissionId) { - reviewSummationWhereClause.submissionId = queryDto.submissionId; - } - if (queryDto.aggregateScore) { - reviewSummationWhereClause.aggregateScore = parseFloat( - queryDto.aggregateScore, - ); - } - if (queryDto.scorecardId) { - reviewSummationWhereClause.scorecardId = queryDto.scorecardId; - } - if (queryDto.isPassing !== undefined) { - reviewSummationWhereClause.isPassing = - queryDto.isPassing.toLowerCase() === 'true'; - } - if (queryDto.isFinal !== undefined) { - reviewSummationWhereClause.isFinal = - queryDto.isFinal.toLowerCase() === 'true'; - } - - // find entities by filters - const reviewSummations = await this.prisma.reviewSummation.findMany({ - where: { - ...reviewSummationWhereClause, - }, - skip, - take: perPage, - orderBy, - }); - - // Count total entities matching the filter for pagination metadata - const totalCount = await this.prisma.reviewSummation.count({ - where: { - ...reviewSummationWhereClause, - }, - }); - - this.logger.log( - `Found ${reviewSummations.length} review summations (page ${page} of ${Math.ceil(totalCount / perPage)})`, - ); - - return { - data: reviewSummations as ReviewSummationResponseDto[], - meta: { - page, - perPage, - totalCount, - totalPages: Math.ceil(totalCount / perPage), - }, - }; - } catch (error) { - const errorResponse = this.prismaErrorService.handleError( - error, - 'fetching review summations', - ); - throw new InternalServerErrorException({ - message: errorResponse.message, - code: errorResponse.code, - }); - } + return this.service.searchSummation(queryDto, paginationDto, sortDto); } @Get('/:reviewSummationId') @@ -286,20 +178,7 @@ export class ReviewSummationController { @Param('reviewSummationId') reviewSummationId: string, ): Promise { this.logger.log(`Getting review summation with ID: ${reviewSummationId}`); - try { - const data = await this.prisma.reviewSummation.findUniqueOrThrow({ - where: { id: reviewSummationId }, - }); - - this.logger.log(`Review summation found: ${reviewSummationId}`); - return data as ReviewSummationResponseDto; - } catch (error) { - throw this._rethrowError( - error, - reviewSummationId, - `fetching review summation ${reviewSummationId}`, - ); - } + return this.service.getSummation(reviewSummationId); } @Delete('/:reviewSummationId') @@ -324,39 +203,9 @@ export class ReviewSummationController { @Param('reviewSummationId') reviewSummationId: string, ) { this.logger.log(`Deleting review summation with ID: ${reviewSummationId}`); - try { - await this.prisma.reviewSummation.delete({ - where: { id: reviewSummationId }, - }); - this.logger.log(`Review type deleted successfully: ${reviewSummationId}`); - return { - message: `Review type ${reviewSummationId} deleted successfully.`, - }; - } catch (error) { - throw this._rethrowError( - error, - reviewSummationId, - `deleting review summation ${reviewSummationId}`, - ); - } - } - - /** - * Build exception by error code - */ - _rethrowError(error: any, reviewSummationId: string, message: string) { - const errorResponse = this.prismaErrorService.handleError(error, message); - - if (errorResponse.code === 'RECORD_NOT_FOUND') { - return new NotFoundException({ - message: `Review type with ID ${reviewSummationId} was not found`, - code: errorResponse.code, - }); - } - - return new InternalServerErrorException({ - message: errorResponse.message, - code: errorResponse.code, - }); + await this.service.deleteSummation(reviewSummationId); + return { + message: `Review type ${reviewSummationId} deleted successfully.`, + }; } } diff --git a/src/api/review-summation/review-summation.service.ts b/src/api/review-summation/review-summation.service.ts new file mode 100644 index 0000000..bae433b --- /dev/null +++ b/src/api/review-summation/review-summation.service.ts @@ -0,0 +1,136 @@ +import { Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { PaginationDto } from "src/dto/pagination.dto"; +import { ReviewSummationQueryDto, ReviewSummationRequestDto, ReviewSummationResponseDto, ReviewSummationUpdateRequestDto } from "src/dto/reviewSummation.dto"; +import { SortDto } from "src/dto/sort.dto"; +import { JwtUser } from "src/shared/modules/global/jwt.service"; +import { PrismaService } from "src/shared/modules/global/prisma.service"; + + + +@Injectable() +export class ReviewSummationService { + private readonly logger = new Logger(ReviewSummationService.name); + + constructor(private readonly prisma: PrismaService) {} + + async createSummation(authUser: JwtUser, body: ReviewSummationRequestDto) { + 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}`); + return data as ReviewSummationResponseDto; + } + + async searchSummation( + queryDto: ReviewSummationQueryDto, + paginationDto?: PaginationDto, + sortDto?: SortDto, + ) { + const { page = 1, perPage = 10 } = paginationDto || {}; + const skip = (page - 1) * perPage; + let orderBy; + + if (sortDto && sortDto.orderBy && sortDto.sortBy) { + orderBy = { + [sortDto.sortBy]: sortDto.orderBy.toLowerCase(), + }; + } + + // Build the where clause for review summations based on available filter parameters + const reviewSummationWhereClause: any = {}; + if (queryDto.submissionId) { + reviewSummationWhereClause.submissionId = queryDto.submissionId; + } + if (queryDto.aggregateScore) { + reviewSummationWhereClause.aggregateScore = parseFloat( + queryDto.aggregateScore, + ); + } + if (queryDto.scorecardId) { + reviewSummationWhereClause.scorecardId = queryDto.scorecardId; + } + if (queryDto.isPassing !== undefined) { + reviewSummationWhereClause.isPassing = + queryDto.isPassing.toLowerCase() === 'true'; + } + if (queryDto.isFinal !== undefined) { + reviewSummationWhereClause.isFinal = + queryDto.isFinal.toLowerCase() === 'true'; + } + + // find entities by filters + const reviewSummations = await this.prisma.reviewSummation.findMany({ + where: { + ...reviewSummationWhereClause, + }, + skip, + take: perPage, + orderBy, + }); + + // Count total entities matching the filter for pagination metadata + const totalCount = await this.prisma.reviewSummation.count({ + where: { + ...reviewSummationWhereClause, + }, + }); + + this.logger.log( + `Found ${reviewSummations.length} review summations (page ${page} of ${Math.ceil(totalCount / perPage)})`, + ); + + return { + data: reviewSummations as ReviewSummationResponseDto[], + meta: { + page, + perPage, + totalCount, + totalPages: Math.ceil(totalCount / perPage), + }, + }; + } + + async getSummation(id: string) { + return this.checkSummation(id); + } + + async updateSummation( + authUser: JwtUser, + id: string, + body: ReviewSummationUpdateRequestDto + ) { + await this.checkSummation(id); + const data = await this.prisma.reviewSummation.update({ + where: { id }, + data: { + ...body, + updatedBy: String(authUser.userId) || '', + updatedAt: new Date() + } + }); + this.logger.log(`Review type updated successfully: ${id}`); + return data as ReviewSummationResponseDto; + } + + async deleteSummation(id: string) { + await this.checkSummation(id); + await this.prisma.reviewSummation.delete({ + where: { id } + }); + } + + private async checkSummation(id: string) { + const data = await this.prisma.reviewSummation.findUnique({ + where: { id } + }); + if (!data || !data.id) { + throw new NotFoundException(`Review summation not found with id ${id}`); + } + return data; + } +} diff --git a/src/api/submission/submission.controller.ts b/src/api/submission/submission.controller.ts index 2e215c4..721c1c4 100644 --- a/src/api/submission/submission.controller.ts +++ b/src/api/submission/submission.controller.ts @@ -8,13 +8,12 @@ import { Body, Param, Query, - NotFoundException, - InternalServerErrorException, UseInterceptors, UploadedFile, StreamableFile, HttpCode, HttpStatus, + Req, } from '@nestjs/common'; import { ApiOperation, @@ -45,11 +44,11 @@ import { ArtifactsCreateResponseDto, ArtifactsListResponseDto, } from 'src/dto/artifacts.dto'; -import { PrismaService } from '../../shared/modules/global/prisma.service'; import { LoggerService } from '../../shared/modules/global/logger.service'; import { PaginatedResponse, PaginationDto } from '../../dto/pagination.dto'; import { SortDto } from '../../dto/sort.dto'; -import { PrismaErrorService } from '../../shared/modules/global/prisma-error.service'; +import { SubmissionService } from './submission.service'; +import { JwtUser } from 'src/shared/modules/global/jwt.service'; @ApiTags('Submissions') @ApiBearerAuth() @@ -58,8 +57,7 @@ export class SubmissionController { private readonly logger: LoggerService; constructor( - private readonly prisma: PrismaService, - private readonly prismaErrorService: PrismaErrorService, + private readonly service: SubmissionService, ) { this.logger = LoggerService.forRoot('SubmissionController'); } @@ -84,27 +82,14 @@ export class SubmissionController { type: SubmissionResponseDto, }) async createSubmission( + @Req() req: Request, @Body() body: SubmissionRequestDto, ): Promise { this.logger.log( `Creating submission with request boy: ${JSON.stringify(body)}`, ); - try { - const data = await this.prisma.submission.create({ - data: body, - }); - this.logger.log(`Submission created with ID: ${data.id}`); - return data as SubmissionResponseDto; - } catch (error) { - const errorResponse = this.prismaErrorService.handleError( - error, - 'creating submission', - ); - throw new InternalServerErrorException({ - message: errorResponse.message, - code: errorResponse.code, - }); - } + const authUser: JwtUser = req['user'] as JwtUser; + return this.service.createSubmission(authUser, body); } @Patch('/:submissionId') @@ -126,10 +111,12 @@ export class SubmissionController { }) @ApiResponse({ status: 404, description: 'Submission not found.' }) async patchSubmission( + @Req() req: Request, @Param('submissionId') submissionId: string, @Body() body: SubmissionUpdateRequestDto, ): Promise { - return this._updateSubmission(submissionId, body); + const authUser: JwtUser = req['user'] as JwtUser; + return this.service.updateSubmission(authUser, submissionId, body); } @Put('/:submissionId') @@ -151,34 +138,12 @@ export class SubmissionController { }) @ApiResponse({ status: 404, description: 'Submission not found.' }) async updateSubmission( + @Req() req: Request, @Param('submissionId') submissionId: string, @Body() body: SubmissionPutRequestDto, ): Promise { - return this._updateSubmission(submissionId, body); - } - - /** - * The inner update method for entity - */ - async _updateSubmission( - submissionId: string, - body: SubmissionUpdateRequestDto, - ): Promise { - this.logger.log(`Updating submission with ID: ${submissionId}`); - try { - const data = await this.prisma.submission.update({ - where: { id: submissionId }, - data: body, - }); - this.logger.log(`Submission updated successfully: ${submissionId}`); - return data as SubmissionResponseDto; - } catch (error) { - throw this._rethrowError( - error, - submissionId, - `updating submission ${submissionId}`, - ); - } + const authUser: JwtUser = req['user'] as JwtUser; + return this.service.updateSubmission(authUser, submissionId, body); } @Get() @@ -207,83 +172,7 @@ export class SubmissionController { this.logger.log( `Getting submissions with filters - ${JSON.stringify(queryDto)}`, ); - - const { page = 1, perPage = 10 } = paginationDto || {}; - const skip = (page - 1) * perPage; - let orderBy; - - if (sortDto && sortDto.orderBy && sortDto.sortBy) { - orderBy = { - [sortDto.sortBy]: sortDto.orderBy.toLowerCase(), - }; - } - - try { - // Build the where clause for submissions based on available filter parameters - const submissionWhereClause: any = {}; - if (queryDto.type) { - submissionWhereClause.type = queryDto.type; - } - if (queryDto.url) { - submissionWhereClause.url = queryDto.url; - } - if (queryDto.challengeId) { - submissionWhereClause.challengeId = queryDto.challengeId; - } - if (queryDto.legacySubmissionId) { - submissionWhereClause.legacySubmissionId = queryDto.legacySubmissionId; - } - if (queryDto.legacyUploadId) { - submissionWhereClause.legacyUploadId = queryDto.legacyUploadId; - } - if (queryDto.submissionPhaseId) { - submissionWhereClause.submissionPhaseId = queryDto.submissionPhaseId; - } - - // find entities by filters - const submissions = await this.prisma.submission.findMany({ - where: { - ...submissionWhereClause, - }, - include: { - review: {}, - reviewSummation: {}, - }, - skip, - take: perPage, - orderBy, - }); - - // Count total entities matching the filter for pagination metadata - const totalCount = await this.prisma.submission.count({ - where: { - ...submissionWhereClause, - }, - }); - - this.logger.log( - `Found ${submissions.length} submissions (page ${page} of ${Math.ceil(totalCount / perPage)})`, - ); - - return { - data: submissions as SubmissionResponseDto[], - meta: { - page, - perPage, - totalCount, - totalPages: Math.ceil(totalCount / perPage), - }, - }; - } catch (error) { - const errorResponse = this.prismaErrorService.handleError( - error, - 'fetching submissions', - ); - throw new InternalServerErrorException({ - message: errorResponse.message, - code: errorResponse.code, - }); - } + return this.service.listSubmission(queryDto, paginationDto, sortDto); } @Get('/:submissionId') @@ -313,24 +202,7 @@ export class SubmissionController { @Param('submissionId') submissionId: string, ): Promise { this.logger.log(`Getting submission with ID: ${submissionId}`); - try { - const data = await this.prisma.submission.findUniqueOrThrow({ - where: { id: submissionId }, - include: { - review: {}, - reviewSummation: {}, - }, - }); - - this.logger.log(`Review type found: ${submissionId}`); - return data as SubmissionResponseDto; - } catch (error) { - throw this._rethrowError( - error, - submissionId, - `fetching submission ${submissionId}`, - ); - } + return this.service.getSubmission(submissionId); } @Delete('/:submissionId') @@ -359,19 +231,8 @@ export class SubmissionController { @ApiResponse({ status: 404, description: 'Submission not found.' }) async deleteSubmission(@Param('submissionId') submissionId: string) { this.logger.log(`Deleting review type with ID: ${submissionId}`); - try { - await this.prisma.submission.delete({ - where: { id: submissionId }, - }); - this.logger.log(`Submission deleted successfully: ${submissionId}`); - return { message: `Submission ${submissionId} deleted successfully.` }; - } catch (error) { - throw this._rethrowError( - error, - submissionId, - `deleting submission ${submissionId}`, - ); - } + await this.service.deleteSubmission(submissionId); + return { message: `Submission ${submissionId} deleted successfully.` }; } @Get('/:submissionId/download') @@ -416,25 +277,6 @@ export class SubmissionController { ); } - /** - * Build exception by error code - */ - _rethrowError(error: any, submissionId: string, message: string) { - const errorResponse = this.prismaErrorService.handleError(error, message); - - if (errorResponse.code === 'RECORD_NOT_FOUND') { - return new NotFoundException({ - message: `Review type with ID ${submissionId} was not found`, - code: errorResponse.code, - }); - } - - return new InternalServerErrorException({ - message: errorResponse.message, - code: errorResponse.code, - }); - } - @Post('/:submissionId/artifacts') @Roles( UserRole.Admin, diff --git a/src/api/submission/submission.service.ts b/src/api/submission/submission.service.ts new file mode 100644 index 0000000..4a3dba0 --- /dev/null +++ b/src/api/submission/submission.service.ts @@ -0,0 +1,161 @@ +import { Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { SubmissionStatus, SubmissionType } from "@prisma/client"; +import { PaginationDto } from "src/dto/pagination.dto"; +import { ReviewResponseDto } from "src/dto/review.dto"; +import { SortDto } from "src/dto/sort.dto"; +import { SubmissionQueryDto, SubmissionRequestDto, SubmissionResponseDto, SubmissionUpdateRequestDto } from "src/dto/submission.dto"; +import { JwtUser } from "src/shared/modules/global/jwt.service"; +import { PrismaService } from "src/shared/modules/global/prisma.service"; +import { Utils } from "src/shared/modules/global/utils.service"; + + +@Injectable() +export class SubmissionService { + private readonly logger = new Logger(SubmissionService.name); + + constructor(private readonly prisma: PrismaService) {} + + async createSubmission(authUser: JwtUser, body: SubmissionRequestDto) { + const data = await this.prisma.submission.create({ + data: { + ...body, + status: SubmissionStatus.ACTIVE, + type: body.type as SubmissionType, + createdBy: String(authUser.userId) || '', + createdAt: new Date() + }, + }); + this.logger.log(`Submission created with ID: ${data.id}`); + return this.buildResponse(data); + } + + async listSubmission( + queryDto: SubmissionQueryDto, + paginationDto?: PaginationDto, + sortDto?: SortDto, + ) { + const { page = 1, perPage = 10 } = paginationDto || {}; + const skip = (page - 1) * perPage; + let orderBy; + + if (sortDto && sortDto.orderBy && sortDto.sortBy) { + orderBy = { + [sortDto.sortBy]: sortDto.orderBy.toLowerCase(), + }; + } + + // Build the where clause for submissions based on available filter parameters + const submissionWhereClause: any = {}; + if (queryDto.type) { + submissionWhereClause.type = queryDto.type; + } + if (queryDto.url) { + submissionWhereClause.url = queryDto.url; + } + if (queryDto.challengeId) { + submissionWhereClause.challengeId = queryDto.challengeId; + } + if (queryDto.legacySubmissionId) { + submissionWhereClause.legacySubmissionId = queryDto.legacySubmissionId; + } + if (queryDto.legacyUploadId) { + submissionWhereClause.legacyUploadId = queryDto.legacyUploadId; + } + if (queryDto.submissionPhaseId) { + submissionWhereClause.submissionPhaseId = queryDto.submissionPhaseId; + } + + // find entities by filters + const submissions = await this.prisma.submission.findMany({ + where: { + ...submissionWhereClause, + }, + include: { + review: {}, + reviewSummation: {}, + }, + skip, + take: perPage, + orderBy, + }); + + // Count total entities matching the filter for pagination metadata + const totalCount = await this.prisma.submission.count({ + where: { + ...submissionWhereClause, + }, + }); + + this.logger.log( + `Found ${submissions.length} submissions (page ${page} of ${Math.ceil(totalCount / perPage)})`, + ); + + return { + data: submissions.map(this.buildResponse), + meta: { + page, + perPage, + totalCount, + totalPages: Math.ceil(totalCount / perPage), + }, + }; + } + + async getSubmission(submissionId: string): Promise { + const data = await this.checkSubmission(submissionId); + return this.buildResponse(data); + } + + async updateSubmission( + authUser: JwtUser, + submissionId: string, + body: SubmissionUpdateRequestDto + ) { + const existing = await this.checkSubmission(submissionId); + const data = await this.prisma.submission.update({ + where: { id: submissionId }, + data: { + ...body, + type: body.type as SubmissionType || existing.type, + updatedBy: String(authUser.userId) || '', + updatedAt: new Date() + } + }); + this.logger.log(`Submission updated successfully: ${submissionId}`); + return this.buildResponse(data); + } + + async deleteSubmission(id: string) { + await this.checkSubmission(id); + await this.prisma.submission.delete({ + where: { id } + }); + } + + + private async checkSubmission(id: string) { + const data = await this.prisma.submission.findUnique({ + where: { id }, + include: { review: true, reviewSummation: true }, + }); + if (!data || !data.id) { + throw new NotFoundException(`Submission with id ${id} not found`); + } + return data; + } + + private buildResponse(data): SubmissionResponseDto { + const dto = { + ...data, + legacyChallengeId: Utils.bigIntToNumber(data.legacyChallengeId), + prizeId: Utils.bigIntToNumber(data.prizeId), + }; + if (data.review) { + dto.review = data.review as ReviewResponseDto[]; + } + if (data.reviewSummation) { + dto.reviewSummation = data.reviewSummation; + } + return dto; + } +} diff --git a/src/dto/reviewSummation.dto.ts b/src/dto/reviewSummation.dto.ts index e07e246..6efa18a 100644 --- a/src/dto/reviewSummation.dto.ts +++ b/src/dto/reviewSummation.dto.ts @@ -74,9 +74,10 @@ export class ReviewSummationBaseRequestDto { description: 'The scorecard id', example: 'd24d4180-65aa-42ec-a945-5fd21dec0501', }) + @IsOptional() @IsString() @IsNotEmpty() - scorecardId: string; + scorecardId?: string; @ApiProperty({ description: 'The isPassing flag for review summation', @@ -87,43 +88,25 @@ export class ReviewSummationBaseRequestDto { @ApiProperty({ description: 'The isFinal flag for review summation', }) + @IsOptional() @IsBoolean() - isFinal: boolean; + isFinal?: boolean; @ApiProperty({ description: 'The reviewed date', example: '2024-10-01T00:00:00Z', }) + @IsOptional() @IsDateString() - reviewedDate: string; + reviewedDate?: string; } export class ReviewSummationRequestDto extends ReviewSummationBaseRequestDto { - @ApiProperty({ - description: 'The user who created the review summation', - example: 'user123', - }) - @IsString() - @IsNotEmpty() - createdBy: string; - @ApiProperty({ - description: 'The user who last updated the review summation', - example: 'user456', - }) - @IsString() - @IsNotEmpty() - updatedBy: string; } export class ReviewSummationPutRequestDto extends ReviewSummationBaseRequestDto { - @ApiProperty({ - description: 'The user who last updated the review summation', - example: 'user456', - }) - @IsString() - @IsNotEmpty() - updatedBy: string; + } export class ReviewSummationUpdateRequestDto { @@ -179,14 +162,6 @@ export class ReviewSummationUpdateRequestDto { @IsOptional() @IsDateString() reviewedDate?: string; - - @ApiProperty({ - description: 'The user who last updated the submission', - example: 'user456', - }) - @IsString() - @IsNotEmpty() - updatedBy: string; } export class ReviewSummationResponseDto { @@ -212,7 +187,7 @@ export class ReviewSummationResponseDto { description: 'The scorecard id', example: 'd24d4180-65aa-42ec-a945-5fd21dec0501', }) - scorecardId: string; + scorecardId: string | null; @ApiProperty({ description: 'The isPassing flag for review summation', @@ -222,13 +197,13 @@ export class ReviewSummationResponseDto { @ApiProperty({ description: 'The isFinal flag for review summation', }) - isFinal: boolean; + isFinal: boolean | null; @ApiProperty({ description: 'The reviewed date', example: '2024-10-01T00:00:00Z', }) - reviewedDate: Date; + reviewedDate: Date | null; @ApiProperty({ description: 'The creation timestamp', @@ -246,11 +221,11 @@ export class ReviewSummationResponseDto { description: 'The last update timestamp', example: '2023-10-01T00:00:00Z', }) - updatedAt: Date; + updatedAt: Date | null; @ApiProperty({ description: 'The user who last updated the review summation', example: 'user456', }) - updatedBy: string; + updatedBy: string | null; } diff --git a/src/dto/submission.dto.ts b/src/dto/submission.dto.ts index df7f0c4..c212d31 100644 --- a/src/dto/submission.dto.ts +++ b/src/dto/submission.dto.ts @@ -4,10 +4,28 @@ import { IsNotEmpty, IsOptional, IsDateString, + IsIn, } from 'class-validator'; import { ReviewResponseDto } from './review.dto'; +export enum SubmissionType { + CONTEST_SUBMISSION = 'CONTEST_SUBMISSION', + SPECIFICATION_SUBMISSION = 'SPECIFICATION_SUBMISSION', + CHECKPOINT_SUBMISSION = 'CHECKPOINT_SUBMISSION', + STUDIO_FINAL_FIX_SUBMISSION = 'STUDIO_FINAL_FIX_SUBMISSION', +} + +export enum SubmissionStatus { + ACTIVE = 'ACTIVE', + FAILED_SCREENING = 'FAILED_SCREENING', + FAILED_REVIEW = 'FAILED_REVIEW', + COMPLETED_WITHOUT_WIN = 'COMPLETED_WITHOUT_WIN', + DELETED = 'DELETED', + FAILED_CHECKPOINT_SCREENING = 'FAILED_CHECKPOINT_SCREENING', + FAILED_CHECKPOINT_REVIEW = 'FAILED_CHECKPOINT_REVIEW', +} + export class SubmissionQueryDto { @ApiProperty({ name: 'type', @@ -74,31 +92,36 @@ export class SubmissionRequestBaseDto { @ApiProperty({ description: 'The submission type', example: 'ContestSubmission', + enum: Object.values(SubmissionType) }) @IsString() @IsNotEmpty() + @IsIn(Object.values(SubmissionType)) type: string; @ApiProperty({ description: 'The submission url', }) + @IsOptional() @IsString() @IsNotEmpty() - url: string; + url?: string; @ApiProperty({ description: 'The member id', }) + @IsOptional() @IsString() @IsNotEmpty() - memberId: string; + memberId?: string; @ApiProperty({ description: 'The challenge id', }) + @IsOptional() @IsString() @IsNotEmpty() - challengeId: string; + challengeId?: string; @ApiProperty({ description: 'The legacy submission id', @@ -128,47 +151,30 @@ export class SubmissionRequestBaseDto { description: 'The submitted date', example: '2024-10-01T00:00:00Z', }) + @IsOptional() @IsDateString() - submittedDate: string; + submittedDate?: string; } export class SubmissionRequestDto extends SubmissionRequestBaseDto { - @ApiProperty({ - description: 'The user who created the submission', - example: 'user123', - }) - @IsString() - @IsNotEmpty() - createdBy: string; - @ApiProperty({ - description: 'The user who last updated the submission', - example: 'user456', - }) - @IsString() - @IsNotEmpty() - updatedBy: string; } export class SubmissionPutRequestDto extends SubmissionRequestBaseDto { - @ApiProperty({ - description: 'The user who last updated the submission', - example: 'user456', - }) - @IsString() - @IsNotEmpty() - updatedBy: string; + } export class SubmissionUpdateRequestDto { @ApiProperty({ description: 'The submission type', example: 'ContestSubmission', + enum: Object.values(SubmissionType), required: false, }) @IsOptional() @IsString() @IsNotEmpty() + @IsIn(Object.values(SubmissionType)) type?: string; @ApiProperty({ @@ -232,20 +238,12 @@ export class SubmissionUpdateRequestDto { @IsOptional() @IsDateString() submittedDate?: string; - - @ApiProperty({ - description: 'The user who last updated the submission', - example: 'user456', - }) - @IsString() - @IsNotEmpty() - updatedBy: string; } export class SubmissionResponseDto { @ApiProperty({ description: 'The ID of the submission', - example: 'c56a4180-65aa-42ec-a945-5fd21dec0501', + example: 'CbgrlhpRMzh6j-', }) id: string; @@ -258,37 +256,47 @@ export class SubmissionResponseDto { @ApiProperty({ description: 'The submission url', }) - url: string; + url: string | null; @ApiProperty({ description: 'The member id', }) - memberId: string; + memberId: string | null; @ApiProperty({ description: 'The challenge id', }) - challengeId: string; + challengeId: string | null; @ApiProperty({ description: 'The legacy submission id', }) - legacySubmissionId?: string; + legacySubmissionId?: string | null; @ApiProperty({ description: 'The legacy upload id', }) - legacyUploadId?: string; + legacyUploadId?: string | null; @ApiProperty({ description: 'The submission phase id', }) - submissionPhaseId?: string; + submissionPhaseId?: string | null; @ApiProperty({ description: 'The submitted date', }) - submittedDate: Date; + submittedDate: Date | null; + + @ApiProperty({ + description: 'Legacy challenge id', + }) + legacyChallengeId?: number | null; + + @ApiProperty({ + description: 'prize id', + }) + prizeId?: number | null; @ApiProperty({ description: 'The creation timestamp', @@ -306,13 +314,13 @@ export class SubmissionResponseDto { description: 'The last update timestamp', example: '2023-10-01T00:00:00Z', }) - updatedAt: Date; + updatedAt: Date | null; @ApiProperty({ description: 'The user who last updated the submission', example: 'user456', }) - updatedBy: string; + updatedBy: string | null; review?: ReviewResponseDto[]; reviewSummation?: any[]; diff --git a/src/dto/upload.dto.ts b/src/dto/upload.dto.ts new file mode 100644 index 0000000..58cf287 --- /dev/null +++ b/src/dto/upload.dto.ts @@ -0,0 +1,12 @@ + +export enum UploadStatus { + ACTIVE = 'ACTIVE', + DELETED = 'DELETED' +} + +export enum UploadType { + SUBMISSION = 'SUBMISSION', + TEST_CASE = 'TEST_CASE', + FINAL_FIX = 'FINAL_FIX', + REVIEW_DOCUMENT = 'REVIEW_DOCUMENT' +} diff --git a/src/shared/modules/global/utils.service.ts b/src/shared/modules/global/utils.service.ts new file mode 100644 index 0000000..d8c560f --- /dev/null +++ b/src/shared/modules/global/utils.service.ts @@ -0,0 +1,8 @@ + +export class Utils { + private constructor() {} + + static bigIntToNumber(t) { + return t ? Number(t) : null; + } +}; From 2c9d32f52b95294908cd20a9251be219251b0c89 Mon Sep 17 00:00:00 2001 From: billsedison Date: Tue, 5 Aug 2025 20:55:27 +0800 Subject: [PATCH 3/5] Update --- .env.sample | 33 ++ Dockerfile | 1 + docker-compose.kafka.yml | 54 +++ docs/GITEA_WEBHOOK_SETUP.md | 316 ++++++++++++++++++ docs/KAFKA_SETUP.md | 188 +++++++++++ M2M_TOKEN_GUIDE.md => docs/M2M_TOKEN_GUIDE.md | 0 package.json | 8 +- pnpm-lock.yaml | 22 ++ prisma/migrate.ts | 133 ++++---- .../migration.sql | 19 ++ .../migration.sql | 12 + prisma/schema.prisma | 11 + src/api/api.module.ts | 10 +- src/api/appeal/appeal.controller.ts | 2 +- src/api/contact/contactRequests.controller.ts | 22 +- .../health-check/healthCheck.controller.ts | 2 +- .../projectResult.controller.ts | 2 +- .../reviewApplication.controller.ts | 17 +- .../reviewApplication.service.ts | 76 +++-- .../reviewHistory.controller.ts | 42 ++- .../reviewOpportunity.controller.ts | 30 +- .../reviewOpportunity.service.ts | 97 ++++-- .../review-summation.controller.ts | 9 +- .../review-summation.service.ts | 31 +- src/api/review/review.controller.ts | 6 +- src/api/scorecard/scorecard.controller.ts | 2 +- src/api/submission/submission.controller.ts | 4 +- src/api/submission/submission.service.ts | 51 +-- .../interfaces/gitea-webhook.interface.ts | 24 ++ src/api/webhook/webhook.controller.ts | 109 ++++++ src/api/webhook/webhook.service.ts | 174 ++++++++++ src/dto/contactRequest.dto.ts | 4 + src/dto/reviewOpportunity.dto.ts | 11 +- src/dto/reviewSummation.dto.ts | 8 +- src/dto/submission.dto.ts | 10 +- src/dto/upload.dto.ts | 5 +- src/dto/webhook-event.dto.ts | 23 ++ src/main.ts | 30 +- src/shared/config/common.config.ts | 5 +- src/shared/enums/userRole.enum.ts | 1 + src/shared/guards/gitea-webhook-auth.guard.ts | 69 ++++ src/shared/models/ResourceInfo.model.ts | 14 + src/shared/models/ResourceRole.model.ts | 12 + src/shared/modules/global/eventBus.service.ts | 7 +- .../modules/global/globalProviders.module.ts | 6 +- src/shared/modules/global/jwt.service.ts | 6 +- src/shared/modules/global/logger.service.ts | 3 +- .../modules/global/prisma-error.service.ts | 4 +- src/shared/modules/global/resource.service.ts | 127 +++++++ src/shared/modules/global/utils.service.ts | 3 +- .../modules/kafka/base-event.handler.ts | 24 ++ .../handlers/avscan-action-scan.handler.ts | 52 +++ .../modules/kafka/kafka-consumer.service.ts | 303 +++++++++++++++++ .../modules/kafka/kafka-handler.registry.ts | 23 ++ src/shared/modules/kafka/kafka.module.ts | 70 ++++ 55 files changed, 2062 insertions(+), 265 deletions(-) create mode 100644 docker-compose.kafka.yml create mode 100644 docs/GITEA_WEBHOOK_SETUP.md create mode 100644 docs/KAFKA_SETUP.md rename M2M_TOKEN_GUIDE.md => docs/M2M_TOKEN_GUIDE.md (100%) create mode 100644 prisma/migrations/20250802073602_gitea_webhook/migration.sql create mode 100644 prisma/migrations/20250804060038_reviewApplication_fkey/migration.sql create mode 100644 src/api/webhook/interfaces/gitea-webhook.interface.ts create mode 100644 src/api/webhook/webhook.controller.ts create mode 100644 src/api/webhook/webhook.service.ts create mode 100644 src/dto/webhook-event.dto.ts create mode 100644 src/shared/guards/gitea-webhook-auth.guard.ts create mode 100644 src/shared/models/ResourceInfo.model.ts create mode 100644 src/shared/models/ResourceRole.model.ts create mode 100644 src/shared/modules/global/resource.service.ts create mode 100644 src/shared/modules/kafka/base-event.handler.ts create mode 100644 src/shared/modules/kafka/handlers/avscan-action-scan.handler.ts create mode 100644 src/shared/modules/kafka/kafka-consumer.service.ts create mode 100644 src/shared/modules/kafka/kafka-handler.registry.ts create mode 100644 src/shared/modules/kafka/kafka.module.ts diff --git a/.env.sample b/.env.sample index 01dd94e..46309ef 100644 --- a/.env.sample +++ b/.env.sample @@ -1,9 +1,42 @@ POSTGRES_SCHEMA="public" DATABASE_URL="postgresql://johndoe:randompassword@localhost:5432/mydb?schema=${POSTGRES_SCHEMA}" + +# Gitea Webhook Configuration +GITEA_WEBHOOK_AUTH="your_webhook_secret_here" + +# Kafka Configuration +KAFKA_BROKERS=localhost:9092 +KAFKA_CLIENT_ID=tc-review-api +KAFKA_GROUP_ID=tc-review-consumer-group +KAFKA_SSL_ENABLED=false + +# SASL Configuration (optional - uncomment if needed) +# KAFKA_SASL_MECHANISM=plain +# KAFKA_SASL_USERNAME= +# KAFKA_SASL_PASSWORD= + +# Consumer Configuration +KAFKA_SESSION_TIMEOUT=30000 +KAFKA_HEARTBEAT_INTERVAL=3000 +KAFKA_MAX_WAIT_TIME=5000 +KAFKA_CONNECTION_TIMEOUT=10000 +KAFKA_REQUEST_TIMEOUT=30000 + +# Retry Configuration +KAFKA_RETRY_ATTEMPTS=5 +KAFKA_INITIAL_RETRY_TIME=100 +KAFKA_MAX_RETRY_TIME=30000 + +# Dead Letter Queue Configuration +KAFKA_DLQ_ENABLED=true +KAFKA_DLQ_TOPIC_SUFFIX=.dlq +KAFKA_DLQ_MAX_RETRIES=3 + # API configs BUS_API_URL="https://api.topcoder-dev.com/v5/bus/events" CHALLENGE_API_URL="https://api.topcoder-dev.com/v5/challenges/" +RESOURCE_API_URL="https://api.topcoder-dev.com/v6/" MEMBER_API_URL="https://api.topcoder-dev.com/v5/members" # M2m configs M2M_AUTH_URL="https://auth0.topcoder-dev.com/oauth/token" diff --git a/Dockerfile b/Dockerfile index 8fb6467..91e76eb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,6 +15,7 @@ WORKDIR /app COPY . . RUN npm install pnpm -g RUN pnpm install +RUN pnpm run lint RUN pnpm run build RUN chmod +x appStartUp.sh CMD ./appStartUp.sh \ No newline at end of file diff --git a/docker-compose.kafka.yml b/docker-compose.kafka.yml new file mode 100644 index 0000000..ac740e6 --- /dev/null +++ b/docker-compose.kafka.yml @@ -0,0 +1,54 @@ +services: + zookeeper: + image: confluentinc/cp-zookeeper:7.4.0 + hostname: zookeeper + container_name: zookeeper + ports: + - "2181:2181" + environment: + ZOOKEEPER_CLIENT_PORT: 2181 + ZOOKEEPER_TICK_TIME: 2000 + networks: + - kafka-network + + kafka: + image: confluentinc/cp-kafka:7.4.0 + hostname: kafka + container_name: kafka + depends_on: + - zookeeper + ports: + - "9092:9092" + - "9101:9101" + environment: + KAFKA_BROKER_ID: 1 + KAFKA_ZOOKEEPER_CONNECT: 'zookeeper:2181' + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT + KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://localhost:9092 + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 + KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 + KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0 + KAFKA_JMX_PORT: 9101 + KAFKA_JMX_HOSTNAME: localhost + KAFKA_AUTO_CREATE_TOPICS_ENABLE: 'true' + networks: + - kafka-network + + kafka-ui: + image: provectuslabs/kafka-ui:latest + container_name: kafka-ui + depends_on: + - kafka + ports: + - "8080:8080" + environment: + KAFKA_CLUSTERS_0_NAME: local + KAFKA_CLUSTERS_0_BOOTSTRAPSERVERS: kafka:29092 + KAFKA_CLUSTERS_0_ZOOKEEPER: zookeeper:2181 + networks: + - kafka-network + +networks: + kafka-network: + driver: bridge \ No newline at end of file diff --git a/docs/GITEA_WEBHOOK_SETUP.md b/docs/GITEA_WEBHOOK_SETUP.md new file mode 100644 index 0000000..a68d2d0 --- /dev/null +++ b/docs/GITEA_WEBHOOK_SETUP.md @@ -0,0 +1,316 @@ +# Gitea Webhook Integration Setup and Testing Guide + +## Overview + +The Topcoder Review API includes a secure Gitea webhook integration that receives webhook events from Gitea repositories, validates them using Authorization header validation, and stores them in the database for audit and future processing. + +## Table of Contents + +1. [Quick Start](#quick-start) +2. [Environment Setup](#environment-setup) +3. [Gitea Repository Configuration](#Gitea-repository-configuration) +4. [Local Development Setup](#local-development-setup) +5. [Testing the Integration](#testing-the-integration) +6. [API Endpoint Reference](#api-endpoint-reference) +7. [Database Schema](#database-schema) +8. [Security Considerations](#security-considerations) +9. [Troubleshooting](#troubleshooting) +10. [Monitoring and Maintenance](#monitoring-and-maintenance) + +## Quick Start + +For immediate setup, follow these steps: + +1. Generate a secure webhook auth secret +2. Configure environment variables +3. Set up Gitea webhook in repository settings +4. Test with a sample event + +## Environment Setup + +### Required Environment Variables + +Add the following environment variable to your application configuration: + +```bash +# .env file +GITEA_WEBHOOK_AUTH=your_generated_secret_here +``` + +### Generate Webhook Secret + +**Using OpenSSL:** + +```bash +openssl rand -hex 32 +``` + +**Example Output:** + +``` +a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456 +``` + +⚠️ **Important:** Store this auth secret securely and use the same value in both your application environment and Gitea webhook configuration. + +### Database Setup + +The webhook integration requires the `gitWebhookLog` table. If not already created, run the database migration: + +```bash +npx prisma migrate dev +``` + +## Gitea Repository Configuration + +### Step 1: Access Repository Settings + +1. Navigate to your Gitea repository +2. Click on the **Settings** tab (requires admin permissions) +3. In the left sidebar, click **Webhooks** +4. Click **Add webhook** + +### Step 2: Configure Webhook Settings + +#### Payload URL + +**Production/Staging Environment:** + +``` +https://your-api-domain.com/v6/review/webhooks/gitea +``` + +**Development Environment:** + +``` +https://your-dev-domain.com/webhooks/gitea +``` + +Note: The `/v6/review` prefix is only added in production when `NODE_ENV=production`. + +#### Content Type + +- Select `application/json` + +#### Authorization + +- Enter the webhook auth secret you generated earlier +- This must exactly match your `GITEA_WEBHOOK_AUTH` environment variable + +#### SSL Verification + +- Keep **Enable SSL verification** checked (recommended for production) +- For development with proper HTTPS setup, this should remain enabled + +### Step 3: Select Events + +Choose one of the following options: + +**Option A: Send Everything (Recommended for Testing)** + +- Select "Send me everything" to receive all Gitea event types + +**Option B: Select Individual Events** +Common events for development workflows: + +- **Pushes** - Code pushes to repository +- **Pull requests** - PR creation, updates, merges +- **Issues** - Issue creation, updates, comments +- **Issue comments** - Comments on issues and PRs +- **Releases** - Release creation and updates +- **Create** - Branch or tag creation +- **Delete** - Branch or tag deletion + +### Step 4: Activate and Create + +1. Ensure **Active** checkbox is checked +2. Click **Add webhook** +3. Gitea will automatically send a `ping` event to test the webhook + +## Local Development Setup + +Since Gitea webhooks require a publicly accessible URL, local development requires exposing your local server to the internet. + +**Install ngrok:** + +```bash +npm install -g ngrok +``` + +**Setup process:** + +```bash +# 1. Start your local API server +pnpm run start:dev + +# 2. In another terminal, expose your local server +ngrok http 3000 + +# 3. Copy the HTTPS URL from ngrok output +# Example: https://abc123.ngrok.io + +# 4. Use this URL in Gitea webhook settings +# https://abc123.ngrok.io/webhooks/gitea +``` + +## Testing the Integration + +### Manual Testing + +#### 1. Verify Initial Setup + +After creating the webhook, Gitea automatically sends a `ping` event: + +1. Go to your repository's webhook settings +2. Click on your webhook +3. Check **Recent Deliveries** section +4. Look for the `ping` event with status 200 OK + +#### 2. Trigger Test Events + +**Create a Push Event:** + +```bash +# Make a small change +echo "webhook test" >> test-webhook.txt +git add test-webhook.txt +git commit -m "Test webhook integration" +git push origin main +``` + +**Create an Issue:** + +1. Go to your repository on Gitea +2. Click **Issues** tab +3. Click **New issue** +4. Create a test issue + +**Create a Pull Request:** + +1. Create a new branch: `git checkout -b test-webhook` +2. Make changes and commit +3. Push branch: `git push origin test-webhook` +4. Open pull request on Gitea + +### API Endpoint Reference + +### Webhook Endpoint + +**URL:** `POST /webhooks/gitea` (development) or `POST /v6/review/webhooks/gitea` (production) + +**Required Headers:** + +- `Content-Type: application/json` +- `X-Gitea-Event: {event_type}` - Gitea event type (push, pull_request, etc.) +- `X-Gitea-Delivery: {delivery_id}` - Unique delivery identifier from Gitea +- `Authorization: Bearer {GITEA_WEBHOOK_AUTH}` - Token used to verify authorization + +**Request Body:** + +- Gitea webhook payload (varies by event type) + +**Response Codes:** + +- `200 OK` - Webhook processed successfully +- `400 Bad Request` - Missing required headers or invalid payload +- `403 Forbidden` - Invalid signature verification +- `500 Internal Server Error` - Processing error or configuration issue + +**Success Response:** + +```json +{ + "success": true, + "message": "Webhook processed successfully" +} +``` + +**Error Response:** + +```json +{ + "statusCode": 403, + "message": "Invalid signature", + "error": "Forbidden", + "timestamp": "2024-01-01T00:00:00.000Z", + "path": "/webhooks/gitea" +} +``` + +## Database Schema + +Webhook events are stored in the `gitWebhookLog` table: + +```sql +CREATE TABLE "gitWebhookLog" ( + "id" VARCHAR(14) NOT NULL DEFAULT nanoid(), + "eventId" TEXT NOT NULL, + "event" TEXT NOT NULL, + "eventPayload" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "gitWebhookLog_pkey" PRIMARY KEY ("id") +); + +-- Indexes for efficient querying +CREATE INDEX "gitWebhookLog_eventId_idx" ON "gitWebhookLog"("eventId"); +CREATE INDEX "gitWebhookLog_event_idx" ON "gitWebhookLog"("event"); +CREATE INDEX "gitWebhookLog_createdAt_idx" ON "gitWebhookLog"("createdAt"); +``` + +### Query Examples + +**View recent webhook events:** + +```sql +SELECT + id, + "eventId", + event, + "createdAt" +FROM "gitWebhookLog" +ORDER BY "createdAt" DESC +LIMIT 10; +``` + +**Filter by event type:** + +```sql +SELECT * FROM "gitWebhookLog" +WHERE event = 'push' +ORDER BY "createdAt" DESC; +``` + +**View specific webhook payload:** + +```sql +SELECT + event, + "eventPayload" +FROM "gitWebhookLog" +WHERE "eventId" = 'your-delivery-id'; +``` + +## Security Considerations + +### Signature Verification + +The webhook implementation uses Gitea's recommended security practices: + +1. **Secret Protection:** Webhook auth secrets are stored as environment variables +2. **Header Validation:** Validates all required Gitea headers + +### Best Practices + +1. **Use HTTPS:** Always use HTTPS URLs for production webhooks +2. **Rotate Secrets:** Periodically rotate webhook secrets +3. **Monitor Access:** Regularly review webhook delivery logs +4. **Limit Events:** Only subscribe to events you actually need +5. **Access Control:** Restrict webhook configuration to repository administrators + +### Environment Security + +- Store `GITEA_WEBHOOK_AUTH` securely using your deployment platform's secret management +- Never commit secrets to version control +- Use different secrets for different environments +- Implement proper secret rotation procedures diff --git a/docs/KAFKA_SETUP.md b/docs/KAFKA_SETUP.md new file mode 100644 index 0000000..54866ae --- /dev/null +++ b/docs/KAFKA_SETUP.md @@ -0,0 +1,188 @@ +# Kafka Development Setup + +This document describes how to set up and test the Kafka consumer functionality in the TC Review API. + +## Quick Start + +### 1. Start Kafka Services + +```bash +# Start Kafka and related services +docker compose -f docker-compose.kafka.yml up -d + +# Verify services are running +docker compose -f docker-compose.kafka.yml ps +``` + +This will start: +- **Zookeeper** on port 2181 +- **Kafka** on port 9092 +- **Kafka UI** on port 8080 (web interface) + +### 2. Configure Environment + +```bash +# Copy the sample environment file +cp .env.sample .env + +# Update the .env file with your database and other configurations +# Kafka settings are pre-configured for local development +``` + +### 3. Start the Application + +```bash +# Install dependencies +pnpm install + +# Start in development mode +pnpm run start:dev +``` + +The application will automatically: +- Connect to Kafka on startup +- Subscribe to registered topics +- Start consuming messages + +## Testing Kafka Events + +### Using Kafka UI (Recommended) + +1. Open http://localhost:8080 in your browser +2. Navigate to Topics +3. Create or select the `avscan.action.scan` topic +4. Produce a test message with JSON payload: + ```json + { + "scanId": "test-123", + "submissionId": "sub-456", + "status": "initiated", + "timestamp": "2025-01-01T12:00:00Z" + } + ``` + +### Using Command Line + +```bash +# Create a topic (optional - auto-created) +docker exec -it kafka kafka-topics --create --topic avscan.action.scan --bootstrap-server localhost:9092 --partitions 1 --replication-factor 1 + +# Produce a test message +docker exec -it kafka kafka-console-producer --topic avscan.action.scan --bootstrap-server localhost:9092 +# Then type your JSON message and press Enter + +# Consume messages (for debugging) +docker exec -it kafka kafka-console-consumer --topic avscan.action.scan --from-beginning --bootstrap-server localhost:9092 +``` + +## Development Workflow + +### Adding New Event Handlers + +1. Create a new handler class extending `BaseEventHandler`: + ```typescript + @Injectable() + export class MyCustomHandler extends BaseEventHandler implements OnModuleInit { + private readonly topic = 'my.custom.topic'; + + constructor(private readonly handlerRegistry: KafkaHandlerRegistry) { + super(LoggerService.forRoot('MyCustomHandler')); + } + + onModuleInit() { + this.handlerRegistry.registerHandler(this.topic, this); + } + + getTopic(): string { + return this.topic; + } + + async handle(message: any): Promise { + // Your custom logic here + } + } + ``` + +2. Register the handler in the KafkaModule providers array +3. The handler will automatically be registered and start consuming messages + +### Dead Letter Queue (DLQ) Support + +The application includes a robust Dead Letter Queue implementation for handling message processing failures: + +1. **Configuration**: + ``` + # DLQ Configuration in .env + KAFKA_DLQ_ENABLED=true + KAFKA_DLQ_TOPIC_SUFFIX=.dlq + KAFKA_DLQ_MAX_RETRIES=3 + ``` + +2. **Retry Mechanism**: + - Failed messages are automatically retried up to the configured maximum number of retries + - Retry count is tracked per message using a unique key based on topic, partition, and offset + - Exponential backoff is applied between retries + +3. **DLQ Processing**: + - After exhausting retries, messages are sent to a DLQ topic (original topic name + configured suffix) + - DLQ messages include: + - Original message content + - Error information + - Original topic, partition, and offset + - Timestamp of failure + - Original message headers + +4. **Monitoring DLQ**: + - Use Kafka UI to monitor DLQ topics (they follow the pattern `.dlq`) + - Check application logs for messages with "Message sent to DLQ" or "Failed to send message to DLQ" + +### Monitoring and Debugging + +- **Application Logs**: Check console output for Kafka connection status and message processing +- **Kafka UI**: Monitor topics, partitions, and consumer groups at http://localhost:8080 +- **Health Checks**: Kafka connection status is included in application health checks + +### Environment Variables + +All Kafka-related environment variables are documented in `.env.sample`: + +- `KAFKA_BROKERS`: Comma-separated list of Kafka brokers +- `KAFKA_CLIENT_ID`: Unique client identifier +- `KAFKA_GROUP_ID`: Consumer group ID +- `KAFKA_SSL_ENABLED`: Enable SSL encryption +- Connection timeouts and retry configurations +- **DLQ Configuration**: + - `KAFKA_DLQ_ENABLED`: Enable/disable the Dead Letter Queue feature + - `KAFKA_DLQ_TOPIC_SUFFIX`: Suffix to append to original topic name for DLQ topics + - `KAFKA_DLQ_MAX_RETRIES`: Maximum number of retries before sending to DLQ + +## Troubleshooting + +### Common Issues + +1. **Connection Refused**: Ensure Kafka is running with `docker compose -f docker-compose.kafka.yml ps` +2. **Topic Not Found**: Topics are auto-created by default, or create manually using Kafka UI +3. **Consumer Group Issues**: Check consumer group status in Kafka UI under "Consumers" +4. **DLQ Topics Missing**: DLQ topics are created automatically when the first message is sent to them + +### Cleanup + +```bash +# Stop and remove Kafka services +docker compose -f docker-compose.kafka.yml down + +# Remove volumes (clears all Kafka data) +docker compose -f docker-compose.kafka.yml down -v +``` + +## Production Considerations + +- Configure SSL/TLS and SASL authentication for production environments +- Set appropriate retention policies for topics +- Monitor consumer lag and processing metrics +- Ensure DLQ topics have appropriate retention policies (longer than source topics) +- Set up alerts for: + - Messages in DLQ topics + - High retry rates + - Consumer failures +- Implement a process for reviewing and potentially reprocessing DLQ messages \ No newline at end of file diff --git a/M2M_TOKEN_GUIDE.md b/docs/M2M_TOKEN_GUIDE.md similarity index 100% rename from M2M_TOKEN_GUIDE.md rename to docs/M2M_TOKEN_GUIDE.md diff --git a/package.json b/package.json index 66986cd..27239ca 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "start:dev": "nest start --watch", "start:debug": "nest start --debug --watch", "start:prod": "node dist/src/main", - "lint": "eslint \"{src,apps,libs,test,prisma}/**/*.ts\" --fix", + "lint": "eslint \"{src,apps,libs,test,prisma}/**/*.ts\"", + "lint:fix": "eslint \"{src,apps,libs,test,prisma}/**/*.ts\" --fix", "test": "jest", "test:watch": "jest --watch", "test:cov": "jest --coverage", @@ -35,11 +36,14 @@ "cors": "^2.8.5", "jsonwebtoken": "^9.0.2", "jwks-rsa": "^3.2.0", + "lodash": "^4.17.21", "multer": "^2.0.1", "nanoid": "~5.1.2", + "querystring": "^0.2.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", - "tc-core-library-js": "appirio-tech/tc-core-library-js.git#v3.0.1" + "tc-core-library-js": "appirio-tech/tc-core-library-js.git#v3.0.1", + "kafkajs": "^2.2.4" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ba27136..7eb7a52 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,12 +50,21 @@ importers: jwks-rsa: specifier: ^3.2.0 version: 3.2.0 + kafkajs: + specifier: ^2.2.4 + version: 2.2.4 + lodash: + specifier: ^4.17.21 + version: 4.17.21 multer: specifier: ^2.0.1 version: 2.0.1 nanoid: specifier: ~5.1.2 version: 5.1.2 + querystring: + specifier: ^0.2.1 + version: 0.2.1 reflect-metadata: specifier: ^0.2.2 version: 0.2.2 @@ -2814,6 +2823,10 @@ packages: jws@3.2.2: resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + kafkajs@2.2.4: + resolution: {integrity: sha512-j/YeapB1vfPT2iOIUn/vxdyKEuhuY2PxMBvf5JWux6iSaukAccrMtXEY/Lb7OvavDhOWME589bpLrEdnVHjfjA==} + engines: {node: '>=14.0.0'} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -3302,6 +3315,11 @@ packages: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} + querystring@0.2.1: + resolution: {integrity: sha512-wkvS7mL/JMugcup3/rMitHmd9ecIGd2lhFhK9N3UUQ450h66d1r3Y9nvXzQAW1Lq+wyx61k/1pfKS5KuKiyEbg==} + engines: {node: '>=0.4.x'} + deprecated: The querystring API is considered Legacy. new code should use the URLSearchParams API instead. + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -7131,6 +7149,8 @@ snapshots: jwa: 1.4.1 safe-buffer: 5.2.1 + kafkajs@2.2.4: {} + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -7546,6 +7566,8 @@ snapshots: dependencies: side-channel: 1.1.0 + querystring@0.2.1: {} + queue-microtask@1.2.3: {} quick-lru@5.1.1: {} diff --git a/prisma/migrate.ts b/prisma/migrate.ts index 72e51d4..6d21c5c 100644 --- a/prisma/migrate.ts +++ b/prisma/migrate.ts @@ -78,12 +78,12 @@ const submissionMap: Record> = {}; // Initialize maps from files if they exist, otherwise create new maps function readIdMap(filename) { return fs.existsSync(`.tmp/${filename}.json`) - ? new Map( - Object.entries( - JSON.parse(fs.readFileSync(`.tmp/${filename}.json`, 'utf-8')), - ), - ) - : new Map(); + ? new Map( + Object.entries( + JSON.parse(fs.readFileSync(`.tmp/${filename}.json`, 'utf-8')), + ), + ) + : new Map(); } const projectIdMap = readIdMap('projectIdMap'); @@ -93,9 +93,13 @@ const scorecardSectionIdMap = readIdMap('scorecardSectionIdMap'); const scorecardQuestionIdMap = readIdMap('scorecardQuestionIdMap'); const reviewIdMap = readIdMap('reviewIdMap'); const reviewItemIdMap = readIdMap('reviewItemIdMap'); -const reviewItemCommentReviewItemCommentIdMap = readIdMap('reviewItemCommentReviewItemCommentIdMap'); +const reviewItemCommentReviewItemCommentIdMap = readIdMap( + 'reviewItemCommentReviewItemCommentIdMap', +); const reviewItemCommentAppealIdMap = readIdMap('reviewItemCommentAppealIdMap'); -const reviewItemCommentAppealResponseIdMap = readIdMap('reviewItemCommentAppealResponseIdMap'); +const reviewItemCommentAppealResponseIdMap = readIdMap( + 'reviewItemCommentAppealResponseIdMap', +); const uploadIdMap = readIdMap('uploadIdMap'); const submissionIdMap = readIdMap('submissionIdMap'); @@ -103,7 +107,7 @@ const submissionIdMap = readIdMap('submissionIdMap'); const rsSetFile = '.tmp/resourceSubmissionSet.json'; if (fs.existsSync(rsSetFile)) { resourceSubmissionSet = new Set([ - ...(JSON.parse(fs.readFileSync(rsSetFile, 'utf-8')) as string[]) + ...(JSON.parse(fs.readFileSync(rsSetFile, 'utf-8')) as string[]), ]); } @@ -155,7 +159,7 @@ enum LegacyUploadType { enum LegacyUploadStatus { 'Active' = UploadStatus.ACTIVE, - 'Deleted' = UploadStatus.DELETED + 'Deleted' = UploadStatus.DELETED, } enum LegacySubmissionType { @@ -166,7 +170,7 @@ enum LegacySubmissionType { 'Contest Submission' = SubmissionType.CONTEST_SUBMISSION, 'Specification Submission' = SubmissionType.SPECIFICATION_SUBMISSION, 'Checkpoint Submission' = SubmissionType.CHECKPOINT_SUBMISSION, - 'Studio Final Fix Submission' = SubmissionType.STUDIO_FINAL_FIX_SUBMISSION + 'Studio Final Fix Submission' = SubmissionType.STUDIO_FINAL_FIX_SUBMISSION, } enum LegacySubmissionStatus { @@ -176,7 +180,7 @@ enum LegacySubmissionStatus { 'Completed Without Win' = SubmissionStatus.COMPLETED_WITHOUT_WIN, 'Deleted' = SubmissionStatus.DELETED, 'Failed Checkpoint Screening' = SubmissionStatus.FAILED_CHECKPOINT_SCREENING, - 'Failed Checkpoint Review' = SubmissionStatus.FAILED_CHECKPOINT_REVIEW + 'Failed Checkpoint Review' = SubmissionStatus.FAILED_CHECKPOINT_REVIEW, } const LegacyChallengeTrack: Record = { @@ -261,12 +265,10 @@ function processLookupFiles() { break; case 'upload_type_lu': uploadTypeMap = Object.fromEntries( - (jsonData.upload_type_lu as any[]).map( - ({ upload_type_id, name }) => [ - upload_type_id, - LegacyUploadType[name] - ] - ) + (jsonData.upload_type_lu as any[]).map(({ upload_type_id, name }) => [ + upload_type_id, + LegacyUploadType[name], + ]), ); break; case 'upload_status_lu': @@ -274,9 +276,9 @@ function processLookupFiles() { (jsonData.upload_status_lu as any[]).map( ({ upload_status_id, name }) => [ upload_status_id, - LegacyUploadStatus[name] - ] - ) + LegacyUploadStatus[name], + ], + ), ); break; case 'submission_type_lu': @@ -284,9 +286,9 @@ function processLookupFiles() { (jsonData.submission_type_lu as any[]).map( ({ submission_type_id, name }) => [ submission_type_id, - LegacySubmissionType[name] - ] - ) + LegacySubmissionType[name], + ], + ), ); break; case 'submission_status_lu': @@ -294,9 +296,9 @@ function processLookupFiles() { (jsonData.submission_status_lu as any[]).map( ({ submission_status_id, name }) => [ submission_status_id, - LegacySubmissionStatus[name] - ] - ) + LegacySubmissionStatus[name], + ], + ), ); break; } @@ -307,10 +309,9 @@ const filenameComp = (a, b) => { const numA = parseInt(a.match(/_(\d+)\.json$/)?.[1] || '0', 10); const numB = parseInt(b.match(/_(\d+)\.json$/)?.[1] || '0', 10); return numA - numB; -} - +}; -function convertSubmissionES(esData) { +function convertSubmissionES(esData): any { let challengeId = null; let legacyChallengeId = null; if (esData.legacyChallengeId) { @@ -346,12 +347,14 @@ function convertSubmissionES(esData) { scorecardId: scorecardIdMap.get(summation.scoreCardId), scorecardLegacyId: String(summation.scoreCardId), isPassing: summation.isPassing, - reviewedDate: summation.reviewedDate ? new Date(summation.reviewedDate) : null, + reviewedDate: summation.reviewedDate + ? new Date(summation.reviewedDate) + : null, createdBy: summation.createdBy, createdAt: new Date(summation.created), updatedBy: summation.updatedBy, updatedAt: summation.updated ? new Date(summation.updated) : null, - } + }, }; } return submission; @@ -376,7 +379,7 @@ async function migrateElasticSearch() { if (source['resource'] === 'submission') { await handleElasticSearchSubmission(source); } - } catch (err) { + } catch { console.log(`Failed to process ES line ${lineCount}`); } if (lineCount % logSize === 0) { @@ -388,13 +391,12 @@ async function migrateElasticSearch() { console.log(`ES data imported with total line: ${lineCount}`); } - let currentSubmissions: any[] = []; async function handleElasticSearchSubmission(item) { // ignore records without legacySubmissionId field. if (item['legacySubmissionId'] == null) { return; - } + } currentSubmissions.push(item); // if we can batch insert data, + if (currentSubmissions.length >= batchSize) { @@ -416,7 +418,9 @@ async function importSubmissionES() { newSubmission = false; await prisma.submission.update({ data: submission, - where: { id: submissionIdMap.get(submission.legacySubmissionId) as string } + where: { + id: submissionIdMap.get(submission.legacySubmissionId) as string, + }, }); } else { newSubmission = true; @@ -433,11 +437,11 @@ async function importSubmissionES() { status: SubmissionStatus.ACTIVE, type, createdBy: item.createdBy || 'migration', - createdAt: item.created ? new Date(item.created) : new Date() + createdAt: item.created ? new Date(item.created) : new Date(), }, }); } - } catch (err) { + } catch { if (newSubmission) { projectIdMap.delete(submission.legacySubmissionId); } @@ -470,7 +474,7 @@ async function importUploadData(uploadData) { uploadDataList.push(uploadData); if (uploadDataList.length >= batchSize) { await doImportUploadData(); - uploadDataList = []; + uploadDataList = []; } } @@ -480,14 +484,14 @@ async function doImportUploadData() { } try { await prisma.upload.createMany({ - data: uploadDataList + data: uploadDataList, }); } catch { // import data one by one for (const u of uploadDataList) { try { await prisma.upload.create({ data: u }); - } catch (err) { + } catch { console.error(`Cannot import upload data id: ${u.legacyId}`); uploadIdMap.delete(u.legacyId); } @@ -526,7 +530,7 @@ async function importSubmissionData(submissionData) { submissionDataList.push(submissionData); if (submissionDataList.length >= batchSize) { await doImportSubmissionData(); - submissionDataList = []; + submissionDataList = []; } } @@ -536,13 +540,13 @@ async function doImportSubmissionData() { } try { await prisma.submission.createMany({ - data: submissionDataList + data: submissionDataList, }); } catch { for (const s of submissionDataList) { try { await prisma.submission.create({ data: s }); - } catch (err) { + } catch { console.error(`Failed to import submission ${s.legacySubmissionId}`); submissionIdMap.delete(s.legacySubmissionId); } @@ -553,7 +557,6 @@ async function doImportSubmissionData() { /** * Read submission data from resource_xxx.json, upload_xxx.json and submission_xxx.json. */ -// eslint-disable-next-line @typescript-eslint/require-await async function initSubmissionMap() { // read submission_x.json, read {uploadId -> submissionId} map. const submissionRegex = new RegExp(`^submission_\\d+\\.json`); @@ -562,7 +565,7 @@ async function initSubmissionMap() { const submissionFiles: string[] = []; const uploadFiles: string[] = []; const resourceFiles: string[] = []; - fs.readdirSync(DATA_DIR).forEach((f) => { + fs.readdirSync(DATA_DIR).filter((f) => { if (submissionRegex.test(f)) { submissionFiles.push(f); } @@ -626,18 +629,21 @@ async function initSubmissionMap() { await importSubmissionData(dbData); } // collect data to uploadSubmissionMap - if (dbData.status === SubmissionStatus.ACTIVE && + if ( + dbData.status === SubmissionStatus.ACTIVE && dbData.legacyUploadId != null ) { const item = { - score: dbData.screeningScore || dbData.initialScore || dbData.finalScore, + score: + dbData.screeningScore || dbData.initialScore || dbData.finalScore, created: dbData.createdAt, submissionId: dbData.legacySubmissionId, }; // pick the latest valid submission for each upload if (uploadSubmissionMap[dbData.legacyUploadId]) { const existing = uploadSubmissionMap[dbData.legacyUploadId]; - if (!existing.score || + if ( + !existing.score || item.created.getTime() > existing.created.getTime() ) { uploadSubmissionMap[dbData.legacyUploadId] = item; @@ -664,9 +670,10 @@ async function initSubmissionMap() { acc[resourceId] = submission; } return acc; - }, {} as Record + }, + {} as Record, ); - + // read resource files const challengeSubmissionMap: Record> = {}; let resourceCount = 0; @@ -1042,7 +1049,9 @@ async function processType(type: string, subtype?: string) { scorecardId: scorecardIdMap.get(review.scorecard_id), committed: review.committed === '1', finalScore: review.score ? parseFloat(review.score) : null, - initialScore: review.initial_score ? parseFloat(review.initial_score) : null, + initialScore: review.initial_score + ? parseFloat(review.initial_score) + : null, createdAt: new Date(review.create_date), createdBy: review.create_user, updatedAt: new Date(review.modify_date), @@ -1364,7 +1373,7 @@ function convertResourceSubmission(jsonData) { createdAt: new Date(jsonData['create_date']), createdBy: jsonData['create_user'], updatedAt: new Date(jsonData['modify_date']), - updatedBy: jsonData['modify_user'] + updatedBy: jsonData['modify_user'], }; } @@ -1380,16 +1389,18 @@ async function handleResourceSubmission(data) { async function doImportResourceSubmission() { try { await prisma.resourceSubmission.createMany({ - data: resourceSubmissions + data: resourceSubmissions, }); } catch { for (const rs of resourceSubmissions) { try { await prisma.resourceSubmission.create({ - data: rs + data: rs, }); } catch { - console.error(`Failed to import resource_submission ${rs.resourceId}_${rs.legacySubmissionId}`); + console.error( + `Failed to import resource_submission ${rs.resourceId}_${rs.legacySubmissionId}`, + ); } } } @@ -1397,7 +1408,9 @@ async function doImportResourceSubmission() { async function migrateResourceSubmissions() { const filenameRegex = new RegExp(`^resource_submission_\\d+\\.json`); - const filenames = fs.readdirSync(DATA_DIR).filter(f => filenameRegex.test(f)); + const filenames = fs + .readdirSync(DATA_DIR) + .filter((f) => filenameRegex.test(f)); filenames.sort(filenameComp); // start importing data let totalCount = 0; @@ -1432,11 +1445,11 @@ async function migrate() { console.log('Starting submission import...'); await initSubmissionMap(); console.log('Submission import completed.'); - + console.log('Starting review import...'); await processAllTypes(); console.log('Review data import completed.'); - + // import Elastic Search data console.log('Starting Elastic Search data migration...'); await migrateElasticSearch(); @@ -1494,7 +1507,7 @@ migrate() key: 'reviewItemCommentAppealResponseIdMap', value: reviewItemCommentAppealResponseIdMap, }, - { key: 'uploadIdMap', value: uploadIdMap, }, + { key: 'uploadIdMap', value: uploadIdMap }, { key: 'submissionIdMap', value: submissionIdMap }, ].forEach((f) => { if (!fs.existsSync('.tmp')) { diff --git a/prisma/migrations/20250802073602_gitea_webhook/migration.sql b/prisma/migrations/20250802073602_gitea_webhook/migration.sql new file mode 100644 index 0000000..d020522 --- /dev/null +++ b/prisma/migrations/20250802073602_gitea_webhook/migration.sql @@ -0,0 +1,19 @@ +-- CreateTable +CREATE TABLE "gitWebhookLog" ( + "id" VARCHAR(14) NOT NULL DEFAULT nanoid(), + "eventId" TEXT NOT NULL, + "event" TEXT NOT NULL, + "eventPayload" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "gitWebhookLog_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "gitWebhookLog_eventId_idx" ON "gitWebhookLog"("eventId"); + +-- CreateIndex +CREATE INDEX "gitWebhookLog_event_idx" ON "gitWebhookLog"("event"); + +-- CreateIndex +CREATE INDEX "gitWebhookLog_createdAt_idx" ON "gitWebhookLog"("createdAt"); diff --git a/prisma/migrations/20250804060038_reviewApplication_fkey/migration.sql b/prisma/migrations/20250804060038_reviewApplication_fkey/migration.sql new file mode 100644 index 0000000..2753162 --- /dev/null +++ b/prisma/migrations/20250804060038_reviewApplication_fkey/migration.sql @@ -0,0 +1,12 @@ +-- DropForeignKey +ALTER TABLE "reviewApplication" DROP CONSTRAINT "reviewApplication_opportunityId_fkey"; + +-- AlterTable +ALTER TABLE "reviewApplication" ALTER COLUMN "opportunityId" SET DATA TYPE TEXT, +ALTER COLUMN "updatedAt" DROP DEFAULT; + +-- AlterTable +ALTER TABLE "reviewOpportunity" ALTER COLUMN "updatedAt" DROP DEFAULT; + +-- AddForeignKey +ALTER TABLE "reviewApplication" ADD CONSTRAINT "reviewApplication_opportunityId_fkey" FOREIGN KEY ("opportunityId") REFERENCES "reviewOpportunity"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9225dd2..33c13be 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -535,3 +535,14 @@ model resourceSubmission { submissions submission? @relation(fields: [submissionId], references: [id]) } +model gitWebhookLog { + id String @id @default(dbgenerated("nanoid()")) @db.VarChar(14) + eventId String // X-GitHub-Delivery header + event String // X-GitHub-Event header + eventPayload Json // Complete webhook payload + createdAt DateTime @default(now()) + + @@index([eventId]) + @@index([event]) + @@index([createdAt]) +} diff --git a/src/api/api.module.ts b/src/api/api.module.ts index 5087b43..35a873a 100644 --- a/src/api/api.module.ts +++ b/src/api/api.module.ts @@ -18,8 +18,12 @@ import { ReviewOpportunityService } from './review-opportunity/reviewOpportunity import { ReviewApplicationService } from './review-application/reviewApplication.service'; import { ReviewHistoryController } from './review-history/reviewHistory.controller'; import { ChallengeApiService } from 'src/shared/modules/global/challenge.service'; +import { ResourceApiService } from 'src/shared/modules/global/resource.service'; import { SubmissionService } from './submission/submission.service'; import { ReviewSummationService } from './review-summation/review-summation.service'; +import { WebhookController } from './webhook/webhook.controller'; +import { WebhookService } from './webhook/webhook.service'; +import { GiteaWebhookAuthGuard } from '../shared/guards/gitea-webhook-auth.guard'; @Module({ imports: [HttpModule, GlobalProvidersModule, FileUploadModule], @@ -35,12 +39,16 @@ import { ReviewSummationService } from './review-summation/review-summation.serv ReviewSummationController, ReviewOpportunityController, ReviewApplicationController, - ReviewHistoryController + ReviewHistoryController, + WebhookController, ], providers: [ ReviewOpportunityService, ReviewApplicationService, ChallengeApiService, + ResourceApiService, + WebhookService, + GiteaWebhookAuthGuard, SubmissionService, ReviewSummationService, ], diff --git a/src/api/appeal/appeal.controller.ts b/src/api/appeal/appeal.controller.ts index 72c8c9c..94cd25c 100644 --- a/src/api/appeal/appeal.controller.ts +++ b/src/api/appeal/appeal.controller.ts @@ -38,7 +38,7 @@ import { PrismaErrorService } from '../../shared/modules/global/prisma-error.ser @ApiTags('Appeal') @ApiBearerAuth() -@Controller('/api/appeals') +@Controller('/appeals') export class AppealController { private readonly logger: LoggerService; diff --git a/src/api/contact/contactRequests.controller.ts b/src/api/contact/contactRequests.controller.ts index e473003..bafd288 100644 --- a/src/api/contact/contactRequests.controller.ts +++ b/src/api/contact/contactRequests.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Post, Body } from '@nestjs/common'; +import { Controller, Post, Body, Req } from '@nestjs/common'; import { ApiOperation, ApiResponse, @@ -16,15 +16,20 @@ import { mapContactRequestToDto, } from 'src/dto/contactRequest.dto'; import { PrismaService } from '../../shared/modules/global/prisma.service'; +import { ResourceApiService } from 'src/shared/modules/global/resource.service'; +import { JwtUser } from 'src/shared/modules/global/jwt.service'; @ApiTags('Contact Requests') @ApiBearerAuth() -@Controller('/api') +@Controller('/') export class ContactRequestsController { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly resourceApiService: ResourceApiService, + ) {} @Post('/contact-requests') - @Roles(UserRole.Submitter, UserRole.Reviewer) + @Roles(UserRole.User, UserRole.Talent) @Scopes(Scope.CreateContactRequest) @ApiOperation({ summary: 'Create a new contact request', @@ -38,8 +43,17 @@ export class ContactRequestsController { }) @ApiResponse({ status: 403, description: 'Forbidden.' }) async createContactRequest( + @Req() req: Request, @Body() body: ContactRequestDto, ): Promise { + const authUser: JwtUser = req['user'] as JwtUser; + await this.resourceApiService.validateResourcesRoles( + [UserRole.Reviewer, UserRole.Submitter], + authUser, + body.challengeId, + body.resourceId, + ); + const data = await this.prisma.contactRequest.create({ data: mapContactRequestToDto(body), }); diff --git a/src/api/health-check/healthCheck.controller.ts b/src/api/health-check/healthCheck.controller.ts index 81575d7..51585cd 100644 --- a/src/api/health-check/healthCheck.controller.ts +++ b/src/api/health-check/healthCheck.controller.ts @@ -23,7 +23,7 @@ export class GetHealthCheckResponseDto { } @ApiTags('Healthcheck') -@Controller('/api') +@Controller('/') export class HealthCheckController { constructor(private readonly prisma: PrismaService) {} diff --git a/src/api/project-result/projectResult.controller.ts b/src/api/project-result/projectResult.controller.ts index f8e746a..f199203 100644 --- a/src/api/project-result/projectResult.controller.ts +++ b/src/api/project-result/projectResult.controller.ts @@ -23,7 +23,7 @@ import { PrismaErrorService } from '../../shared/modules/global/prisma-error.ser @ApiTags('ProjectResult') @ApiBearerAuth() -@Controller('/api') +@Controller('/') export class ProjectResultController { private readonly logger: LoggerService; diff --git a/src/api/review-application/reviewApplication.controller.ts b/src/api/review-application/reviewApplication.controller.ts index ffefb41..ec9502a 100644 --- a/src/api/review-application/reviewApplication.controller.ts +++ b/src/api/review-application/reviewApplication.controller.ts @@ -1,4 +1,13 @@ -import { Body, Controller, ForbiddenException, Get, Param, Patch, Post, Req } from '@nestjs/common'; +import { + Body, + Controller, + ForbiddenException, + Get, + Param, + Patch, + Post, + Req, +} from '@nestjs/common'; import { ApiBearerAuth, ApiBody, @@ -18,7 +27,7 @@ import { isAdmin, JwtUser } from 'src/shared/modules/global/jwt.service'; import { ReviewApplicationService } from './reviewApplication.service'; @ApiTags('Review Application') -@Controller('/api/review-applications') +@Controller('/review-applications') export class ReviewApplicationController { constructor(private readonly service: ReviewApplicationService) {} @@ -89,7 +98,9 @@ export class ReviewApplicationController { // Check user permission. Only admin and user himself can access const authUser: JwtUser = req['user'] as JwtUser; if (authUser.userId !== userId && !isAdmin(authUser)) { - throw new ForbiddenException('You cannot check this user\'s review applications') + throw new ForbiddenException( + "You cannot check this user's review applications", + ); } return OkResponse(await this.service.listByUser(userId)); } diff --git a/src/api/review-application/reviewApplication.service.ts b/src/api/review-application/reviewApplication.service.ts index 07a77e1..bef3bc5 100644 --- a/src/api/review-application/reviewApplication.service.ts +++ b/src/api/review-application/reviewApplication.service.ts @@ -1,6 +1,10 @@ -import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; import { - convertRoleName, + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { CreateReviewApplicationDto, ReviewApplicationResponseDto, ReviewApplicationRoleOpportunityTypeMap, @@ -8,7 +12,10 @@ import { } from 'src/dto/reviewApplication.dto'; import { CommonConfig } from 'src/shared/config/common.config'; import { ChallengeApiService } from 'src/shared/modules/global/challenge.service'; -import { EventBusSendEmailPayload, EventBusService } from 'src/shared/modules/global/eventBus.service'; +import { + EventBusSendEmailPayload, + EventBusService, +} from 'src/shared/modules/global/eventBus.service'; import { JwtUser } from 'src/shared/modules/global/jwt.service'; import { MemberService } from 'src/shared/modules/global/member.service'; import { PrismaService } from 'src/shared/modules/global/prisma.service'; @@ -19,7 +26,7 @@ export class ReviewApplicationService { private readonly prisma: PrismaService, private readonly challengeService: ChallengeApiService, private readonly memberService: MemberService, - private readonly eventBusService: EventBusService + private readonly eventBusService: EventBusService, ) {} /** @@ -36,22 +43,26 @@ export class ReviewApplicationService { const handle = authUser.handle as string; // make sure review opportunity exists const opportunity = await this.prisma.reviewOpportunity.findUnique({ - where: { id: dto.opportunityId} - }) + where: { id: dto.opportunityId }, + }); if (!opportunity || !opportunity.id) { - throw new BadRequestException('Opportunity doesn\'t exist'); + throw new BadRequestException("Opportunity doesn't exist"); } // make sure application role matches - if (ReviewApplicationRoleOpportunityTypeMap[dto.role] !== opportunity.type) { - throw new BadRequestException('Review application role doesn\'t match opportunity type'); + if ( + ReviewApplicationRoleOpportunityTypeMap[dto.role] !== opportunity.type + ) { + throw new BadRequestException( + "Review application role doesn't match opportunity type", + ); } // check existing const existing = await this.prisma.reviewApplication.findMany({ where: { userId, opportunityId: dto.opportunityId, - role: dto.role - } + role: dto.role, + }, }); if (existing && existing.length > 0) { throw new ConflictException('Reviewer has submitted application before.'); @@ -155,7 +166,7 @@ export class ReviewApplicationService { // select all pending const entityList = await this.prisma.reviewApplication.findMany({ where: { opportunityId, status: ReviewApplicationStatus.PENDING }, - include: { opportunity: true } + include: { opportunity: true }, }); // update all pending await this.prisma.reviewApplication.updateMany({ @@ -184,9 +195,9 @@ export class ReviewApplicationService { userId, status: ReviewApplicationStatus.APPROVED, createdAt: { - gte: beginDate - } - } + gte: beginDate, + }, + }, }); return entityList.map((e) => this.buildResponse(e)); } @@ -196,43 +207,50 @@ export class ReviewApplicationService { * @param entityList review application entity list * @param status application status */ - private async sendEmails(entityList, status: ReviewApplicationStatus) { + private async sendEmails( + entityList: any[], + status: ReviewApplicationStatus, + ): Promise { // All review application has same review opportunity and same challenge id. const challengeId = entityList[0].opportunity.challengeId; // get member id list - const userIds = entityList.map(e => e.userId); + const userIds: string[] = entityList.map((e: any) => e.userId as string); // Get challenge data and member emails. const [challengeData, memberInfoList] = await Promise.all([ this.challengeService.getChallengeDetail(challengeId), - this.memberService.getUserEmails(userIds) + this.memberService.getUserEmails(userIds), ]); // Get sendgrid template id - const sendgridTemplateId = status === ReviewApplicationStatus.APPROVED ? - CommonConfig.sendgridConfig.acceptEmailTemplate : - CommonConfig.sendgridConfig.rejectEmailTemplate; + const sendgridTemplateId = + status === ReviewApplicationStatus.APPROVED + ? CommonConfig.sendgridConfig.acceptEmailTemplate + : CommonConfig.sendgridConfig.rejectEmailTemplate; // build userId -> email map const userEmailMap = new Map(); - memberInfoList.forEach(e => userEmailMap.set(e.userId, e.email)); + memberInfoList.forEach((e) => userEmailMap.set(e.userId, e.email)); // prepare challenge data const challengeName = challengeData.name; - const challengeUrl = CommonConfig.apis.onlineReviewUrlBase + challengeData.legacyId; + const challengeUrl = + CommonConfig.apis.onlineReviewUrlBase + challengeData.legacyId; // build event bus message payload const eventBusPayloads: EventBusSendEmailPayload[] = []; - for (let entity of entityList) { - const payload:EventBusSendEmailPayload = new EventBusSendEmailPayload(); + for (const entity of entityList) { + const payload: EventBusSendEmailPayload = new EventBusSendEmailPayload(); payload.sendgrid_template_id = sendgridTemplateId; payload.recipients = [userEmailMap.get(entity.userId)]; payload.data = { handle: entity.handle, reviewPhaseStart: entity.startDate, challengeUrl, - challengeName + challengeName, }; eventBusPayloads.push(payload); } // send all emails - await Promise.all(eventBusPayloads.map(e => this.eventBusService.sendEmail(e))); - } + await Promise.all( + eventBusPayloads.map((e) => this.eventBusService.sendEmail(e)), + ); + } /** * Make sure review application exists. @@ -242,7 +260,7 @@ export class ReviewApplicationService { private async checkExists(id: string) { const entity = await this.prisma.reviewApplication.findUnique({ where: { id }, - include: { opportunity: true } + include: { opportunity: true }, }); if (!entity || !entity.id) { throw new NotFoundException('Review application not found.'); diff --git a/src/api/review-history/reviewHistory.controller.ts b/src/api/review-history/reviewHistory.controller.ts index c075cc1..e2de274 100644 --- a/src/api/review-history/reviewHistory.controller.ts +++ b/src/api/review-history/reviewHistory.controller.ts @@ -1,16 +1,28 @@ -import { Controller, ForbiddenException, Get, Param, Query, Req } from "@nestjs/common"; -import { ApiBearerAuth, ApiOperation, ApiQuery, ApiResponse, ApiTags } from "@nestjs/swagger"; -import { ReviewApplicationService } from "../review-application/reviewApplication.service"; -import { OkResponse, ResponseDto } from "src/dto/common.dto"; -import { ReviewApplicationResponseDto } from "src/dto/reviewApplication.dto"; -import { Roles } from "src/shared/guards/tokenRoles.guard"; -import { UserRole } from "src/shared/enums/userRole.enum"; -import { isAdmin, JwtUser } from "src/shared/modules/global/jwt.service"; +import { + Controller, + ForbiddenException, + Get, + Param, + Query, + Req, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiOperation, + ApiQuery, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import { ReviewApplicationService } from '../review-application/reviewApplication.service'; +import { OkResponse, ResponseDto } from 'src/dto/common.dto'; +import { ReviewApplicationResponseDto } from 'src/dto/reviewApplication.dto'; +import { Roles } from 'src/shared/guards/tokenRoles.guard'; +import { UserRole } from 'src/shared/enums/userRole.enum'; +import { isAdmin, JwtUser } from 'src/shared/modules/global/jwt.service'; @ApiTags('Review History') -@Controller('/api/review-history') +@Controller('/review-history') export class ReviewHistoryController { - constructor(private readonly service: ReviewApplicationService) {} @ApiOperation({ @@ -35,11 +47,17 @@ export class ReviewHistoryController { @ApiBearerAuth() @Roles(UserRole.Reviewer, UserRole.Admin) @Get('/:userId') - async getHistory(@Req() req: Request, @Param('userId') userId: string, @Query('range') range: number): Promise> { + async getHistory( + @Req() req: Request, + @Param('userId') userId: string, + @Query('range') range: number, + ): Promise> { // Check user permission const authUser: JwtUser = req['user'] as JwtUser; if (authUser.userId !== userId && !isAdmin(authUser)) { - throw new ForbiddenException('You cannot check this user\'s review history') + throw new ForbiddenException( + "You cannot check this user's review history", + ); } return OkResponse(await this.service.getHistory(userId, range)); } diff --git a/src/api/review-opportunity/reviewOpportunity.controller.ts b/src/api/review-opportunity/reviewOpportunity.controller.ts index 1ee8930..0778727 100644 --- a/src/api/review-opportunity/reviewOpportunity.controller.ts +++ b/src/api/review-opportunity/reviewOpportunity.controller.ts @@ -30,7 +30,7 @@ import { JwtUser } from 'src/shared/modules/global/jwt.service'; import { ReviewOpportunityService } from './reviewOpportunity.service'; @ApiTags('Review Opportunity') -@Controller('/api/review-opportunities') +@Controller('/review-opportunities') export class ReviewOpportunityController { constructor(private readonly service: ReviewOpportunityService) {} @@ -44,70 +44,70 @@ export class ReviewOpportunityController { description: 'payment min value', type: 'number', example: 0.0, - required: false + required: false, }) @ApiQuery({ name: 'paymentTo', description: 'payment max value', type: 'number', example: 200.0, - required: false + required: false, }) @ApiQuery({ name: 'startDateFrom', description: 'Start date min value', type: 'string', example: '2022-05-22T12:34:56', - required: false + required: false, }) @ApiQuery({ name: 'startDateTo', description: 'Start date max value', type: 'string', example: '2022-05-22T12:34:56', - required: false + required: false, }) @ApiQuery({ name: 'durationFrom', description: 'duration min value (seconds)', type: 'number', example: 86400, - required: false + required: false, }) @ApiQuery({ name: 'durationTo', description: 'duration max value (seconds)', type: 'number', example: 86400, - required: false + required: false, }) @ApiQuery({ name: 'numSubmissionsFrom', description: 'min number of submissions', type: 'number', example: 1, - required: false + required: false, }) @ApiQuery({ name: 'numSubmissionsTo', description: 'max number of submissions', type: 'number', example: 5, - required: false + required: false, }) @ApiQuery({ name: 'tracks', description: 'Challenge tracks', type: 'array', example: ['CODE'], - required: false + required: false, }) @ApiQuery({ name: 'skills', description: 'Skills of challenges', type: 'array', example: ['TypeScript'], - required: false + required: false, }) @ApiQuery({ name: 'sortBy', @@ -116,7 +116,7 @@ export class ReviewOpportunityController { type: 'string', example: 'basePayment', default: 'startDate', - required: false + required: false, }) @ApiQuery({ name: 'sortOrder', @@ -125,7 +125,7 @@ export class ReviewOpportunityController { type: 'string', example: 'asc', default: 'asc', - required: false + required: false, }) @ApiQuery({ name: 'limit', @@ -133,7 +133,7 @@ export class ReviewOpportunityController { type: 'number', example: 10, default: 10, - required: false + required: false, }) @ApiQuery({ name: 'offset', @@ -141,7 +141,7 @@ export class ReviewOpportunityController { type: 'number', example: 0, default: 0, - required: false + required: false, }) @ApiResponse({ status: 200, diff --git a/src/api/review-opportunity/reviewOpportunity.service.ts b/src/api/review-opportunity/reviewOpportunity.service.ts index da91274..7252413 100644 --- a/src/api/review-opportunity/reviewOpportunity.service.ts +++ b/src/api/review-opportunity/reviewOpportunity.service.ts @@ -1,4 +1,10 @@ -import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; import { convertRoleName, ReviewApplicationRole, @@ -21,7 +27,6 @@ import { PrismaService } from 'src/shared/modules/global/prisma.service'; @Injectable() export class ReviewOpportunityService { - private readonly logger: Logger = new Logger(ReviewOpportunityService.name); constructor( @@ -29,7 +34,6 @@ export class ReviewOpportunityService { private readonly challengeService: ChallengeApiService, ) {} - /** * Search Review Opportunities * @param dto query dto @@ -38,49 +42,69 @@ export class ReviewOpportunityService { // filter data with payment, duration and start date const prismaFilter = { include: { applications: true }, - where: { AND: [{ - status: ReviewOpportunityStatus.OPEN - }] as any[] } - } + where: { + AND: [ + { + status: ReviewOpportunityStatus.OPEN, + }, + ] as any[], + }, + }; if (dto.paymentFrom) { - prismaFilter.where.AND.push({ basePayment: { gte: dto.paymentFrom } }) + prismaFilter.where.AND.push({ basePayment: { gte: dto.paymentFrom } }); } if (dto.paymentTo) { - prismaFilter.where.AND.push({ basePayment: { lte: dto.paymentTo } }) + prismaFilter.where.AND.push({ basePayment: { lte: dto.paymentTo } }); } if (dto.durationFrom) { - prismaFilter.where.AND.push({ duration: { gte: dto.durationFrom } }) + prismaFilter.where.AND.push({ duration: { gte: dto.durationFrom } }); } if (dto.durationTo) { - prismaFilter.where.AND.push({ duration: { lte: dto.durationTo } }) + prismaFilter.where.AND.push({ duration: { lte: dto.durationTo } }); } if (dto.startDateFrom) { - prismaFilter.where.AND.push({ startDate: { gte: dto.startDateFrom } }) + prismaFilter.where.AND.push({ startDate: { gte: dto.startDateFrom } }); } if (dto.startDateTo) { - prismaFilter.where.AND.push({ startDate: { lte: dto.startDateTo } }) + prismaFilter.where.AND.push({ startDate: { lte: dto.startDateTo } }); } // query data from db - const entityList = await this.prisma.reviewOpportunity.findMany(prismaFilter); + const entityList = + await this.prisma.reviewOpportunity.findMany(prismaFilter); // build result with challenge data let responseList = await this.assembleList(entityList); // filter with challenge fields if (dto.numSubmissionsFrom) { - responseList = responseList.filter(r => (r.submissions ?? 0) >= (dto.numSubmissionsFrom ?? 0)) + responseList = responseList.filter( + (r) => (r.submissions ?? 0) >= (dto.numSubmissionsFrom ?? 0), + ); } if (dto.numSubmissionsTo) { - responseList = responseList.filter(r => (r.submissions ?? 0) <= (dto.numSubmissionsTo ?? 0)) + responseList = responseList.filter( + (r) => (r.submissions ?? 0) <= (dto.numSubmissionsTo ?? 0), + ); } if (dto.tracks && dto.tracks.length > 0) { - responseList = responseList.filter(r => r.challengeData && dto.tracks?.includes(r.challengeData['track'] as string)) + responseList = responseList.filter( + (r) => + r.challengeData && + dto.tracks?.includes(r.challengeData['track'] as string), + ); } if (dto.skills && dto.skills.length > 0) { - responseList = responseList.filter(r => r.challengeData && - (r.challengeData['technologies'] as string[]).some(e => dto.skills?.includes(e))) + responseList = responseList.filter( + (r) => + r.challengeData && + (r.challengeData['technologies'] as string[]).some((e) => + dto.skills?.includes(e), + ), + ); } // sort list responseList = [...responseList].sort((a, b) => { - return dto.sortOrder === 'asc' ? (a[dto.sortBy] - b[dto.sortBy]) : (b[dto.sortBy] - a[dto.sortBy]); + return dto.sortOrder === 'asc' + ? a[dto.sortBy] - b[dto.sortBy] + : b[dto.sortBy] - a[dto.sortBy]; }); // pagination const start = Math.max(0, dto.offset as number); @@ -108,18 +132,20 @@ export class ReviewOpportunityService { ); } catch (e) { // challenge doesn't exist. Return 400 - this.logger.error('Can\'t get challenge:', e) + this.logger.error("Can't get challenge:", e); throw new BadRequestException("Challenge doesn't exist"); } // check existing const existing = await this.prisma.reviewOpportunity.findMany({ where: { challengeId: dto.challengeId, - type: dto.type - } + type: dto.type, + }, }); if (existing && existing.length > 0) { - throw new ConflictException('Review opportunity exists for challenge and type'); + throw new ConflictException( + 'Review opportunity exists for challenge and type', + ); } const entity = await this.prisma.reviewOpportunity.create({ data: { @@ -128,7 +154,7 @@ export class ReviewOpportunityService { updatedBy: authUser.userId ?? '', }, }); - return await this.buildResponse(entity, challengeData); + return this.buildResponse(entity, challengeData); } /** @@ -197,23 +223,22 @@ export class ReviewOpportunityService { * @returns response list */ private async assembleList( - entityList, + entityList: any[], ): Promise { // get challenge id and remove duplicated - const challengeIdList = [...new Set(entityList.map((e) => e.challengeId))]; + const challengeIdList: string[] = [ + ...new Set(entityList.map((e: any) => e.challengeId as string)), + ]; // get all challenge data - const challengeList = await this.challengeService.getChallenges( - challengeIdList as string[], - ); + const challengeList = + await this.challengeService.getChallenges(challengeIdList); // build challenge id -> challenge data map const challengeMap = new Map(); challengeList.forEach((c) => challengeMap.set(c.id, c)); // build response list. return entityList.map((e) => { - return this.buildResponse(e, challengeMap.get(e.challengeId)) - } - - ); + return this.buildResponse(e, challengeMap.get(e.challengeId)); + }); } /** @@ -235,7 +260,7 @@ export class ReviewOpportunityService { * @returns response dto */ private buildResponse( - entity, + entity: any, challengeData: ChallengeData, ): ReviewOpportunityResponseDto { const ret = new ReviewOpportunityResponseDto(); @@ -272,7 +297,7 @@ export class ReviewOpportunityService { applicationDate: e.createdBy, })); } - + // payments ret.payments = []; const paymentConfig = CommonConfig.reviewPaymentConfig; diff --git a/src/api/review-summation/review-summation.controller.ts b/src/api/review-summation/review-summation.controller.ts index 3766718..da9fefc 100644 --- a/src/api/review-summation/review-summation.controller.ts +++ b/src/api/review-summation/review-summation.controller.ts @@ -44,9 +44,7 @@ import { JwtUser } from 'src/shared/modules/global/jwt.service'; export class ReviewSummationController { private readonly logger: LoggerService; - constructor( - private readonly service: ReviewSummationService, - ) { + constructor(private readonly service: ReviewSummationService) { this.logger = LoggerService.forRoot(ReviewSummationController.name); } @@ -57,7 +55,10 @@ export class ReviewSummationController { summary: 'Create a new review summation', description: 'Roles: Admin, Copilot | Scopes: create:review_summation', }) - @ApiBody({ description: 'Review summation data', type: ReviewSummationRequestDto }) + @ApiBody({ + description: 'Review summation data', + type: ReviewSummationRequestDto, + }) @ApiResponse({ status: 201, description: 'Review summation created successfully.', diff --git a/src/api/review-summation/review-summation.service.ts b/src/api/review-summation/review-summation.service.ts index bae433b..d4aaa16 100644 --- a/src/api/review-summation/review-summation.service.ts +++ b/src/api/review-summation/review-summation.service.ts @@ -1,11 +1,14 @@ -import { Injectable, Logger, NotFoundException } from "@nestjs/common"; -import { PaginationDto } from "src/dto/pagination.dto"; -import { ReviewSummationQueryDto, ReviewSummationRequestDto, ReviewSummationResponseDto, ReviewSummationUpdateRequestDto } from "src/dto/reviewSummation.dto"; -import { SortDto } from "src/dto/sort.dto"; -import { JwtUser } from "src/shared/modules/global/jwt.service"; -import { PrismaService } from "src/shared/modules/global/prisma.service"; - - +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { PaginationDto } from 'src/dto/pagination.dto'; +import { + ReviewSummationQueryDto, + ReviewSummationRequestDto, + ReviewSummationResponseDto, + ReviewSummationUpdateRequestDto, +} from 'src/dto/reviewSummation.dto'; +import { SortDto } from 'src/dto/sort.dto'; +import { JwtUser } from 'src/shared/modules/global/jwt.service'; +import { PrismaService } from 'src/shared/modules/global/prisma.service'; @Injectable() export class ReviewSummationService { @@ -20,7 +23,7 @@ export class ReviewSummationService { createdBy: String(authUser.userId) || '', createdAt: new Date(), updatedBy: String(authUser.userId) || '', - } + }, }); this.logger.log(`Review summation created with ID: ${data.id}`); return data as ReviewSummationResponseDto; @@ -102,7 +105,7 @@ export class ReviewSummationService { async updateSummation( authUser: JwtUser, id: string, - body: ReviewSummationUpdateRequestDto + body: ReviewSummationUpdateRequestDto, ) { await this.checkSummation(id); const data = await this.prisma.reviewSummation.update({ @@ -110,8 +113,8 @@ export class ReviewSummationService { data: { ...body, updatedBy: String(authUser.userId) || '', - updatedAt: new Date() - } + updatedAt: new Date(), + }, }); this.logger.log(`Review type updated successfully: ${id}`); return data as ReviewSummationResponseDto; @@ -120,13 +123,13 @@ export class ReviewSummationService { async deleteSummation(id: string) { await this.checkSummation(id); await this.prisma.reviewSummation.delete({ - where: { id } + where: { id }, }); } private async checkSummation(id: string) { const data = await this.prisma.reviewSummation.findUnique({ - where: { id } + where: { id }, }); if (!data || !data.id) { throw new NotFoundException(`Review summation not found with id ${id}`); diff --git a/src/api/review/review.controller.ts b/src/api/review/review.controller.ts index bc282c6..38e3628 100644 --- a/src/api/review/review.controller.ts +++ b/src/api/review/review.controller.ts @@ -42,7 +42,7 @@ import { PrismaErrorService } from '../../shared/modules/global/prisma-error.ser @ApiTags('Reviews') @ApiBearerAuth() -@Controller('/api/reviews') +@Controller('/reviews') export class ReviewController { private readonly logger: LoggerService; @@ -428,11 +428,11 @@ export class ReviewController { // Deduplicate reviews by ID const uniqueReviews = Object.values( - reviews.reduce((acc, review) => { + reviews.reduce((acc: Record, review) => { if (!acc[review.id]) { acc[review.id] = review; } - return acc; // eslint-disable-line @typescript-eslint/no-unsafe-return + return acc; }, {}), ); diff --git a/src/api/scorecard/scorecard.controller.ts b/src/api/scorecard/scorecard.controller.ts index 361a6b9..1bdf59d 100644 --- a/src/api/scorecard/scorecard.controller.ts +++ b/src/api/scorecard/scorecard.controller.ts @@ -33,7 +33,7 @@ import { PrismaService } from '../../shared/modules/global/prisma.service'; @ApiTags('Scorecard') @ApiBearerAuth() -@Controller('/api/scorecards') +@Controller('/scorecards') export class ScorecardController { constructor(private readonly prisma: PrismaService) {} diff --git a/src/api/submission/submission.controller.ts b/src/api/submission/submission.controller.ts index 721c1c4..637ee8c 100644 --- a/src/api/submission/submission.controller.ts +++ b/src/api/submission/submission.controller.ts @@ -56,9 +56,7 @@ import { JwtUser } from 'src/shared/modules/global/jwt.service'; export class SubmissionController { private readonly logger: LoggerService; - constructor( - private readonly service: SubmissionService, - ) { + constructor(private readonly service: SubmissionService) { this.logger = LoggerService.forRoot('SubmissionController'); } diff --git a/src/api/submission/submission.service.ts b/src/api/submission/submission.service.ts index 4a3dba0..8739e72 100644 --- a/src/api/submission/submission.service.ts +++ b/src/api/submission/submission.service.ts @@ -1,20 +1,24 @@ -import { Injectable, Logger, NotFoundException } from "@nestjs/common"; -import { SubmissionStatus, SubmissionType } from "@prisma/client"; -import { PaginationDto } from "src/dto/pagination.dto"; -import { ReviewResponseDto } from "src/dto/review.dto"; -import { SortDto } from "src/dto/sort.dto"; -import { SubmissionQueryDto, SubmissionRequestDto, SubmissionResponseDto, SubmissionUpdateRequestDto } from "src/dto/submission.dto"; -import { JwtUser } from "src/shared/modules/global/jwt.service"; -import { PrismaService } from "src/shared/modules/global/prisma.service"; -import { Utils } from "src/shared/modules/global/utils.service"; - +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { SubmissionStatus, SubmissionType } from '@prisma/client'; +import { PaginationDto } from 'src/dto/pagination.dto'; +import { ReviewResponseDto } from 'src/dto/review.dto'; +import { SortDto } from 'src/dto/sort.dto'; +import { + SubmissionQueryDto, + SubmissionRequestDto, + SubmissionResponseDto, + SubmissionUpdateRequestDto, +} from 'src/dto/submission.dto'; +import { JwtUser } from 'src/shared/modules/global/jwt.service'; +import { PrismaService } from 'src/shared/modules/global/prisma.service'; +import { Utils } from 'src/shared/modules/global/utils.service'; @Injectable() export class SubmissionService { private readonly logger = new Logger(SubmissionService.name); constructor(private readonly prisma: PrismaService) {} - + async createSubmission(authUser: JwtUser, body: SubmissionRequestDto) { const data = await this.prisma.submission.create({ data: { @@ -22,7 +26,7 @@ export class SubmissionService { status: SubmissionStatus.ACTIVE, type: body.type as SubmissionType, createdBy: String(authUser.userId) || '', - createdAt: new Date() + createdAt: new Date(), }, }); this.logger.log(`Submission created with ID: ${data.id}`); @@ -91,7 +95,7 @@ export class SubmissionService { ); return { - data: submissions.map(this.buildResponse), + data: submissions.map((submission) => this.buildResponse(submission)), meta: { page, perPage, @@ -107,32 +111,31 @@ export class SubmissionService { } async updateSubmission( - authUser: JwtUser, - submissionId: string, - body: SubmissionUpdateRequestDto + authUser: JwtUser, + submissionId: string, + body: SubmissionUpdateRequestDto, ) { const existing = await this.checkSubmission(submissionId); const data = await this.prisma.submission.update({ where: { id: submissionId }, data: { ...body, - type: body.type as SubmissionType || existing.type, + type: (body.type as SubmissionType) || existing.type, updatedBy: String(authUser.userId) || '', - updatedAt: new Date() - } + updatedAt: new Date(), + }, }); this.logger.log(`Submission updated successfully: ${submissionId}`); return this.buildResponse(data); } - + async deleteSubmission(id: string) { await this.checkSubmission(id); await this.prisma.submission.delete({ - where: { id } + where: { id }, }); } - private async checkSubmission(id: string) { const data = await this.prisma.submission.findUnique({ where: { id }, @@ -144,8 +147,8 @@ export class SubmissionService { return data; } - private buildResponse(data): SubmissionResponseDto { - const dto = { + private buildResponse(data: any): SubmissionResponseDto { + const dto: SubmissionResponseDto = { ...data, legacyChallengeId: Utils.bigIntToNumber(data.legacyChallengeId), prizeId: Utils.bigIntToNumber(data.prizeId), diff --git a/src/api/webhook/interfaces/gitea-webhook.interface.ts b/src/api/webhook/interfaces/gitea-webhook.interface.ts new file mode 100644 index 0000000..7a6cf12 --- /dev/null +++ b/src/api/webhook/interfaces/gitea-webhook.interface.ts @@ -0,0 +1,24 @@ +export interface GitHubWebhookHeaders { + 'x-github-delivery': string; + 'x-github-event': string; + 'x-hub-signature-256': string; + 'content-type': string; +} + +export interface WebhookRequest { + headers: GitHubWebhookHeaders; + body: any; // GitHub webhook payload (varies by event type) +} + +export interface WebhookResponse { + success: boolean; + message?: string; +} + +export interface ErrorResponse { + statusCode: number; + message: string; + error: string; + timestamp: string; + path: string; +} diff --git a/src/api/webhook/webhook.controller.ts b/src/api/webhook/webhook.controller.ts new file mode 100644 index 0000000..9bf7f5c --- /dev/null +++ b/src/api/webhook/webhook.controller.ts @@ -0,0 +1,109 @@ +import { + Controller, + Post, + Body, + Headers, + HttpCode, + HttpStatus, + UseGuards, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiHeader } from '@nestjs/swagger'; +import { WebhookService } from './webhook.service'; +import { + WebhookEventDto, + WebhookResponseDto, +} from '../../dto/webhook-event.dto'; +import { GiteaWebhookAuthGuard } from '../../shared/guards/gitea-webhook-auth.guard'; +import { LoggerService } from '../../shared/modules/global/logger.service'; + +@ApiTags('Webhooks') +@Controller('webhooks') +export class WebhookController { + private readonly logger = LoggerService.forRoot('WebhookController'); + + constructor(private readonly webhookService: WebhookService) {} + + @Post('gitea') + @HttpCode(HttpStatus.OK) + @UseGuards(GiteaWebhookAuthGuard) + @ApiOperation({ + summary: 'Gitea Webhook Endpoint', + description: + 'Receives and processes Gitea webhook events with signature verification', + }) + @ApiHeader({ + name: 'X-Gitea-Delivery', + description: 'Gitea delivery UUID', + required: true, + }) + @ApiHeader({ + name: 'X-Gitea-Event', + description: 'Gitea event type', + required: true, + }) + @ApiHeader({ + name: 'authorization', + description: 'Authorization header for Gitea webhook', + required: true, + }) + @ApiResponse({ + status: 200, + description: 'Webhook processed successfully', + type: WebhookResponseDto, + }) + @ApiResponse({ + status: 400, + description: 'Bad Request - Missing required headers or invalid payload', + }) + @ApiResponse({ + status: 403, + description: 'Forbidden - Invalid signature', + }) + @ApiResponse({ + status: 500, + description: 'Internal Server Error - Processing failed', + }) + async handleGiteaWebhook( + @Body() payload: any, + @Headers('x-gitea-delivery') delivery: string, + @Headers('x-gitea-event') event: string, + ): Promise { + try { + this.logger.log({ + message: 'Received Gitea webhook', + delivery, + event, + timestamp: new Date().toISOString(), + }); + + // Create webhook event DTO + const webhookEvent: WebhookEventDto = { + eventId: delivery, + event: event, + eventPayload: payload, + }; + + // Process the webhook + const result = await this.webhookService.processWebhook(webhookEvent); + + this.logger.log({ + message: 'Successfully processed Gitea webhook', + delivery, + event, + success: result.success, + }); + + return result; + } catch (error) { + this.logger.error({ + message: 'Failed to process Gitea webhook', + delivery, + event, + error: error.message, + stack: error.stack, + }); + + throw error; + } + } +} diff --git a/src/api/webhook/webhook.service.ts b/src/api/webhook/webhook.service.ts new file mode 100644 index 0000000..257e8ba --- /dev/null +++ b/src/api/webhook/webhook.service.ts @@ -0,0 +1,174 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../shared/modules/global/prisma.service'; +import { LoggerService } from '../../shared/modules/global/logger.service'; +import { PrismaErrorService } from '../../shared/modules/global/prisma-error.service'; +import { + WebhookEventDto, + WebhookResponseDto, +} from '../../dto/webhook-event.dto'; + +@Injectable() +export class WebhookService { + private readonly logger = LoggerService.forRoot('WebhookService'); + + constructor( + private readonly prisma: PrismaService, + private readonly prismaErrorService: PrismaErrorService, + ) {} + + async processWebhook( + webhookEvent: WebhookEventDto, + ): Promise { + try { + this.logger.log({ + message: 'Processing GitHub webhook event', + eventId: webhookEvent.eventId, + event: webhookEvent.event, + timestamp: new Date().toISOString(), + }); + + // Store webhook event in database + const storedEvent = await this.prisma.gitWebhookLog.create({ + data: { + eventId: webhookEvent.eventId, + event: webhookEvent.event, + eventPayload: webhookEvent.eventPayload, + }, + }); + + this.logger.log({ + message: 'Successfully stored webhook event', + eventId: webhookEvent.eventId, + event: webhookEvent.event, + storedId: storedEvent.id, + createdAt: storedEvent.createdAt, + }); + + // Future extensibility: Add event-specific handlers here + this.handleEventSpecificProcessing( + webhookEvent.event, + webhookEvent.eventPayload, + ); + + return { + success: true, + message: 'Webhook processed successfully', + }; + } catch (error) { + this.logger.error({ + message: 'Failed to process webhook event', + eventId: webhookEvent.eventId, + event: webhookEvent.event, + error: error.message, + stack: error.stack, + }); + + // Handle Prisma errors with the existing error service + if (error.code) { + this.prismaErrorService.handleError(error); + } + + throw error; + } + } + + /** + * Placeholder for future event-specific processing logic + * This method can be extended to handle different GitHub events differently + */ + private handleEventSpecificProcessing(event: string, payload: any): void { + this.logger.log({ + message: 'Event-specific processing placeholder', + event, + payloadSize: JSON.stringify(payload).length, + }); + + // Future implementation examples: + // switch (event) { + // case 'push': + // await this.handlePushEvent(payload); + // break; + // case 'pull_request': + // await this.handlePullRequestEvent(payload); + // break; + // case 'issues': + // await this.handleIssuesEvent(payload); + // break; + // default: + // this.logger.log(`No specific handler for event type: ${event}`); + // } + } + + /** + * Get webhook logs with pagination and filtering + * This method provides basic querying capabilities for webhook events + */ + async getWebhookLogs(options: { + eventId?: string; + event?: string; + limit?: number; + offset?: number; + startDate?: Date; + endDate?: Date; + }) { + try { + const { + eventId, + event, + limit = 50, + offset = 0, + startDate, + endDate, + } = options; + + const where: any = {}; + + if (eventId) { + where.eventId = eventId; + } + + if (event) { + where.event = event; + } + + if (startDate || endDate) { + where.createdAt = {}; + if (startDate) { + where.createdAt.gte = startDate; + } + if (endDate) { + where.createdAt.lte = endDate; + } + } + + const [logs, total] = await this.prisma.$transaction([ + this.prisma.gitWebhookLog.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take: limit, + skip: offset, + }), + this.prisma.gitWebhookLog.count({ where }), + ]); + + return { + logs, + total, + limit, + offset, + }; + } catch (error) { + this.logger.error({ + message: 'Failed to retrieve webhook logs', + error: error.message, + options, + }); + + if (error.code) { + this.prismaErrorService.handleError(error); + } + + throw error; + } + } +} diff --git a/src/dto/contactRequest.dto.ts b/src/dto/contactRequest.dto.ts index ea6f063..ffa6f2d 100644 --- a/src/dto/contactRequest.dto.ts +++ b/src/dto/contactRequest.dto.ts @@ -1,19 +1,23 @@ import { ApiProperty } from '@nestjs/swagger'; +import { IsString } from 'class-validator'; export class ContactRequestBaseDto { @ApiProperty({ description: 'The ID of the resource', example: 'user123' }) + @IsString() resourceId: string; @ApiProperty({ description: 'The associated challenge ID', example: 'challenge456', }) + @IsString() challengeId: string; @ApiProperty({ description: 'The message content', example: 'I have a question regarding the challenge rules.', }) + @IsString() message: string; } diff --git a/src/dto/reviewOpportunity.dto.ts b/src/dto/reviewOpportunity.dto.ts index 1c172cd..f6fd49e 100644 --- a/src/dto/reviewOpportunity.dto.ts +++ b/src/dto/reviewOpportunity.dto.ts @@ -1,4 +1,9 @@ -import { ApiProperty, ApiPropertyOptional, OmitType, PartialType } from '@nestjs/swagger'; +import { + ApiProperty, + ApiPropertyOptional, + OmitType, + PartialType, +} from '@nestjs/swagger'; import { IsArray, IsDateString, @@ -110,12 +115,10 @@ export class CreateReviewOpportunityDto { incrementalPayment: number; } - export class UpdateReviewOpportunityDto extends PartialType( - OmitType(CreateReviewOpportunityDto, ['challengeId', 'type']) + OmitType(CreateReviewOpportunityDto, ['challengeId', 'type']), ) {} - export class ReviewPaymentDto { @ApiProperty({ description: 'Review application role name', diff --git a/src/dto/reviewSummation.dto.ts b/src/dto/reviewSummation.dto.ts index 6efa18a..6527788 100644 --- a/src/dto/reviewSummation.dto.ts +++ b/src/dto/reviewSummation.dto.ts @@ -101,13 +101,9 @@ export class ReviewSummationBaseRequestDto { reviewedDate?: string; } -export class ReviewSummationRequestDto extends ReviewSummationBaseRequestDto { +export class ReviewSummationRequestDto extends ReviewSummationBaseRequestDto {} -} - -export class ReviewSummationPutRequestDto extends ReviewSummationBaseRequestDto { - -} +export class ReviewSummationPutRequestDto extends ReviewSummationBaseRequestDto {} export class ReviewSummationUpdateRequestDto { @ApiProperty({ diff --git a/src/dto/submission.dto.ts b/src/dto/submission.dto.ts index c212d31..695f409 100644 --- a/src/dto/submission.dto.ts +++ b/src/dto/submission.dto.ts @@ -92,7 +92,7 @@ export class SubmissionRequestBaseDto { @ApiProperty({ description: 'The submission type', example: 'ContestSubmission', - enum: Object.values(SubmissionType) + enum: Object.values(SubmissionType), }) @IsString() @IsNotEmpty() @@ -156,13 +156,9 @@ export class SubmissionRequestBaseDto { submittedDate?: string; } -export class SubmissionRequestDto extends SubmissionRequestBaseDto { - -} +export class SubmissionRequestDto extends SubmissionRequestBaseDto {} -export class SubmissionPutRequestDto extends SubmissionRequestBaseDto { - -} +export class SubmissionPutRequestDto extends SubmissionRequestBaseDto {} export class SubmissionUpdateRequestDto { @ApiProperty({ diff --git a/src/dto/upload.dto.ts b/src/dto/upload.dto.ts index 58cf287..97beaca 100644 --- a/src/dto/upload.dto.ts +++ b/src/dto/upload.dto.ts @@ -1,12 +1,11 @@ - export enum UploadStatus { ACTIVE = 'ACTIVE', - DELETED = 'DELETED' + DELETED = 'DELETED', } export enum UploadType { SUBMISSION = 'SUBMISSION', TEST_CASE = 'TEST_CASE', FINAL_FIX = 'FINAL_FIX', - REVIEW_DOCUMENT = 'REVIEW_DOCUMENT' + REVIEW_DOCUMENT = 'REVIEW_DOCUMENT', } diff --git a/src/dto/webhook-event.dto.ts b/src/dto/webhook-event.dto.ts new file mode 100644 index 0000000..718fc65 --- /dev/null +++ b/src/dto/webhook-event.dto.ts @@ -0,0 +1,23 @@ +import { IsString, IsNotEmpty, IsOptional } from 'class-validator'; + +export class WebhookEventDto { + @IsString() + @IsNotEmpty() + eventId: string; // From X-GitHub-Delivery + + @IsString() + @IsNotEmpty() + event: string; // From X-GitHub-Event + + @IsNotEmpty() + eventPayload: any; // Complete webhook payload +} + +export class WebhookResponseDto { + @IsNotEmpty() + success: boolean; + + @IsString() + @IsOptional() + message?: string; +} diff --git a/src/main.ts b/src/main.ts index 0b986a3..cfba064 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,6 +6,9 @@ import { NestExpressApplication } from '@nestjs/platform-express'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { ApiModule } from './api/api.module'; import { LoggerService } from './shared/modules/global/logger.service'; +import { Response } from 'express'; + +const API_PREFIX = '/v6/review'; // Global prefix for all routes in production async function bootstrap() { const app = await NestFactory.create(AppModule, { @@ -16,10 +19,10 @@ async function bootstrap() { // Create logger instance for application bootstrap const logger = LoggerService.forRoot('Bootstrap'); - // Global prefix for all routes in production is configured as `/v5/review` + // Global prefix for all routes in production if (process.env.NODE_ENV === 'production') { - app.setGlobalPrefix('/v5/review'); - logger.log('Setting global prefix to /v5/review in production mode'); + app.setGlobalPrefix(API_PREFIX); + logger.log(`Setting global prefix to ${API_PREFIX} in production mode`); } console.log( @@ -58,7 +61,7 @@ async function bootstrap() { // Intercept response to log it const originalSend = res.send; - res.send = function (body) { + res.send = function (body: any): Response { const responseTime = Date.now() - startTime; const statusCode = res.statusCode; @@ -76,8 +79,7 @@ async function bootstrap() { let responseBody; try { responseBody = typeof body === 'string' ? JSON.parse(body) : body; - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (error) { + } catch { responseBody = body; } @@ -89,7 +91,7 @@ async function bootstrap() { }); } - return originalSend.call(this, body); // eslint-disable-line @typescript-eslint/no-unsafe-return + return originalSend.call(this, body) as Response; }; next(); @@ -148,20 +150,12 @@ async function bootstrap() { const document = SwaggerModule.createDocument(app, config, { include: [ApiModule], }); - if (process.env.NODE_ENV === 'production') { - SwaggerModule.setup('/v5/review/api-docs', app, document); - } else { - SwaggerModule.setup('/api-docs', app, document); - } + SwaggerModule.setup(`${API_PREFIX}/api-docs`, app, document); logger.log('Swagger documentation configured'); // Add an event handler to log uncaught promise rejections and prevent the server from crashing - process.on('unhandledRejection', (reason, promise) => { - logger.error( - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `Unhandled Promise Rejection at: ${promise}`, // eslint-disable-line @typescript-eslint/no-base-to-string - reason as string, - ); + process.on('unhandledRejection', (reason) => { + logger.error('Unhandled Promise Rejection', reason as string); }); // Add an event handler to log uncaught errors and prevent the server from crashing diff --git a/src/shared/config/common.config.ts b/src/shared/config/common.config.ts index 69d1126..ab04755 100644 --- a/src/shared/config/common.config.ts +++ b/src/shared/config/common.config.ts @@ -47,8 +47,11 @@ export const CommonConfig = { busApiUrl: process.env.BUS_API_URL ?? 'http://localhost:4000/eventBus', challengeApiUrl: process.env.CHALLENGE_API_URL ?? 'http://localhost:4000/challenges/', + resourceApiUrl: + process.env.RESOURCE_API_URL ?? 'https://api.topcoder-dev.com/v6/', memberApiUrl: process.env.MEMBER_API_URL ?? 'http://localhost:4000/members', - onlineReviewUrlBase: 'https://software.topcoder.com/review/actions/ViewProjectDetails?pid=' + onlineReviewUrlBase: + 'https://software.topcoder.com/review/actions/ViewProjectDetails?pid=', }, // configs of payment for each review type reviewPaymentConfig: paymentConfig, diff --git a/src/shared/enums/userRole.enum.ts b/src/shared/enums/userRole.enum.ts index 2da33b5..9cd0b2d 100644 --- a/src/shared/enums/userRole.enum.ts +++ b/src/shared/enums/userRole.enum.ts @@ -7,4 +7,5 @@ export enum UserRole { Reviewer = 'reviewer', Submitter = 'Submitter', User = 'Topcoder User', + Talent = 'Topcoder Talent', } diff --git a/src/shared/guards/gitea-webhook-auth.guard.ts b/src/shared/guards/gitea-webhook-auth.guard.ts new file mode 100644 index 0000000..1506cfa --- /dev/null +++ b/src/shared/guards/gitea-webhook-auth.guard.ts @@ -0,0 +1,69 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + ForbiddenException, + BadRequestException, + InternalServerErrorException, +} from '@nestjs/common'; +import { Request } from 'express'; +import { LoggerService } from '../modules/global/logger.service'; + +@Injectable() +export class GiteaWebhookAuthGuard implements CanActivate { + private readonly logger = LoggerService.forRoot('GiteaWebhookAuthGuard'); + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const delivery = request.headers['x-gitea-delivery'] as string; + const event = request.headers['x-gitea-event'] as string; + const authHeader = request.headers['authorization'] as string; + + // Check if GITEA_WEBHOOK_AUTH is configured + const auth = process.env.GITEA_WEBHOOK_AUTH; + if (!auth) { + this.logger.error( + 'GITEA_WEBHOOK_AUTH environment variable is not configured', + ); + throw new InternalServerErrorException('Webhook auth not configured'); + } + + if (!delivery) { + this.logger.error('Missing X-Gitea-Delivery header'); + throw new BadRequestException('Missing delivery header'); + } + + if (!event) { + this.logger.error('Missing X-Gitea-Event header'); + throw new BadRequestException('Missing event header'); + } + + try { + // Validate the authorization header + if (!authHeader) { + this.logger.error('Missing Authorization header'); + throw new BadRequestException('Missing authorization header'); + } + + if (authHeader !== `Bearer ${auth}`) { + this.logger.error('Invalid authorization header'); + throw new ForbiddenException('Invalid authorization'); + } + + this.logger.log( + `Valid webhook authorization verified for delivery ${delivery}, event ${event}`, + ); + return true; + } catch (error) { + if ( + error instanceof ForbiddenException || + error instanceof BadRequestException + ) { + throw error; + } + + this.logger.error(`Error validating webhook signature: ${error.message}`); + throw new InternalServerErrorException('Signature validation failed'); + } + } +} diff --git a/src/shared/models/ResourceInfo.model.ts b/src/shared/models/ResourceInfo.model.ts new file mode 100644 index 0000000..5e7c3f9 --- /dev/null +++ b/src/shared/models/ResourceInfo.model.ts @@ -0,0 +1,14 @@ +/** + * Resource info model + */ +export interface ResourceInfo { + id: string; + challengeId: string; + memberId: string; + memberHandle: string; + roleId: string; + roleName?: string; // this field is calculated + createdBy: string; + created: string | Date; + rating?: number; +} diff --git a/src/shared/models/ResourceRole.model.ts b/src/shared/models/ResourceRole.model.ts new file mode 100644 index 0000000..9d36527 --- /dev/null +++ b/src/shared/models/ResourceRole.model.ts @@ -0,0 +1,12 @@ +/** + * Resource role model + */ +export interface ResourceRole { + id: string; + name: string; + legacyId?: number; + fullReadAccess: boolean; + fullWriteAccess: boolean; + isActive: boolean; + selfObtainable: boolean; +} diff --git a/src/shared/modules/global/eventBus.service.ts b/src/shared/modules/global/eventBus.service.ts index 0600acc..2b7cfbe 100644 --- a/src/shared/modules/global/eventBus.service.ts +++ b/src/shared/modules/global/eventBus.service.ts @@ -29,7 +29,7 @@ export class EventBusSendEmailPayload { challengeName: string; }; from: Record = { - email: 'Topcoder ' + email: 'Topcoder ', }; version: string = 'v3'; sendgrid_template_id: string; @@ -68,9 +68,10 @@ export class EventBusService { }, }), ); + const responseStatus: HttpStatus = response.status as HttpStatus; if ( - response.status !== HttpStatus.OK && - response.status !== HttpStatus.NO_CONTENT + responseStatus !== HttpStatus.OK && + responseStatus !== HttpStatus.NO_CONTENT ) { throw new Error(`Event bus status code: ${response.status}`); } diff --git a/src/shared/modules/global/globalProviders.module.ts b/src/shared/modules/global/globalProviders.module.ts index a763d15..d3af074 100644 --- a/src/shared/modules/global/globalProviders.module.ts +++ b/src/shared/modules/global/globalProviders.module.ts @@ -10,12 +10,14 @@ import { M2MService } from './m2m.service'; import { ChallengeApiService } from './challenge.service'; import { EventBusService } from './eventBus.service'; import { MemberService } from './member.service'; +import { ResourceApiService } from './resource.service'; +import { KafkaModule } from '../kafka/kafka.module'; // Global module for providing global providers // Add any provider you want to be global here @Global() @Module({ - imports: [HttpModule], + imports: [HttpModule, KafkaModule.forRoot()], providers: [ { provide: APP_GUARD, @@ -32,6 +34,7 @@ import { MemberService } from './member.service'; PrismaErrorService, M2MService, ChallengeApiService, + ResourceApiService, EventBusService, MemberService, ], @@ -42,6 +45,7 @@ import { MemberService } from './member.service'; PrismaErrorService, M2MService, ChallengeApiService, + ResourceApiService, EventBusService, MemberService, ], diff --git a/src/shared/modules/global/jwt.service.ts b/src/shared/modules/global/jwt.service.ts index 3b500bc..dc4b5b8 100644 --- a/src/shared/modules/global/jwt.service.ts +++ b/src/shared/modules/global/jwt.service.ts @@ -19,7 +19,7 @@ export interface JwtUser { export const isAdmin = (user: JwtUser): boolean => { return user.isMachine || (user.roles ?? []).includes(UserRole.Admin); -} +}; // Map for testing tokens, will be removed in production const TOKEN_ROLE_MAP: Record = { @@ -117,7 +117,7 @@ export class JwtService implements OnModuleInit { throw new UnauthorizedException('Invalid token'); } - const user: JwtUser = {isMachine: false}; + const user: JwtUser = { isMachine: false }; // Check for M2M token from Auth0 if (decodedToken.scope) { @@ -136,7 +136,7 @@ export class JwtService implements OnModuleInit { user.userId = decodedToken[key] as string; } if (key.endsWith('roles')) { - user.roles = decodedToken[key] as UserRole[] + user.roles = decodedToken[key] as UserRole[]; } } } diff --git a/src/shared/modules/global/logger.service.ts b/src/shared/modules/global/logger.service.ts index 697e67f..8dc9498 100644 --- a/src/shared/modules/global/logger.service.ts +++ b/src/shared/modules/global/logger.service.ts @@ -51,8 +51,7 @@ export class LoggerService implements NestLoggerService { if (typeof message === 'object') { try { logMessage = JSON.stringify(message); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (e) { + } catch { logMessage = String(message); } } else { diff --git a/src/shared/modules/global/prisma-error.service.ts b/src/shared/modules/global/prisma-error.service.ts index 065fe9b..8998a7f 100644 --- a/src/shared/modules/global/prisma-error.service.ts +++ b/src/shared/modules/global/prisma-error.service.ts @@ -78,7 +78,9 @@ export class PrismaErrorService { const targetField = error.meta?.target ? Array.isArray(error.meta.target) ? error.meta.target.join(', ') - : String(error.meta.target) // eslint-disable-line @typescript-eslint/no-base-to-string + : typeof error.meta.target === 'string' + ? error.meta.target + : JSON.stringify(error.meta.target) : null; switch (error.code) { diff --git a/src/shared/modules/global/resource.service.ts b/src/shared/modules/global/resource.service.ts new file mode 100644 index 0000000..67a05c9 --- /dev/null +++ b/src/shared/modules/global/resource.service.ts @@ -0,0 +1,127 @@ +import { HttpService } from '@nestjs/axios'; +import { firstValueFrom } from 'rxjs'; +import { AxiosError } from 'axios'; +import { ForbiddenException, Injectable, Logger } from '@nestjs/common'; +import { CommonConfig } from 'src/shared/config/common.config'; +import { ResourceRole } from 'src/shared/models/ResourceRole.model'; +import { ResourceInfo } from 'src/shared/models/ResourceInfo.model'; +import { JwtUser } from './jwt.service'; +import * as querystring from 'querystring'; +import { some } from 'lodash'; +import { M2MService } from './m2m.service'; + +@Injectable() +export class ResourceApiService { + private readonly logger: Logger = new Logger(ResourceApiService.name); + + constructor( + private readonly m2mService: M2MService, + private readonly httpService: HttpService, + ) {} + + /** + * Fetch list of resource roles + * + * @returns resolves to list of resouce role + */ + async getResourceRoles(): Promise<{ + [key: string]: ResourceRole; + }> { + try { + // Send request to resource api + const response = await firstValueFrom( + this.httpService.get( + `${CommonConfig.apis.resourceApiUrl}resource-roles`, + {}, + ), + ); + return response.data.reduce( + (mappingResult, resourceRole: ResourceRole) => ({ + ...mappingResult, + [resourceRole.id]: resourceRole, + }), + {}, + ); + } catch (e) { + if (e instanceof AxiosError) { + this.logger.error(`Http Error: ${e.message}`, e.response?.data); + throw new Error('Cannot get data from Resource API.'); + } + this.logger.error(`Data validation error: ${e}`); + throw new Error('Malformed data returned from Resource API'); + } + } + + /** + * Fetch list of resource + * + * @returns resolves to list of resouce info + */ + async getResources(query: { + challengeId?: string; + memberId?: string; + }): Promise { + try { + // Send request to resource api + const url = `${CommonConfig.apis.resourceApiUrl}resources?${querystring.stringify(query)}`; + const token = await this.m2mService.getM2MToken(); + const response = await firstValueFrom( + this.httpService.get(url, { + headers: { + Authorization: 'Bearer ' + token, + }, + }), + ); + return response.data; + } catch (e) { + if (e instanceof AxiosError) { + this.logger.error(`Http Error: ${e.message}`, e.response?.data); + throw new Error('Cannot get data from Resource API.'); + } + this.logger.error(`Data validation error: ${e}`); + throw new Error('Malformed data returned from Resource API'); + } + } + + /** + * Validate resource fole + * + * @param requiredRoles list of require roles + * @param authUser login user info + * @param challengeId challenge id + * @param resourceId resource id + * @returns resolves to true if role is valid + */ + async validateResourcesRoles( + requiredRoles: string[], + authUser: JwtUser, + challengeId: string, + resourceId: string, + ): Promise { + const resourceRoles = await this.getResourceRoles(); + const myResources = ( + await this.getResources({ + challengeId: challengeId, + memberId: authUser.userId, + }) + ) + .filter( + (resource) => + resource.id === resourceId && resource.memberId === authUser.userId, + ) + .map((resource) => ({ + ...resource, + roleName: resourceRoles?.[resource.roleId]?.name ?? '', + })) + .filter((resource) => + some( + requiredRoles.map((item) => item.toLowerCase()), + (role: string) => resource.roleName.toLowerCase().indexOf(role) >= 0, + ), + ); + if (!myResources.length) { + throw new ForbiddenException('Insufficient permissions'); + } + return true; + } +} diff --git a/src/shared/modules/global/utils.service.ts b/src/shared/modules/global/utils.service.ts index d8c560f..63cf5d4 100644 --- a/src/shared/modules/global/utils.service.ts +++ b/src/shared/modules/global/utils.service.ts @@ -1,8 +1,7 @@ - export class Utils { private constructor() {} static bigIntToNumber(t) { return t ? Number(t) : null; } -}; +} diff --git a/src/shared/modules/kafka/base-event.handler.ts b/src/shared/modules/kafka/base-event.handler.ts new file mode 100644 index 0000000..398d534 --- /dev/null +++ b/src/shared/modules/kafka/base-event.handler.ts @@ -0,0 +1,24 @@ +import { LoggerService } from '../global/logger.service'; + +export abstract class BaseEventHandler { + protected logger: LoggerService; + + constructor(logger: LoggerService) { + this.logger = logger; + } + + abstract handle(message: any): Promise; + abstract getTopic(): string; + + protected logMessage(message: any): void { + this.logger.log({ + message: 'Processing Kafka message', + topic: this.getTopic(), + payload: message, + }); + } + + protected validateMessage(message: any): boolean { + return message !== null && message !== undefined; + } +} diff --git a/src/shared/modules/kafka/handlers/avscan-action-scan.handler.ts b/src/shared/modules/kafka/handlers/avscan-action-scan.handler.ts new file mode 100644 index 0000000..bc2f441 --- /dev/null +++ b/src/shared/modules/kafka/handlers/avscan-action-scan.handler.ts @@ -0,0 +1,52 @@ +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { BaseEventHandler } from '../base-event.handler'; +import { KafkaHandlerRegistry } from '../kafka-handler.registry'; +import { LoggerService } from '../../global/logger.service'; + +@Injectable() +export class AVScanActionScanHandler + extends BaseEventHandler + implements OnModuleInit +{ + private readonly topic = 'avscan.action.scan'; + + constructor(private readonly handlerRegistry: KafkaHandlerRegistry) { + super(LoggerService.forRoot('AVScanActionScanHandler')); + } + + onModuleInit() { + this.handlerRegistry.registerHandler(this.topic, this); + this.logger.log(`Registered handler for topic: ${this.topic}`); + } + + getTopic(): string { + return this.topic; + } + + async handle(message: any): Promise { + try { + this.logger.log({ + message: 'Processing AVScan Action Scan event', + topic: this.topic, + payload: message, + }); + + if (!this.validateMessage(message)) { + this.logger.warn('Invalid message received'); + return; + } + + this.logger.log('=== AVScan Action Scan Event ==='); + this.logger.log('Topic:', this.topic); + this.logger.log('Payload:', JSON.stringify(message, null, 2)); + this.logger.log('=============================='); + + await Promise.resolve(); // Add await to satisfy linter + + this.logger.log('AVScan Action Scan event processed successfully'); + } catch (error) { + this.logger.error('Error processing AVScan Action Scan event', error); + throw error; + } + } +} diff --git a/src/shared/modules/kafka/kafka-consumer.service.ts b/src/shared/modules/kafka/kafka-consumer.service.ts new file mode 100644 index 0000000..f23eb7f --- /dev/null +++ b/src/shared/modules/kafka/kafka-consumer.service.ts @@ -0,0 +1,303 @@ +import { + Injectable, + OnModuleInit, + OnModuleDestroy, + OnApplicationBootstrap, +} from '@nestjs/common'; +import { Kafka, Consumer, Producer, KafkaMessage, SASLOptions } from 'kafkajs'; +import { KafkaHandlerRegistry } from './kafka-handler.registry'; +import { LoggerService } from '../global/logger.service'; + +export interface KafkaModuleOptions { + brokers: string[]; + clientId: string; + groupId: string; + ssl?: boolean; + sasl?: SASLOptions; + connectionTimeout?: number; + requestTimeout?: number; + retry?: { + retries: number; + initialRetryTime: number; + maxRetryTime: number; + }; + dlq?: { + enabled: boolean; + topicSuffix: string; + maxRetries: number; + }; +} + +@Injectable() +export class KafkaConsumerService + implements OnModuleInit, OnModuleDestroy, OnApplicationBootstrap +{ + private kafka: Kafka; + private consumer: Consumer; + private producer: Producer; + private logger: LoggerService; + private messageRetryCount: Map = new Map(); + + constructor( + private readonly options: KafkaModuleOptions, + private readonly handlerRegistry: KafkaHandlerRegistry, + ) { + this.logger = LoggerService.forRoot('KafkaConsumerService'); + } + + onModuleInit() { + this.connect(); + } + + async onApplicationBootstrap() { + await this.subscribeToTopics(); + await this.startConsumer(); + } + + async onModuleDestroy() { + await this.disconnect(); + } + + connect(): void { + try { + this.kafka = new Kafka({ + clientId: this.options.clientId, + brokers: this.options.brokers, + ssl: this.options.ssl || false, + sasl: this.options.sasl, + connectionTimeout: this.options.connectionTimeout || 10000, + requestTimeout: this.options.requestTimeout || 30000, + retry: this.options.retry || { + retries: 5, + initialRetryTime: 100, + maxRetryTime: 30000, + }, + }); + + this.consumer = this.kafka.consumer({ + groupId: this.options.groupId, + }); + + this.producer = this.kafka.producer(); + + this.logger.log('Kafka client and consumer initialized successfully'); + } catch (error) { + this.logger.error('Failed to initialize Kafka client', error); + throw error; + } + } + + async disconnect(): Promise { + try { + if (this.consumer) { + await this.consumer.disconnect(); + this.logger.log('Kafka consumer disconnected successfully'); + } + if (this.producer) { + await this.producer.disconnect(); + this.logger.log('Kafka producer disconnected successfully'); + } + } catch (error) { + this.logger.error('Error during Kafka disconnect', error); + } + } + + async subscribeToTopics(): Promise { + try { + const topics = this.handlerRegistry.getAllTopics(); + + if (topics.length === 0) { + this.logger.warn( + 'No topics registered for subscription. Skipping Kafka initialization.', + ); + return; + } + + for (const topic of topics) { + await this.consumer.subscribe({ topic }); + this.logger.log(`Subscribed to topic: ${topic}`); + } + } catch (error) { + this.logger.error('Failed to subscribe to topics', error); + throw error; + } + } + + private async startConsumer(): Promise { + try { + await this.producer.connect(); + + await this.consumer.run({ + eachMessage: async ({ topic, partition, message }) => { + await this.processMessage(topic, partition, message); + }, + }); + + this.logger.log('Kafka consumer started successfully'); + } catch (error) { + this.logger.error('Failed to start Kafka consumer', error); + throw error; + } + } + + async processMessage( + topic: string, + partition: number, + message: KafkaMessage, + ): Promise { + const startTime = Date.now(); + const messageKey = `${topic}-${partition}-${message.offset}`; + + try { + this.logger.log({ + message: 'Received Kafka message', + topic, + partition, + offset: message.offset, + timestamp: message.timestamp, + }); + + const handler = this.handlerRegistry.getHandler(topic); + + if (!handler) { + this.logger.warn(`No handler registered for topic: ${topic}`); + return; + } + + let payload; + try { + payload = message.value ? JSON.parse(message.value.toString()) : null; + } catch (parseError) { + this.logger.error( + `Failed to parse message payload for topic ${topic}`, + parseError, + ); + await this.sendToDLQ(topic, message, 'Parse error'); + return; + } + + this.logger.log({ + message: 'Processing message with handler', + topic, + handlerName: handler.constructor.name, + }); + + await handler.handle(payload); + + // Reset retry count on successful processing + this.messageRetryCount.delete(messageKey); + + const processingTime = Date.now() - startTime; + this.logger.log({ + message: 'Message processed successfully', + topic, + processingTime: `${processingTime}ms`, + }); + } catch (error) { + const processingTime = Date.now() - startTime; + this.logger.error({ + message: 'Failed to process message', + topic, + error: error.message, + processingTime: `${processingTime}ms`, + }); + + await this.handleFailedMessage(topic, message, error, messageKey); + } + } + + private async handleFailedMessage( + topic: string, + message: KafkaMessage, + error: Error, + messageKey: string, + ): Promise { + if (!this.options.dlq?.enabled) { + this.logger.error({ + message: 'Message processing failed and DLQ is disabled', + topic, + error: error.message, + }); + return; + } + + const currentRetryCount = this.messageRetryCount.get(messageKey) || 0; + const maxRetries = this.options.dlq.maxRetries || 3; + + if (currentRetryCount < maxRetries) { + this.messageRetryCount.set(messageKey, currentRetryCount + 1); + this.logger.warn({ + message: `Message processing failed, retry ${currentRetryCount + 1}/${maxRetries}`, + topic, + messageKey, + error: error.message, + }); + throw error; // Re-throw to trigger Kafka's retry mechanism + } else { + this.logger.error({ + message: `Message processing failed after ${maxRetries} retries, sending to DLQ`, + topic, + messageKey, + error: error.message, + }); + await this.sendToDLQ(topic, message, error.message); + this.messageRetryCount.delete(messageKey); + } + } + + private async sendToDLQ( + originalTopic: string, + message: KafkaMessage, + errorReason: string, + ): Promise { + if (!this.options.dlq?.enabled) { + return; + } + + try { + const dlqTopic = `${originalTopic}${this.options.dlq.topicSuffix}`; + + const dlqMessage = { + originalTopic, + originalPartition: 0, // Will be set by the partition parameter + originalOffset: message.offset, + originalTimestamp: message.timestamp, + originalKey: message.key?.toString(), + originalValue: message.value?.toString(), + errorReason, + failedAt: new Date().toISOString(), + headers: message.headers, + }; + + await this.producer.send({ + topic: dlqTopic, + messages: [ + { + key: message.key, + value: JSON.stringify(dlqMessage), + headers: { + ...message.headers, + 'dlq-original-topic': originalTopic, + 'dlq-error-reason': errorReason, + 'dlq-failed-at': new Date().toISOString(), + }, + }, + ], + }); + + this.logger.log({ + message: 'Message sent to DLQ', + originalTopic, + dlqTopic, + errorReason, + }); + } catch (dlqError) { + this.logger.error({ + message: 'Failed to send message to DLQ', + originalTopic, + errorReason, + dlqError: dlqError.message, + }); + } + } +} diff --git a/src/shared/modules/kafka/kafka-handler.registry.ts b/src/shared/modules/kafka/kafka-handler.registry.ts new file mode 100644 index 0000000..d12f94d --- /dev/null +++ b/src/shared/modules/kafka/kafka-handler.registry.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@nestjs/common'; +import { BaseEventHandler } from './base-event.handler'; + +@Injectable() +export class KafkaHandlerRegistry { + private readonly handlers = new Map(); + + registerHandler(topic: string, handler: BaseEventHandler): void { + this.handlers.set(topic, handler); + } + + getHandler(topic: string): BaseEventHandler | undefined { + return this.handlers.get(topic); + } + + getAllTopics(): string[] { + return Array.from(this.handlers.keys()); + } + + hasHandler(topic: string): boolean { + return this.handlers.has(topic); + } +} diff --git a/src/shared/modules/kafka/kafka.module.ts b/src/shared/modules/kafka/kafka.module.ts new file mode 100644 index 0000000..f5865ba --- /dev/null +++ b/src/shared/modules/kafka/kafka.module.ts @@ -0,0 +1,70 @@ +import { Module, DynamicModule } from '@nestjs/common'; +import { + KafkaConsumerService, + KafkaModuleOptions, +} from './kafka-consumer.service'; +import { KafkaHandlerRegistry } from './kafka-handler.registry'; +import { AVScanActionScanHandler } from './handlers/avscan-action-scan.handler'; + +@Module({}) +export class KafkaModule { + static register(options: KafkaModuleOptions): DynamicModule { + return { + module: KafkaModule, + providers: [ + { + provide: 'KAFKA_OPTIONS', + useValue: options, + }, + KafkaHandlerRegistry, + { + provide: KafkaConsumerService, + useFactory: (handlerRegistry: KafkaHandlerRegistry) => { + return new KafkaConsumerService(options, handlerRegistry); + }, + inject: [KafkaHandlerRegistry], + }, + AVScanActionScanHandler, + ], + exports: [KafkaConsumerService, KafkaHandlerRegistry], + }; + } + + static forRoot(): DynamicModule { + const kafkaOptions: KafkaModuleOptions = { + brokers: process.env.KAFKA_BROKERS?.split(',') || ['localhost:9092'], + clientId: process.env.KAFKA_CLIENT_ID || 'tc-review-api', + groupId: process.env.KAFKA_GROUP_ID || 'tc-review-consumer-group', + ssl: process.env.KAFKA_SSL_ENABLED === 'true', + sasl: process.env.KAFKA_SASL_USERNAME + ? { + mechanism: + (process.env.KAFKA_SASL_MECHANISM as + | 'plain' + | 'scram-sha-256' + | 'scram-sha-512') || 'plain', + username: process.env.KAFKA_SASL_USERNAME, + password: process.env.KAFKA_SASL_PASSWORD || '', + } + : undefined, + connectionTimeout: parseInt( + process.env.KAFKA_CONNECTION_TIMEOUT || '10000', + ), + requestTimeout: parseInt(process.env.KAFKA_REQUEST_TIMEOUT || '30000'), + retry: { + retries: parseInt(process.env.KAFKA_RETRY_ATTEMPTS || '5'), + initialRetryTime: parseInt( + process.env.KAFKA_INITIAL_RETRY_TIME || '100', + ), + maxRetryTime: parseInt(process.env.KAFKA_MAX_RETRY_TIME || '30000'), + }, + dlq: { + enabled: process.env.KAFKA_DLQ_ENABLED === 'true', + topicSuffix: process.env.KAFKA_DLQ_TOPIC_SUFFIX || '.dlq', + maxRetries: parseInt(process.env.KAFKA_DLQ_MAX_RETRIES || '3'), + }, + }; + + return this.register(kafkaOptions); + } +} From 195207b3c721dfbb8e1a561f04e4b2e5a09e2bf2 Mon Sep 17 00:00:00 2001 From: billsedison Date: Tue, 5 Aug 2025 21:00:13 +0800 Subject: [PATCH 4/5] Remove query string --- package.json | 1 - pnpm-lock.yaml | 5 ----- 2 files changed, 6 deletions(-) diff --git a/package.json b/package.json index 27239ca..5085965 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,6 @@ "lodash": "^4.17.21", "multer": "^2.0.1", "nanoid": "~5.1.2", - "querystring": "^0.2.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "tc-core-library-js": "appirio-tech/tc-core-library-js.git#v3.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7eb7a52..85b5550 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3315,11 +3315,6 @@ packages: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} - querystring@0.2.1: - resolution: {integrity: sha512-wkvS7mL/JMugcup3/rMitHmd9ecIGd2lhFhK9N3UUQ450h66d1r3Y9nvXzQAW1Lq+wyx61k/1pfKS5KuKiyEbg==} - engines: {node: '>=0.4.x'} - deprecated: The querystring API is considered Legacy. new code should use the URLSearchParams API instead. - queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} From 0951bcd35d235f300b07e52b31e31aa0267376b4 Mon Sep 17 00:00:00 2001 From: billsedison Date: Tue, 5 Aug 2025 21:04:28 +0800 Subject: [PATCH 5/5] Remove querystring further --- pnpm-lock.yaml | 5 ----- src/shared/modules/global/resource.service.ts | 7 +++++-- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 85b5550..173efbf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,9 +62,6 @@ importers: nanoid: specifier: ~5.1.2 version: 5.1.2 - querystring: - specifier: ^0.2.1 - version: 0.2.1 reflect-metadata: specifier: ^0.2.2 version: 0.2.2 @@ -7561,8 +7558,6 @@ snapshots: dependencies: side-channel: 1.1.0 - querystring@0.2.1: {} - queue-microtask@1.2.3: {} quick-lru@5.1.1: {} diff --git a/src/shared/modules/global/resource.service.ts b/src/shared/modules/global/resource.service.ts index 67a05c9..8c7d9c1 100644 --- a/src/shared/modules/global/resource.service.ts +++ b/src/shared/modules/global/resource.service.ts @@ -6,7 +6,6 @@ import { CommonConfig } from 'src/shared/config/common.config'; import { ResourceRole } from 'src/shared/models/ResourceRole.model'; import { ResourceInfo } from 'src/shared/models/ResourceInfo.model'; import { JwtUser } from './jwt.service'; -import * as querystring from 'querystring'; import { some } from 'lodash'; import { M2MService } from './m2m.service'; @@ -63,7 +62,11 @@ export class ResourceApiService { }): Promise { try { // Send request to resource api - const url = `${CommonConfig.apis.resourceApiUrl}resources?${querystring.stringify(query)}`; + const params = new URLSearchParams(); + if (query.challengeId) params.append('challengeId', query.challengeId); + if (query.memberId) params.append('memberId', query.memberId); + + const url = `${CommonConfig.apis.resourceApiUrl}resources?${params.toString()}`; const token = await this.m2mService.getM2MToken(); const response = await firstValueFrom( this.httpService.get(url, {