From 90b4c551a6f34a6366acb308f050c4338287aecf Mon Sep 17 00:00:00 2001 From: MmagdyhafezZ Date: Tue, 4 Nov 2025 02:58:01 -0700 Subject: [PATCH 1/5] feat: regrading request end to end --- .secrets.baseline | 85 +- .tool-versions | 2 +- apps/api-gateway/package.json | 1 - .../mock.jwt.cookie.auth.guard.ts | 9 +- apps/api/package.json | 1 - .../migration.sql | 4 + .../migration.sql | 16 + apps/api/prisma/schema.prisma | 14 + .../src/api/admin/admin.controller.spec.ts | 2 + apps/api/src/api/admin/admin.module.ts | 7 +- apps/api/src/api/admin/admin.service.spec.ts | 643 +++++++++- apps/api/src/api/admin/admin.service.ts | 319 ++++- .../author-regrading-requests.controller.ts | 125 ++ .../regrading-requests.controller.ts | 21 + .../admin/controllers/settings.controller.ts | 73 ++ .../api/assignment/attempt/attempt.service.ts | 12 - .../feedback.request.dto.ts | 10 + .../v2/modules/assignment.module.ts | 2 + .../v2/tests/unit/__mocks__/ common-mocks.ts | 3 + .../api/src/api/attempt/attempt.controller.ts | 43 + apps/api/src/api/attempt/attempt.module.ts | 2 + .../attempt-regrading.service.spec.ts | 530 ++++++++ .../services/attempt-regrading.service.ts | 112 +- .../api/attempt/services/attempt.service.ts | 197 ++- .../scheduled-tasks/scheduled-tasks.module.ts | 3 +- apps/api/src/auth/admin-auth.module.ts | 2 + .../src/auth/services/admin-email.service.ts | 351 +----- apps/api/src/common/services/email.service.ts | 581 +++++++++ apps/api/src/main.ts | 4 +- apps/web/app/admin/components/AdminNav.tsx | 89 ++ .../components/OptimizedAdminDashboard.tsx | 1095 +++++++++-------- apps/web/app/admin/page.tsx | 10 +- .../components/RegradingRequestsContent.tsx | 833 +++++++++++++ .../web/app/admin/regrading-requests/page.tsx | 119 ++ apps/web/app/admin/settings/page.tsx | 158 +++ apps/web/app/api/markChat/route.ts | 15 +- apps/web/app/api/markChat/stream/route.ts | 465 +++---- apps/web/app/chatbot/components/MarkChat.tsx | 9 +- apps/web/app/chatbot/lib/baseFunctionDefs.ts | 16 +- apps/web/app/chatbot/lib/markChatFunctions.ts | 202 ++- .../app/chatbot/store/useLearnerContext.ts | 44 +- .../[assignmentId]/successPage/Question.tsx | 6 +- .../successPage/[submissionId]/page.tsx | 246 +++- .../components/AuthorGradeEditor.tsx | 266 ++++ apps/web/components/ui/switch.tsx | 29 + apps/web/components/ui/textarea.tsx | 23 + apps/web/package.json | 1 + package.json | 3 +- yarn.lock | 18 +- 49 files changed, 5400 insertions(+), 1421 deletions(-) create mode 100644 apps/api/prisma/migrations/20251104082911_support_multiple_question_ids_in_regrading_regrading_request/migration.sql create mode 100644 apps/api/prisma/migrations/20251104091020_adding_email_settings/migration.sql create mode 100644 apps/api/src/api/admin/controllers/author-regrading-requests.controller.ts create mode 100644 apps/api/src/api/admin/controllers/settings.controller.ts create mode 100644 apps/api/src/api/attempt/services/__tests__/attempt-regrading.service.spec.ts create mode 100644 apps/api/src/common/services/email.service.ts create mode 100644 apps/web/app/admin/components/AdminNav.tsx create mode 100644 apps/web/app/admin/regrading-requests/components/RegradingRequestsContent.tsx create mode 100644 apps/web/app/admin/regrading-requests/page.tsx create mode 100644 apps/web/app/admin/settings/page.tsx create mode 100644 apps/web/app/learner/[assignmentId]/successPage/components/AuthorGradeEditor.tsx create mode 100644 apps/web/components/ui/switch.tsx create mode 100644 apps/web/components/ui/textarea.tsx diff --git a/.secrets.baseline b/.secrets.baseline index 0a89c52e..05ad33e8 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -3,7 +3,7 @@ "files": null, "lines": null }, - "generated_at": "2025-10-30T07:06:07Z", + "generated_at": "2025-11-04T09:57:50Z", "plugins_used": [ { "name": "AWSKeyDetector" @@ -149,7 +149,7 @@ { "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", "is_verified": false, - "line_number": 15, + "line_number": 16, "type": "Basic Auth Credentials", "verified_result": null } @@ -158,7 +158,7 @@ { "hashed_secret": "9d4e1e23bd5b727046a9e3b4b7db57bd8d6ee684", "is_verified": false, - "line_number": 12, + "line_number": 51, "type": "Basic Auth Credentials", "verified_result": null } @@ -244,39 +244,18 @@ "verified_result": null } ], - "apps/api/src/auth/services/admin-email.service.ts": [ - { - "hashed_secret": "0745833f7b44bb251e684ed54daa7f30db4a61ff", - "is_verified": false, - "line_number": 17, - "type": "Secret Keyword", - "verified_result": null - }, - { - "hashed_secret": "71011165e6f4116d3943a7b5ef8446c02f10ea7f", - "is_verified": false, - "line_number": 23, - "type": "Secret Keyword", - "verified_result": null - }, + "apps/api/src/common/services/email.service.ts": [ { "hashed_secret": "da32b2dd835ec07a74fb098cb9e617bbfac01eff", "is_verified": false, - "line_number": 48, + "line_number": 53, "type": "Secret Keyword", "verified_result": null }, { "hashed_secret": "de493e0c6963b221bcc6f77606d52602cc46f1f0", "is_verified": false, - "line_number": 51, - "type": "Secret Keyword", - "verified_result": null - }, - { - "hashed_secret": "6947818ac409551f11fbaa78f0ea6391960aa5b8", - "is_verified": false, - "line_number": 96, + "line_number": 55, "type": "Secret Keyword", "verified_result": null } @@ -317,15 +296,6 @@ "verified_result": null } ], - "apps/api/src/middleware/__tests__/data-transform.middleware.test.ts": [ - { - "hashed_secret": "c43b74f82f891e351ea8d73c4cac9988f05fa49f", - "is_verified": false, - "line_number": 41, - "type": "Base64 High Entropy String", - "verified_result": null - } - ], "apps/web/.env.template": [ { "hashed_secret": "d08f88df745fa7950b104e4a707a31cfce7b5841", @@ -443,49 +413,49 @@ { "hashed_secret": "b7e41a1408b0de53b6a18b0383983df52151bffd", "is_verified": false, - "line_number": 60, + "line_number": 57, "type": "Base64 High Entropy String", "verified_result": null }, { "hashed_secret": "7f8a4c8efb7a9d741a4131e6382406d04920a55c", "is_verified": false, - "line_number": 75, + "line_number": 72, "type": "Base64 High Entropy String", "verified_result": null }, { "hashed_secret": "ef584f952dbcdb807b1f2a5b688a6b2ac7beaf76", "is_verified": false, - "line_number": 158, + "line_number": 155, "type": "Base64 High Entropy String", "verified_result": null }, { "hashed_secret": "fca9f0a000cc0d99a6b89eff22b280d43b3dc23a", "is_verified": false, - "line_number": 168, + "line_number": 164, "type": "Base64 High Entropy String", "verified_result": null }, { "hashed_secret": "ad5a4eb98aace66b683002aa7038bb800f8b0a65", "is_verified": false, - "line_number": 174, + "line_number": 170, "type": "Base64 High Entropy String", "verified_result": null }, { "hashed_secret": "28681ff8a70bc722645c0ebe5c21fbd8a2ee904a", "is_verified": false, - "line_number": 183, + "line_number": 179, "type": "Base64 High Entropy String", "verified_result": null }, { "hashed_secret": "dd65cdacb216bbeca512abfb1ecd15d1c53ac7bd", "is_verified": false, - "line_number": 431, + "line_number": 427, "type": "Base64 High Entropy String", "verified_result": null } @@ -526,6 +496,15 @@ "verified_result": null } ], + "apps/web/app/chatbot/lib/markChatFunctions.ts": [ + { + "hashed_secret": "d3ecb0d890368d7659ee54010045b835dacb8efe", + "is_verified": false, + "line_number": 334, + "type": "Secret Keyword", + "verified_result": null + } + ], "apps/web/app/learner/(components)/Question/FileCodeUploadSection.tsx": [ { "hashed_secret": "cfb93ca9f329289d5f23de2b0e065103b81a374c", @@ -583,6 +562,15 @@ "verified_result": null } ], + "apps/web/app/learner/[assignmentId]/successPage/[submissionId]/page.tsx": [ + { + "hashed_secret": "d3ecb0d890368d7659ee54010045b835dacb8efe", + "is_verified": false, + "line_number": 657, + "type": "Secret Keyword", + "verified_result": null + } + ], "apps/web/public/ffmpeg-core/ffmpeg-core.js": [ { "hashed_secret": "b4e44716dbbf57be3dae2f819d96795a85d06652", @@ -596,10 +584,19 @@ { "hashed_secret": "5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8", "is_verified": false, - "line_number": 135, + "line_number": 138, "type": "Basic Auth Credentials", "verified_result": null } + ], + "scripts/validate-env.sh": [ + { + "hashed_secret": "8da52328e314a37358f9758f38d5bb0e8687b6ab", + "is_verified": false, + "line_number": 74, + "type": "Secret Keyword", + "verified_result": null + } ] }, "version": "0.13.1+ibm.64.dss", diff --git a/.tool-versions b/.tool-versions index e084f460..9bc5c5e5 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ -nodejs 22.0.0 +nodejs 22.12.0 yarn 1.22.22 \ No newline at end of file diff --git a/apps/api-gateway/package.json b/apps/api-gateway/package.json index 432c00f7..b7093c49 100644 --- a/apps/api-gateway/package.json +++ b/apps/api-gateway/package.json @@ -65,7 +65,6 @@ "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.3", "eslint-plugin-unicorn": "^47.0.0", - "husky": "^8.0.3", "jest": "^29.5.0", "prettier": "^2.8.8", "prettier-plugin-pkg": "^0.19.0", diff --git a/apps/api-gateway/src/auth/jwt/cookie-based/mock.jwt.cookie.auth.guard.ts b/apps/api-gateway/src/auth/jwt/cookie-based/mock.jwt.cookie.auth.guard.ts index ee1dc1e1..0d5caccd 100644 --- a/apps/api-gateway/src/auth/jwt/cookie-based/mock.jwt.cookie.auth.guard.ts +++ b/apps/api-gateway/src/auth/jwt/cookie-based/mock.jwt.cookie.auth.guard.ts @@ -22,15 +22,14 @@ export class MockJwtCookieAuthGuard extends AuthGuard("cookie-strategy") { const request: RequestWithUserSession = context.switchToHttp().getRequest(); request.user = { - userId: "dev-user", - role: UserRole.LEARNER, - groupId: "string", - assignmentId: 1888, + userId: "magdy.hafez@ibm.com2", + role: UserRole.AUTHOR, + groupId: "autogen-faculty-v1-course-v1-IND-AI0103EN-v1", + assignmentId: 1, gradingCallbackRequired: false, returnUrl: "https://skills.network", launch_presentation_locale: "en", }; - return true; } } diff --git a/apps/api/package.json b/apps/api/package.json index c5928568..09d79909 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -122,7 +122,6 @@ "eslint-config-prettier": "^8.8.0", "eslint-plugin-prettier": "^4.2.1", "eslint-plugin-unicorn": "^47.0.0", - "husky": "^9.1.7", "jest": "^29.5.0", "prettier": "^2.8.8", "prettier-plugin-pkg": "^0.19.0", diff --git a/apps/api/prisma/migrations/20251104082911_support_multiple_question_ids_in_regrading_regrading_request/migration.sql b/apps/api/prisma/migrations/20251104082911_support_multiple_question_ids_in_regrading_regrading_request/migration.sql new file mode 100644 index 00000000..93fed5e0 --- /dev/null +++ b/apps/api/prisma/migrations/20251104082911_support_multiple_question_ids_in_regrading_regrading_request/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "RegradingRequest" ADD COLUMN "processedBy" TEXT, +ADD COLUMN "proposedGrade" DOUBLE PRECISION, +ADD COLUMN "questionIds" INTEGER[] DEFAULT ARRAY[]::INTEGER[]; diff --git a/apps/api/prisma/migrations/20251104091020_adding_email_settings/migration.sql b/apps/api/prisma/migrations/20251104091020_adding_email_settings/migration.sql new file mode 100644 index 00000000..a39eb5d9 --- /dev/null +++ b/apps/api/prisma/migrations/20251104091020_adding_email_settings/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "AuthorSettings" ( + "id" SERIAL NOT NULL, + "userId" TEXT NOT NULL, + "emailOnRegradingRequest" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AuthorSettings_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "AuthorSettings_userId_key" ON "AuthorSettings"("userId"); + +-- CreateIndex +CREATE INDEX "AuthorSettings_userId_idx" ON "AuthorSettings"("userId"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index fab58ec1..24c8aba1 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -488,7 +488,10 @@ model RegradingRequest { attemptId Int /// The ID of the assignment attempt assignmentAttempt AssignmentAttempt @relation(fields: [attemptId], references: [id], onDelete: Cascade) regradingReason String? /// Reason for requesting regrading + proposedGrade Float? /// AI-proposed grade (0.0 to 1.0) for the regrading + questionIds Int[] @default([]) /// Array of question IDs the learner complained about (if applicable) regradingStatus RegradingStatus @default(PENDING) /// Status of the regrading request + processedBy String? /// Email of the author/admin who processed this request createdAt DateTime @default(now()) /// Timestamp for when the regrading request was created updatedAt DateTime @updatedAt /// Timestamp for the last update to the regrading request } @@ -684,6 +687,17 @@ model AdminSession { @@index([expiresAt]) } +/// This model stores author/admin settings and preferences +model AuthorSettings { + id Int @id @default(autoincrement()) + userId String @unique /// The email/userId of the author + emailOnRegradingRequest Boolean @default(true) /// Whether to email author when learners submit regrading requests + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) +} + /// This model tracks the authors of assignments model AssignmentAuthor { id Int @id @default(autoincrement()) diff --git a/apps/api/src/api/admin/admin.controller.spec.ts b/apps/api/src/api/admin/admin.controller.spec.ts index c57170d4..836f4d9b 100644 --- a/apps/api/src/api/admin/admin.controller.spec.ts +++ b/apps/api/src/api/admin/admin.controller.spec.ts @@ -1,4 +1,5 @@ import { Test, TestingModule } from "@nestjs/testing"; +import { EmailService } from "src/common/services/email.service"; import { AdminVerificationService } from "../../auth/services/admin-verification.service"; import { PrismaService } from "../../database/prisma.service"; import { LLM_PRICING_SERVICE } from "../llm/llm.constants"; @@ -45,6 +46,7 @@ describe("AdminController", () => { providers: [ AdminService, PrismaService, + EmailService, AdminRepository, { provide: LLM_PRICING_SERVICE, useValue: mockLlmPricingService }, { diff --git a/apps/api/src/api/admin/admin.module.ts b/apps/api/src/api/admin/admin.module.ts index 55ec830b..899c7bbb 100644 --- a/apps/api/src/api/admin/admin.module.ts +++ b/apps/api/src/api/admin/admin.module.ts @@ -1,6 +1,7 @@ import { Module } from "@nestjs/common"; import { PassportModule } from "@nestjs/passport"; import { PrismaService } from "src/database/prisma.service"; +import { EmailService } from "src/common/services/email.service"; import { AdminAuthModule } from "../../auth/admin-auth.module"; import { AuthModule } from "../../auth/auth.module"; import { LlmModule } from "../llm/llm.module"; @@ -10,10 +11,12 @@ import { AdminRepository } from "./admin.repository"; import { AdminService } from "./admin.service"; import { AdminDashboardController } from "./controllers/admin-dashboard.controller"; import { AssignmentAnalyticsController } from "./controllers/assignment-analytics.controller"; +import { AuthorRegradingRequestsController } from "./controllers/author-regrading-requests.controller"; import { FlaggedSubmissionsController } from "./controllers/flagged-submissions.controller"; import { LLMAssignmentController } from "./controllers/llm-assignment.controller"; import { LLMPricingController } from "./controllers/llm-pricing.controller"; import { RegradingRequestsController } from "./controllers/regrading-requests.controller"; +import { SettingsController } from "./controllers/settings.controller"; @Module({ imports: [ @@ -29,9 +32,11 @@ import { RegradingRequestsController } from "./controllers/regrading-requests.co LLMAssignmentController, LLMPricingController, RegradingRequestsController, + AuthorRegradingRequestsController, FlaggedSubmissionsController, AssignmentAnalyticsController, + SettingsController, ], - providers: [AdminService, PrismaService, AdminRepository], + providers: [AdminService, PrismaService, AdminRepository, EmailService], }) export class AdminModule {} diff --git a/apps/api/src/api/admin/admin.service.spec.ts b/apps/api/src/api/admin/admin.service.spec.ts index ba63a0c3..470c750a 100644 --- a/apps/api/src/api/admin/admin.service.spec.ts +++ b/apps/api/src/api/admin/admin.service.spec.ts @@ -1,12 +1,51 @@ import { Test, TestingModule } from "@nestjs/testing"; +import { NotFoundException } from "@nestjs/common"; +import { RegradingStatus } from "@prisma/client"; import { PrismaService } from "../../database/prisma.service"; +import { EmailService } from "../../common/services/email.service"; import { LLM_PRICING_SERVICE } from "../llm/llm.constants"; import { AdminService } from "./admin.service"; describe("AdminService", () => { let service: AdminService; + let prismaService: PrismaService; + let emailService: EmailService; const originalDatabaseUrl = process.env.DATABASE_URL; + const mockPrismaService = { + regradingRequest: { + findUnique: jest.fn(), + findMany: jest.fn(), + findFirst: jest.fn(), + update: jest.fn(), + }, + assignmentAttempt: { + update: jest.fn(), + }, + assignmentAuthor: { + findMany: jest.fn(), + findFirst: jest.fn(), + }, + }; + + const mockEmailService = { + sendGradeUpdateNotification: jest.fn(), + }; + + const mockLlmPricingService = { + calculateCost: jest.fn().mockReturnValue(0.01), + getTokenCount: jest.fn().mockReturnValue(100), + calculateCostWithBreakdown: jest.fn().mockResolvedValue({ + totalCost: 0.01, + inputCost: 0.005, + outputCost: 0.005, + modelKey: "gpt-4o", + inputTokenPrice: 0.000_001, + outputTokenPrice: 0.000_002, + pricingEffectiveDate: new Date(), + }), + }; + beforeAll(() => { process.env.DATABASE_URL = originalDatabaseUrl ?? "postgresql://user:pass@localhost:5432/test"; @@ -21,23 +60,615 @@ describe("AdminService", () => { }); beforeEach(async () => { - const mockLlmPricingService = { - calculateCost: jest.fn().mockReturnValue(0.01), - getTokenCount: jest.fn().mockReturnValue(100), - }; - const module: TestingModule = await Test.createTestingModule({ providers: [ AdminService, - PrismaService, + { provide: PrismaService, useValue: mockPrismaService }, + { provide: EmailService, useValue: mockEmailService }, { provide: LLM_PRICING_SERVICE, useValue: mockLlmPricingService }, ], }).compile(); service = module.get(AdminService); + prismaService = module.get(PrismaService); + emailService = module.get(EmailService); + + jest.clearAllMocks(); }); it("should be defined", () => { expect(service).toBeDefined(); }); + + describe("Regrading Request Management", () => { + describe("approveRegradingRequest", () => { + const mockRequest = { + id: 1, + assignmentId: 10, + attemptId: 20, + userId: "learner@example.com", + regradingStatus: RegradingStatus.PENDING, + regradingReason: "I believe my answer was correct", + proposedGrade: 80, + questionIds: [1, 2], + createdAt: new Date(), + updatedAt: new Date(), + assignment: { name: "Test Assignment" }, + assignmentAttempt: { grade: 0.5 }, + }; + + it("should approve regrading request and update grade", async () => { + mockPrismaService.regradingRequest.findUnique.mockResolvedValue( + mockRequest, + ); + mockPrismaService.regradingRequest.update.mockResolvedValue({ + ...mockRequest, + regradingStatus: RegradingStatus.APPROVED, + }); + mockPrismaService.assignmentAttempt.update.mockResolvedValue({ + id: 20, + grade: 0.85, + }); + mockEmailService.sendGradeUpdateNotification.mockResolvedValue(); + + const result = await service.approveRegradingRequest(1, 0.85); + + expect(result).toEqual({ success: true }); + expect(mockPrismaService.regradingRequest.update).toHaveBeenCalledWith({ + where: { id: 1 }, + data: { + regradingStatus: "APPROVED", + processedBy: null, + }, + }); + expect(mockPrismaService.assignmentAttempt.update).toHaveBeenCalledWith( + { + where: { id: 20 }, + data: { grade: 0.85 }, + }, + ); + expect( + mockEmailService.sendGradeUpdateNotification, + ).toHaveBeenCalledWith( + "learner@example.com", + "Test Assignment", + 10, + 20, + 0.5, + 0.85, + "APPROVED", + ); + }); + + it("should throw error when request not found", async () => { + mockPrismaService.regradingRequest.findUnique.mockResolvedValue(null); + + await expect( + service.approveRegradingRequest(999, 0.85), + ).rejects.toThrow("Regrading request with ID 999 not found"); + }); + + it("should handle email notification failure gracefully", async () => { + mockPrismaService.regradingRequest.findUnique.mockResolvedValue( + mockRequest, + ); + mockPrismaService.regradingRequest.update.mockResolvedValue({ + ...mockRequest, + regradingStatus: RegradingStatus.APPROVED, + }); + mockPrismaService.assignmentAttempt.update.mockResolvedValue({ + id: 20, + grade: 0.85, + }); + mockEmailService.sendGradeUpdateNotification.mockRejectedValue( + new Error("Email service error"), + ); + + const result = await service.approveRegradingRequest(1, 0.85); + expect(result).toEqual({ success: true }); + }); + + it("should convert percentage grade to decimal correctly", async () => { + mockPrismaService.regradingRequest.findUnique.mockResolvedValue( + mockRequest, + ); + mockPrismaService.regradingRequest.update.mockResolvedValue({ + ...mockRequest, + regradingStatus: RegradingStatus.APPROVED, + }); + mockPrismaService.assignmentAttempt.update.mockResolvedValue({ + id: 20, + grade: 0.95, + }); + mockEmailService.sendGradeUpdateNotification.mockResolvedValue(); + + await service.approveRegradingRequest(1, 0.95); + + expect(mockPrismaService.assignmentAttempt.update).toHaveBeenCalledWith( + { + where: { id: 20 }, + data: { grade: 0.95 }, + }, + ); + }); + }); + + describe("rejectRegradingRequest", () => { + const mockRequest = { + id: 1, + assignmentId: 10, + attemptId: 20, + userId: "learner@example.com", + regradingStatus: RegradingStatus.PENDING, + regradingReason: "I believe my answer was correct", + proposedGrade: 80, + questionIds: [1, 2], + createdAt: new Date(), + updatedAt: new Date(), + assignment: { name: "Test Assignment" }, + assignmentAttempt: { grade: 0.6 }, + }; + + it("should reject regrading request with reason", async () => { + mockPrismaService.regradingRequest.findUnique.mockResolvedValue( + mockRequest, + ); + mockPrismaService.regradingRequest.update.mockResolvedValue({ + ...mockRequest, + regradingStatus: RegradingStatus.REJECTED, + regradingReason: "The grading was correct as per rubric", + }); + mockEmailService.sendGradeUpdateNotification.mockResolvedValue(); + + const result = await service.rejectRegradingRequest( + 1, + "The grading was correct as per rubric", + ); + + expect(result).toEqual({ success: true }); + expect(mockPrismaService.regradingRequest.update).toHaveBeenCalledWith({ + where: { id: 1 }, + data: { + regradingStatus: "REJECTED", + regradingReason: "The grading was correct as per rubric", + processedBy: null, + }, + }); + expect( + mockEmailService.sendGradeUpdateNotification, + ).toHaveBeenCalledWith( + "learner@example.com", + "Test Assignment", + 10, + 20, + 0.6, + 0.6, + "REJECTED", + ); + }); + + it("should throw error when request not found", async () => { + mockPrismaService.regradingRequest.findUnique.mockResolvedValue(null); + + await expect( + service.rejectRegradingRequest(999, "Not valid"), + ).rejects.toThrow("Regrading request with ID 999 not found"); + }); + + it("should not change grade when rejecting", async () => { + mockPrismaService.regradingRequest.findUnique.mockResolvedValue( + mockRequest, + ); + mockPrismaService.regradingRequest.update.mockResolvedValue({ + ...mockRequest, + regradingStatus: RegradingStatus.REJECTED, + }); + mockEmailService.sendGradeUpdateNotification.mockResolvedValue(); + + await service.rejectRegradingRequest(1, "Grading is correct"); + + expect( + mockPrismaService.assignmentAttempt.update, + ).not.toHaveBeenCalled(); + expect( + mockEmailService.sendGradeUpdateNotification, + ).toHaveBeenCalledWith( + expect.any(String), + expect.any(String), + expect.any(Number), + expect.any(Number), + 0.6, + 0.6, + "REJECTED", + ); + }); + }); + + describe("dismissFlaggedSubmission", () => { + const mockRequest = { + id: 1, + assignmentId: 10, + attemptId: 20, + userId: "learner@example.com", + regradingStatus: RegradingStatus.PENDING, + regradingReason: "Flagged for review", + proposedGrade: null, + questionIds: [], + createdAt: new Date(), + updatedAt: new Date(), + assignment: { name: "Test Assignment" }, + assignmentAttempt: { grade: 0.7 }, + }; + + it("should dismiss flagged submission", async () => { + mockPrismaService.regradingRequest.findUnique.mockResolvedValue( + mockRequest, + ); + mockPrismaService.regradingRequest.update.mockResolvedValue({ + ...mockRequest, + regradingStatus: RegradingStatus.REJECTED, + }); + mockEmailService.sendGradeUpdateNotification.mockResolvedValue(); + + const result = await service.dismissFlaggedSubmission(1); + + expect(result).toEqual({ + ...mockRequest, + regradingStatus: RegradingStatus.REJECTED, + }); + expect(mockPrismaService.regradingRequest.update).toHaveBeenCalledWith({ + where: { id: 1 }, + data: { regradingStatus: "REJECTED" }, + }); + expect( + mockEmailService.sendGradeUpdateNotification, + ).toHaveBeenCalledWith( + "learner@example.com", + "Test Assignment", + 10, + 20, + 0.7, + 0.7, + "REJECTED", + ); + }); + + it("should throw error when request not found", async () => { + mockPrismaService.regradingRequest.findUnique.mockResolvedValue(null); + + await expect(service.dismissFlaggedSubmission(999)).rejects.toThrow( + "Regrading request with ID 999 not found", + ); + }); + + it("should handle email failure gracefully", async () => { + mockPrismaService.regradingRequest.findUnique.mockResolvedValue( + mockRequest, + ); + mockPrismaService.regradingRequest.update.mockResolvedValue({ + ...mockRequest, + regradingStatus: RegradingStatus.REJECTED, + }); + mockEmailService.sendGradeUpdateNotification.mockRejectedValue( + new Error("Email error"), + ); + + const result = await service.dismissFlaggedSubmission(1); + + expect(result).toBeDefined(); + expect(result.regradingStatus).toBe(RegradingStatus.REJECTED); + }); + }); + + describe("getRegradingRequests", () => { + it("should return all regrading requests", async () => { + const now = new Date(); + const mockRequests = [ + { + id: 1, + assignmentId: 10, + attemptId: 20, + userId: "learner1@example.com", + regradingStatus: RegradingStatus.PENDING, + createdAt: now, + updatedAt: null, + assignment: { id: 10, name: "Assignment 1" }, + assignmentAttempt: { + id: 20, + userId: "learner1@example.com", + grade: 0.5, + createdAt: now, + }, + }, + { + id: 2, + assignmentId: 11, + attemptId: 21, + userId: "learner2@example.com", + regradingStatus: RegradingStatus.APPROVED, + createdAt: now, + updatedAt: null, + assignment: { id: 11, name: "Assignment 2" }, + assignmentAttempt: { + id: 21, + userId: "learner2@example.com", + grade: 0.8, + createdAt: now, + }, + }, + ]; + + mockPrismaService.regradingRequest.findMany.mockResolvedValue( + mockRequests, + ); + + const result = await service.getRegradingRequests(); + + // The service converts Date objects to ISO strings + const expectedResult = mockRequests.map((request) => ({ + id: request.id, + assignmentId: request.assignmentId, + attemptId: request.attemptId, + userId: request.userId, + regradingStatus: request.regradingStatus, + createdAt: request.createdAt.toISOString(), + updatedAt: null, + assignment: request.assignment, + assignmentAttempt: request.assignmentAttempt + ? { + id: request.assignmentAttempt.id, + userId: request.assignmentAttempt.userId, + grade: request.assignmentAttempt.grade, + createdAt: request.assignmentAttempt.createdAt.toISOString(), + } + : null, + })); + + expect(result).toEqual(expectedResult); + expect( + mockPrismaService.regradingRequest.findMany, + ).toHaveBeenCalledWith({ + orderBy: { createdAt: "desc" }, + include: { + assignment: { select: { id: true, name: true } }, + assignmentAttempt: { + select: { + id: true, + userId: true, + grade: true, + createdAt: true, + }, + }, + }, + }); + }); + }); + + describe("getAuthorRegradingRequests", () => { + it("should return regrading requests for author's assignments", async () => { + const authorId = "author@example.com"; + const mockAuthorAssignments = [ + { assignmentId: 10 }, + { assignmentId: 11 }, + ]; + const mockRequests = [ + { + id: 1, + assignmentId: 10, + attemptId: 20, + userId: "learner@example.com", + regradingStatus: RegradingStatus.PENDING, + createdAt: new Date(), + assignment: { id: 10, name: "Assignment 1" }, + assignmentAttempt: { + id: 20, + userId: "learner@example.com", + grade: 0.5, + createdAt: new Date(), + }, + }, + ]; + + mockPrismaService.assignmentAuthor.findMany.mockResolvedValue( + mockAuthorAssignments, + ); + mockPrismaService.regradingRequest.findMany.mockResolvedValue( + mockRequests, + ); + + const result = await service.getAuthorRegradingRequests(authorId); + + expect(result).toEqual(mockRequests); + expect( + mockPrismaService.assignmentAuthor.findMany, + ).toHaveBeenCalledWith({ + where: { userId: authorId }, + select: { assignmentId: true }, + }); + expect( + mockPrismaService.regradingRequest.findMany, + ).toHaveBeenCalledWith({ + where: { assignmentId: { in: [10, 11] } }, + include: { + assignment: { select: { id: true, name: true } }, + assignmentAttempt: { + select: { + id: true, + userId: true, + grade: true, + createdAt: true, + }, + }, + }, + orderBy: { createdAt: "desc" }, + }); + }); + + it("should return empty array when author has no assignments", async () => { + mockPrismaService.assignmentAuthor.findMany.mockResolvedValue([]); + + const result = + await service.getAuthorRegradingRequests("author@example.com"); + + expect(result).toEqual([]); + }); + + it("should filter by assignmentId when provided", async () => { + const authorId = "author@example.com"; + const assignmentId = 10; + + mockPrismaService.assignmentAuthor.findMany.mockResolvedValue([ + { assignmentId: 10 }, + ]); + mockPrismaService.regradingRequest.findMany.mockResolvedValue([]); + + await service.getAuthorRegradingRequests(authorId, assignmentId); + + expect( + mockPrismaService.assignmentAuthor.findMany, + ).toHaveBeenCalledWith({ + where: { userId: authorId, assignmentId: 10 }, + select: { assignmentId: true }, + }); + }); + }); + + describe("approveAuthorRegradingRequest", () => { + const mockRequest = { + id: 1, + assignmentId: 10, + attemptId: 20, + userId: "learner@example.com", + regradingStatus: RegradingStatus.PENDING, + assignment: { name: "Test Assignment" }, + }; + + it("should approve request when author owns the assignment", async () => { + mockPrismaService.regradingRequest.findUnique.mockResolvedValue( + mockRequest, + ); + mockPrismaService.assignmentAuthor.findFirst.mockResolvedValue({ + assignmentId: 10, + userId: "author@example.com", + }); + mockPrismaService.regradingRequest.update.mockResolvedValue({ + ...mockRequest, + regradingStatus: RegradingStatus.APPROVED, + }); + mockPrismaService.assignmentAttempt.update.mockResolvedValue({ + id: 20, + grade: 0.9, + }); + + const result = await service.approveAuthorRegradingRequest( + 1, + 90, + "author@example.com", + ); + + expect(result).toEqual({ success: true }); + expect( + mockPrismaService.assignmentAuthor.findFirst, + ).toHaveBeenCalledWith({ + where: { assignmentId: 10, userId: "author@example.com" }, + }); + }); + + it("should throw NotFoundException when request not found", async () => { + mockPrismaService.regradingRequest.findUnique.mockResolvedValue(null); + + await expect( + service.approveAuthorRegradingRequest(999, 90, "author@example.com"), + ).rejects.toThrow(NotFoundException); + }); + + it("should throw NotFoundException when author does not own assignment", async () => { + mockPrismaService.regradingRequest.findUnique.mockResolvedValue( + mockRequest, + ); + mockPrismaService.assignmentAuthor.findFirst.mockResolvedValue(null); + + await expect( + service.approveAuthorRegradingRequest(1, 90, "different@example.com"), + ).rejects.toThrow( + new NotFoundException( + "You do not have permission to manage this regrading request", + ), + ); + }); + }); + + describe("rejectAuthorRegradingRequest", () => { + const mockRequest = { + id: 1, + assignmentId: 10, + attemptId: 20, + userId: "learner@example.com", + regradingStatus: RegradingStatus.PENDING, + assignment: { name: "Test Assignment" }, + }; + + it("should reject request when author owns the assignment", async () => { + mockPrismaService.regradingRequest.findUnique.mockResolvedValue( + mockRequest, + ); + mockPrismaService.assignmentAuthor.findFirst.mockResolvedValue({ + assignmentId: 10, + userId: "author@example.com", + }); + mockPrismaService.regradingRequest.update.mockResolvedValue({ + ...mockRequest, + regradingStatus: RegradingStatus.REJECTED, + regradingReason: "Grading is correct", + }); + + const result = await service.rejectAuthorRegradingRequest( + 1, + "Grading is correct", + "author@example.com", + ); + + expect(result).toEqual({ success: true }); + expect(mockPrismaService.regradingRequest.update).toHaveBeenCalledWith({ + where: { id: 1 }, + data: { + regradingStatus: "REJECTED", + regradingReason: "Grading is correct", + processedBy: "author@example.com", + }, + }); + }); + + it("should throw NotFoundException when request not found", async () => { + mockPrismaService.regradingRequest.findUnique.mockResolvedValue(null); + + await expect( + service.rejectAuthorRegradingRequest( + 999, + "Not valid", + "author@example.com", + ), + ).rejects.toThrow(NotFoundException); + }); + + it("should throw NotFoundException when author does not own assignment", async () => { + mockPrismaService.regradingRequest.findUnique.mockResolvedValue( + mockRequest, + ); + mockPrismaService.assignmentAuthor.findFirst.mockResolvedValue(null); + + await expect( + service.rejectAuthorRegradingRequest( + 1, + "Not valid", + "different@example.com", + ), + ).rejects.toThrow( + new NotFoundException( + "You do not have permission to manage this regrading request", + ), + ); + }); + }); + }); }); diff --git a/apps/api/src/api/admin/admin.service.ts b/apps/api/src/api/admin/admin.service.ts index 12c5e6e6..cfafa80a 100644 --- a/apps/api/src/api/admin/admin.service.ts +++ b/apps/api/src/api/admin/admin.service.ts @@ -10,6 +10,7 @@ import { UserSession, } from "../../auth/interfaces/user.session.interface"; import { PrismaService } from "../../database/prisma.service"; +import { EmailService } from "../../common/services/email.service"; import { LLMPricingService } from "../llm/core/services/llm-pricing.service"; import { LLM_PRICING_SERVICE } from "../llm/llm.constants"; import { AdminAddAssignmentToGroupResponseDto } from "./dto/assignment/add.assignment.to.group.response.dto"; @@ -42,6 +43,7 @@ export class AdminService { private readonly prisma: PrismaService, @Inject(LLM_PRICING_SERVICE) private readonly llmPricingService: LLMPricingService, + private readonly emailService: EmailService, ) {} /** @@ -785,62 +787,326 @@ export class AdminService { } async dismissFlaggedSubmission(id: number) { - return this.prisma.regradingRequest.update({ + const request = await this.prisma.regradingRequest.findUnique({ + where: { id }, + include: { + assignment: { select: { name: true } }, + assignmentAttempt: { select: { grade: true } }, + }, + }); + + if (!request) { + throw new Error(`Regrading request with ID ${id} not found`); + } + + const currentGrade = request.assignmentAttempt.grade || 0; + + const result = await this.prisma.regradingRequest.update({ where: { id }, data: { regradingStatus: "REJECTED", }, }); + + try { + await this.emailService.sendGradeUpdateNotification( + request.userId, + request.assignment.name, + request.assignmentId, + request.attemptId, + currentGrade, + currentGrade, + "REJECTED", + ); + } catch (error) { + this.logger.error("Failed to send dismissal email:", error); + } + + return result; } async getRegradingRequests() { - return this.prisma.regradingRequest.findMany({ + const requests = await this.prisma.regradingRequest.findMany({ orderBy: { createdAt: "desc", }, + include: { + assignment: { + select: { + id: true, + name: true, + }, + }, + assignmentAttempt: { + select: { + id: true, + userId: true, + grade: true, + createdAt: true, + }, + }, + }, }); + + return requests.map((request) => ({ + ...request, + createdAt: request.createdAt?.toISOString() || null, + updatedAt: request.updatedAt?.toISOString() || null, + assignmentAttempt: request.assignmentAttempt + ? { + ...request.assignmentAttempt, + createdAt: + request.assignmentAttempt.createdAt?.toISOString() || null, + } + : null, + })); } - async approveRegradingRequest(id: number, newGrade: number) { + async approveRegradingRequest( + id: number, + newGrade: number, + authorEmail?: string, + ) { + this.logger.log( + `[ApproveRegrading] Request ID: ${id}, New Grade: ${newGrade}, Author: ${authorEmail}`, + ); + const request = await this.prisma.regradingRequest.findUnique({ where: { id }, + include: { + assignment: { select: { name: true } }, + assignmentAttempt: { select: { grade: true } }, + }, }); if (!request) { throw new Error(`Regrading request with ID ${id} not found`); } + const oldGrade = request.assignmentAttempt.grade || 0; + this.logger.log( + `[ApproveRegrading] Attempt ID: ${request.attemptId}, Old Grade: ${oldGrade}, New Grade: ${newGrade}`, + ); + await this.prisma.regradingRequest.update({ where: { id }, data: { regradingStatus: "APPROVED", + processedBy: authorEmail || null, }, }); - await this.prisma.assignmentAttempt.update({ + const updatedAttempt = await this.prisma.assignmentAttempt.update({ where: { id: request.attemptId }, data: { - grade: newGrade / 100, + grade: newGrade, }, }); + this.logger.log( + `[ApproveRegrading] Grade updated successfully. New grade in DB: ${updatedAttempt.grade}`, + ); + + try { + await this.emailService.sendGradeUpdateNotification( + request.userId, + request.assignment.name, + request.assignmentId, + request.attemptId, + oldGrade, + newGrade, + "APPROVED", + ); + } catch (error) { + this.logger.error("Failed to send approval email:", error); + } + return { success: true }; } - async rejectRegradingRequest(id: number, reason: string) { + async rejectRegradingRequest( + id: number, + reason: string, + authorEmail?: string, + ) { const request = await this.prisma.regradingRequest.findUnique({ where: { id }, + include: { + assignment: { select: { name: true } }, + assignmentAttempt: { select: { grade: true } }, + }, }); if (!request) { throw new Error(`Regrading request with ID ${id} not found`); } + const currentGrade = request.assignmentAttempt.grade || 0; + + await this.prisma.regradingRequest.update({ + where: { id }, + data: { + regradingStatus: "REJECTED", + regradingReason: reason, + processedBy: authorEmail || null, + }, + }); + + try { + await this.emailService.sendGradeUpdateNotification( + request.userId, + request.assignment.name, + request.assignmentId, + request.attemptId, + currentGrade, + currentGrade, + "REJECTED", + ); + } catch (error) { + this.logger.error("Failed to send rejection email:", error); + } + + return { success: true }; + } + + /** + * Get regrading requests for assignments created by a specific author + */ + async getAuthorRegradingRequests(authorId: string, assignmentId?: number) { + const authorAssignments = await this.prisma.assignmentAuthor.findMany({ + where: { + userId: authorId, + ...(assignmentId && { assignmentId }), + }, + select: { + assignmentId: true, + }, + }); + + const assignmentIds = authorAssignments.map((aa) => aa.assignmentId); + + if (assignmentIds.length === 0) { + return []; + } + + const regradingRequests = await this.prisma.regradingRequest.findMany({ + where: { + assignmentId: { + in: assignmentIds, + }, + }, + include: { + assignment: { + select: { + id: true, + name: true, + }, + }, + assignmentAttempt: { + select: { + id: true, + userId: true, + grade: true, + createdAt: true, + }, + }, + }, + orderBy: { + createdAt: "desc", + }, + }); + + return regradingRequests; + } + + /** + * Approve a regrading request (author-specific) + */ + async approveAuthorRegradingRequest( + id: number, + newGrade: number, + authorId: string, + ) { + const request = await this.prisma.regradingRequest.findUnique({ + where: { id }, + include: { + assignment: true, + }, + }); + + if (!request) { + throw new NotFoundException(`Regrading request with ID ${id} not found`); + } + + const authorAssignment = await this.prisma.assignmentAuthor.findFirst({ + where: { + assignmentId: request.assignmentId, + userId: authorId, + }, + }); + + if (!authorAssignment) { + throw new NotFoundException( + `You do not have permission to manage this regrading request`, + ); + } + + await this.prisma.regradingRequest.update({ + where: { id }, + data: { + regradingStatus: "APPROVED", + processedBy: authorId, + }, + }); + + await this.prisma.assignmentAttempt.update({ + where: { id: request.attemptId }, + data: { + grade: newGrade, + }, + }); + + return { success: true }; + } + + /** + * Reject a regrading request (author-specific) + */ + async rejectAuthorRegradingRequest( + id: number, + reason: string, + authorId: string, + ) { + const request = await this.prisma.regradingRequest.findUnique({ + where: { id }, + include: { + assignment: true, + }, + }); + + if (!request) { + throw new NotFoundException(`Regrading request with ID ${id} not found`); + } + + const authorAssignment = await this.prisma.assignmentAuthor.findFirst({ + where: { + assignmentId: request.assignmentId, + userId: authorId, + }, + }); + + if (!authorAssignment) { + throw new NotFoundException( + `You do not have permission to manage this regrading request`, + ); + } + await this.prisma.regradingRequest.update({ where: { id }, data: { regradingStatus: "REJECTED", regradingReason: reason, + processedBy: authorId, }, }); @@ -2607,4 +2873,45 @@ export class AdminService { .slice(0, limit), }; } + + /** + * Get author settings + */ + async getAuthorSettings(userId: string) { + let settings = await this.prisma.authorSettings.findUnique({ + where: { userId }, + }); + + if (!settings) { + settings = await this.prisma.authorSettings.create({ + data: { + userId, + emailOnRegradingRequest: true, + }, + }); + } + + return settings; + } + + /** + * Update author settings + */ + async updateAuthorSettings( + userId: string, + settingsDto: { emailOnRegradingRequest: boolean }, + ) { + const settings = await this.prisma.authorSettings.upsert({ + where: { userId }, + update: { + emailOnRegradingRequest: settingsDto.emailOnRegradingRequest, + }, + create: { + userId, + emailOnRegradingRequest: settingsDto.emailOnRegradingRequest, + }, + }); + + return settings; + } } diff --git a/apps/api/src/api/admin/controllers/author-regrading-requests.controller.ts b/apps/api/src/api/admin/controllers/author-regrading-requests.controller.ts new file mode 100644 index 00000000..d2f29579 --- /dev/null +++ b/apps/api/src/api/admin/controllers/author-regrading-requests.controller.ts @@ -0,0 +1,125 @@ +import { + Body, + Controller, + Get, + Injectable, + Param, + Post, + Query, + Req, + UseGuards, + UsePipes, + ValidationPipe, +} from "@nestjs/common"; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiParam, + ApiQuery, + ApiResponse, + ApiTags, +} from "@nestjs/swagger"; +import { + UserRole, + UserSessionRequest, +} from "src/auth/interfaces/user.session.interface"; +import { Roles } from "src/auth/role/roles.global.guard"; +import { AdminService } from "../admin.service"; + +class ApproveRegradingRequestDto { + newGrade: number; +} + +class RejectRegradingRequestDto { + reason: string; +} + +@ApiTags("Author - Regrading Requests") +@UsePipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + }), +) +@ApiBearerAuth() +@Injectable() +@Controller({ + path: "author/regrading-requests", + version: "1", +}) +export class AuthorRegradingRequestsController { + constructor(private adminService: AdminService) {} + + @Get() + @Roles(UserRole.AUTHOR) + @ApiOperation({ + summary: + "Get all regrading requests for assignments created by this author", + }) + @ApiQuery({ + name: "assignmentId", + required: false, + description: "Filter by specific assignment ID", + }) + @ApiResponse({ status: 200 }) + @ApiResponse({ status: 403 }) + async getAuthorRegradingRequests( + @Req() request: UserSessionRequest, + @Query("assignmentId") assignmentId?: string, + ) { + const userId = request.userSession.userId; + return this.adminService.getAuthorRegradingRequests( + userId, + assignmentId ? Number(assignmentId) : undefined, + ); + } + + @Post(":id/approve") + @Roles(UserRole.AUTHOR) + @ApiOperation({ + summary: + "Approve a regrading request (authors can only approve their own assignment requests)", + }) + @ApiParam({ name: "id", required: true, description: "Regrading request ID" }) + @ApiBody({ type: ApproveRegradingRequestDto }) + @ApiResponse({ status: 200 }) + @ApiResponse({ status: 403 }) + @ApiResponse({ status: 404 }) + async approveRegradingRequest( + @Param("id") id: number, + @Body() approveDto: ApproveRegradingRequestDto, + @Req() request: UserSessionRequest, + ) { + const userId = request.userSession.userId; + return this.adminService.approveAuthorRegradingRequest( + Number(id), + approveDto.newGrade, + userId, + ); + } + + @Post(":id/reject") + @Roles(UserRole.AUTHOR) + @ApiOperation({ + summary: + "Reject a regrading request (authors can only reject their own assignment requests)", + }) + @ApiParam({ name: "id", required: true, description: "Regrading request ID" }) + @ApiBody({ type: RejectRegradingRequestDto }) + @ApiResponse({ status: 200 }) + @ApiResponse({ status: 403 }) + @ApiResponse({ status: 404 }) + async rejectRegradingRequest( + @Param("id") id: number, + @Body() rejectDto: RejectRegradingRequestDto, + @Req() request: UserSessionRequest, + ) { + const userId = request.userSession.userId; + return this.adminService.rejectAuthorRegradingRequest( + Number(id), + rejectDto.reason, + userId, + ); + } +} diff --git a/apps/api/src/api/admin/controllers/regrading-requests.controller.ts b/apps/api/src/api/admin/controllers/regrading-requests.controller.ts index e46e9dd2..638e8fbd 100644 --- a/apps/api/src/api/admin/controllers/regrading-requests.controller.ts +++ b/apps/api/src/api/admin/controllers/regrading-requests.controller.ts @@ -5,6 +5,8 @@ import { Injectable, Param, Post, + Req, + UseGuards, UsePipes, ValidationPipe, } from "@nestjs/common"; @@ -16,17 +18,32 @@ import { ApiResponse, ApiTags, } from "@nestjs/swagger"; +import { IsNumber, IsString, IsNotEmpty } from "class-validator"; import { AdminService } from "../admin.service"; +import { AdminGuard } from "../../../auth/guards/admin.guard"; +import { Request } from "express"; + +interface AdminSessionRequest extends Request { + userSession: { + userId: string; + role: string; + }; +} class ApproveRegradingRequestDto { + @IsNumber() + @IsNotEmpty() newGrade: number; } class RejectRegradingRequestDto { + @IsString() + @IsNotEmpty() reason: string; } @ApiTags("Admin") +@UseGuards(AdminGuard) @UsePipes( new ValidationPipe({ whitelist: true, @@ -59,10 +76,12 @@ export class RegradingRequestsController { approveRegradingRequest( @Param("id") id: number, @Body() approveDto: ApproveRegradingRequestDto, + @Req() request: AdminSessionRequest, ) { return this.adminService.approveRegradingRequest( Number(id), approveDto.newGrade, + request.userSession?.userId, ); } @@ -75,10 +94,12 @@ export class RegradingRequestsController { rejectRegradingRequest( @Param("id") id: number, @Body() rejectDto: RejectRegradingRequestDto, + @Req() request: AdminSessionRequest, ) { return this.adminService.rejectRegradingRequest( Number(id), rejectDto.reason, + request.userSession?.userId, ); } } diff --git a/apps/api/src/api/admin/controllers/settings.controller.ts b/apps/api/src/api/admin/controllers/settings.controller.ts new file mode 100644 index 00000000..7adf1a44 --- /dev/null +++ b/apps/api/src/api/admin/controllers/settings.controller.ts @@ -0,0 +1,73 @@ +import { + Body, + Controller, + Get, + Put, + Req, + UseGuards, + UsePipes, + ValidationPipe, +} from "@nestjs/common"; +import { + ApiBearerAuth, + ApiBody, + ApiOperation, + ApiResponse, + ApiTags, +} from "@nestjs/swagger"; +import { IsBoolean } from "class-validator"; +import { AdminService } from "../admin.service"; +import { AdminGuard } from "../../../auth/guards/admin.guard"; +import { Request } from "express"; + +interface AdminSessionRequest extends Request { + userSession: { + userId: string; + role: string; + }; +} + +class UpdateSettingsDto { + @IsBoolean() + emailOnRegradingRequest: boolean; +} + +@ApiTags("Admin") +@UseGuards(AdminGuard) +@UsePipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + }), +) +@ApiBearerAuth() +@Controller({ + path: "admin/settings", + version: "1", +}) +export class SettingsController { + constructor(private adminService: AdminService) {} + + @Get() + @ApiOperation({ summary: "Get author settings" }) + @ApiResponse({ status: 200 }) + @ApiResponse({ status: 403 }) + getSettings(@Req() request: AdminSessionRequest) { + return this.adminService.getAuthorSettings(request.userSession?.userId); + } + + @Put() + @ApiOperation({ summary: "Update author settings" }) + @ApiBody({ type: UpdateSettingsDto }) + @ApiResponse({ status: 200 }) + @ApiResponse({ status: 403 }) + updateSettings( + @Req() request: AdminSessionRequest, + @Body() settingsDto: UpdateSettingsDto, + ) { + return this.adminService.updateAuthorSettings( + request.userSession?.userId, + settingsDto, + ); + } +} diff --git a/apps/api/src/api/assignment/attempt/attempt.service.ts b/apps/api/src/api/assignment/attempt/attempt.service.ts index 37537ab5..4739af03 100644 --- a/apps/api/src/api/assignment/attempt/attempt.service.ts +++ b/apps/api/src/api/assignment/attempt/attempt.service.ts @@ -2342,25 +2342,13 @@ export class AttemptServiceV1 { learnerResponse: string; } { const choices = this.parseChoices(question.choices); - console.log( - `Handling single correct question response for question ID: ${question.id}`, - ); const learnerChoice = createQuestionResponseAttemptRequestDto.learnerChoices[0]; - console.log( - `Learner choice: ${learnerChoice}, Choices: ${JSON.stringify(choices)}`, - ); const normalizedLearnerChoice = this.normalizeText(learnerChoice); const correctChoice = choices.find((choice) => choice.isCorrect); - console.log( - `Correct choice: ${correctChoice ? correctChoice.choice : "None"}`, - ); const selectedChoice = choices.find( (choice) => this.normalizeText(choice.choice) === normalizedLearnerChoice, ); - console.log( - `Selected choice: ${selectedChoice ? selectedChoice.choice : "None"}`, - ); const data = { learnerChoice, diff --git a/apps/api/src/api/assignment/attempt/dto/assignment-attempt/feedback.request.dto.ts b/apps/api/src/api/assignment/attempt/dto/assignment-attempt/feedback.request.dto.ts index 4d86a476..508b013e 100644 --- a/apps/api/src/api/assignment/attempt/dto/assignment-attempt/feedback.request.dto.ts +++ b/apps/api/src/api/assignment/attempt/dto/assignment-attempt/feedback.request.dto.ts @@ -1,4 +1,5 @@ import { + IsArray, IsBoolean, IsEmail, IsNumber, @@ -61,6 +62,15 @@ export class RegradingRequestDto { @IsString() reason: string; + + @IsNumber() + @IsOptional() + proposedGrade?: number; + + @IsArray() + @IsNumber({}, { each: true }) + @IsOptional() + questionIds?: number[]; } export class RegradingStatusResponseDto { status: string; diff --git a/apps/api/src/api/assignment/v2/modules/assignment.module.ts b/apps/api/src/api/assignment/v2/modules/assignment.module.ts index 56d88e1a..c1c1386e 100644 --- a/apps/api/src/api/assignment/v2/modules/assignment.module.ts +++ b/apps/api/src/api/assignment/v2/modules/assignment.module.ts @@ -3,6 +3,7 @@ import { Module } from "@nestjs/common"; import { AdminService } from "src/api/admin/admin.service"; import { LlmModule } from "src/api/llm/llm.module"; import { AdminVerificationService } from "src/auth/services/admin-verification.service"; +import { EmailService } from "src/common/services/email.service"; import { PrismaService } from "src/database/prisma.service"; import { AssignmentControllerV2 } from "../controllers/assignment.controller"; import { DraftManagementController } from "../controllers/draft-management.controller"; @@ -30,6 +31,7 @@ import { VersionManagementService } from "../services/version-management.service QuestionService, ReportService, JobStatusServiceV2, + EmailService, AssignmentRepository, QuestionRepository, diff --git a/apps/api/src/api/assignment/v2/tests/unit/__mocks__/ common-mocks.ts b/apps/api/src/api/assignment/v2/tests/unit/__mocks__/ common-mocks.ts index a4146eb5..0c5f1067 100644 --- a/apps/api/src/api/assignment/v2/tests/unit/__mocks__/ common-mocks.ts +++ b/apps/api/src/api/assignment/v2/tests/unit/__mocks__/ common-mocks.ts @@ -1464,6 +1464,9 @@ export const createMockRegradingRequest = ( regradingStatus: status, createdAt: new Date(), updatedAt: new Date(), + proposedGrade: null, + questionIds: [], + processedBy: null, }; return { diff --git a/apps/api/src/api/attempt/attempt.controller.ts b/apps/api/src/api/attempt/attempt.controller.ts index 2d169aef..b443804f 100644 --- a/apps/api/src/api/attempt/attempt.controller.ts +++ b/apps/api/src/api/attempt/attempt.controller.ts @@ -510,4 +510,47 @@ export class AttemptControllerV2 { statistics, }; } + + @Patch(":attemptId/question-grades") + @Roles(UserRole.AUTHOR) + @UseGuards(AssignmentAttemptAccessControlGuard) + @ApiOperation({ + summary: "Update individual question grades for an attempt (authors only).", + }) + @ApiBody({ + schema: { + type: "object", + properties: { + questionGrades: { + type: "object", + additionalProperties: { type: "number" }, + description: "Map of questionResponseId to new points value", + }, + regradingRequestId: { + type: "number", + description: "Optional regrading request ID to mark as completed", + }, + }, + }, + }) + @ApiResponse({ status: 200 }) + @ApiResponse({ status: 403 }) + async updateQuestionGrades( + @Param("assignmentId") assignmentId: string, + @Param("attemptId") attemptId: string, + @Body() + body: { + questionGrades: Record; + regradingRequestId?: number; + }, + @Req() request: UserSessionRequest, + ) { + return this.attemptService.updateQuestionGrades( + Number(assignmentId), + Number(attemptId), + body.questionGrades, + body.regradingRequestId, + request.userSession, + ); + } } diff --git a/apps/api/src/api/attempt/attempt.module.ts b/apps/api/src/api/attempt/attempt.module.ts index b22cab57..39f601c7 100644 --- a/apps/api/src/api/attempt/attempt.module.ts +++ b/apps/api/src/api/attempt/attempt.module.ts @@ -1,5 +1,6 @@ import { Module } from "@nestjs/common"; import { PrismaService } from "../../database/prisma.service"; +import { EmailService } from "../../common/services/email.service"; import { AssignmentAttemptAccessControlGuard } from "../assignment/attempt/guards/assignment.attempt.access.control.guard"; import { QuestionService } from "../assignment/question/question.service"; import { AssignmentModuleV2 } from "../assignment/v2/modules/assignment.module"; @@ -61,6 +62,7 @@ import { TranslationService } from "./services/translation/translation.service"; useClass: GradingAuditService, }, PrismaService, + EmailService, { provide: FILE_CONTENT_EXTRACTION_SERVICE, useClass: FileContentExtractionService, diff --git a/apps/api/src/api/attempt/services/__tests__/attempt-regrading.service.spec.ts b/apps/api/src/api/attempt/services/__tests__/attempt-regrading.service.spec.ts new file mode 100644 index 00000000..7d9529b5 --- /dev/null +++ b/apps/api/src/api/attempt/services/__tests__/attempt-regrading.service.spec.ts @@ -0,0 +1,530 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { + BadRequestException, + ForbiddenException, + NotFoundException, +} from "@nestjs/common"; +import { RegradingStatus } from "@prisma/client"; +import { AttemptRegradingService } from "../attempt-regrading.service"; +import { PrismaService } from "../../../../database/prisma.service"; +import { EmailService } from "../../../../common/services/email.service"; +import { UserRole } from "../../../../auth/interfaces/user.session.interface"; + +describe("AttemptRegradingService", () => { + let service: AttemptRegradingService; + let prismaService: PrismaService; + let emailService: EmailService; + + const mockPrismaService = { + assignmentAttempt: { + findUnique: jest.fn(), + }, + regradingRequest: { + findFirst: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + assignment: { + findUnique: jest.fn(), + }, + assignmentAuthor: { + findMany: jest.fn(), + }, + authorSettings: { + findMany: jest.fn(), + }, + }; + + const mockEmailService = { + sendRegradingRequestNotification: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AttemptRegradingService, + { provide: PrismaService, useValue: mockPrismaService }, + { provide: EmailService, useValue: mockEmailService }, + ], + }).compile(); + + service = module.get(AttemptRegradingService); + prismaService = module.get(PrismaService); + emailService = module.get(EmailService); + + jest.clearAllMocks(); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + describe("processRegradingRequest", () => { + const mockUserSession = { + userId: "learner@example.com", + role: UserRole.LEARNER, + assignmentId: 1, + groupId: "group-1", + }; + + const mockAssignmentAttempt = { + id: 1, + assignmentId: 1, + userId: "learner@example.com", + grade: 0.5, + submitted: true, + createdAt: new Date(), + updatedAt: new Date(), + expiresAt: new Date(), + }; + + const mockAssignment = { + id: 1, + name: "Test Assignment", + }; + + const mockAssignmentAuthors = [ + { userId: "author1@example.com" }, + { userId: "author2@example.com" }, + ]; + + const mockRegradingRequestDto = { + assignmentId: 1, + userId: "learner@example.com", + attemptId: 1, + reason: "I believe my answer was correct", + proposedGrade: 80, + questionIds: [1, 2], + }; + + it("should create a new regrading request when none exists", async () => { + mockPrismaService.assignmentAttempt.findUnique.mockResolvedValue( + mockAssignmentAttempt, + ); + mockPrismaService.regradingRequest.findFirst.mockResolvedValue(null); + mockPrismaService.regradingRequest.create.mockResolvedValue({ + id: 1, + ...mockRegradingRequestDto, + regradingStatus: RegradingStatus.PENDING, + createdAt: new Date(), + updatedAt: new Date(), + }); + mockPrismaService.assignment.findUnique.mockResolvedValue(mockAssignment); + mockPrismaService.assignmentAuthor.findMany.mockResolvedValue( + mockAssignmentAuthors, + ); + mockPrismaService.authorSettings.findMany.mockResolvedValue([]); + mockEmailService.sendRegradingRequestNotification.mockResolvedValue(); + + const result = await service.processRegradingRequest( + 1, + 1, + mockRegradingRequestDto, + mockUserSession, + ); + + expect(result).toEqual({ success: true, id: 1 }); + expect(mockPrismaService.regradingRequest.create).toHaveBeenCalledWith({ + data: { + assignmentId: 1, + attemptId: 1, + userId: "learner@example.com", + regradingReason: "I believe my answer was correct", + proposedGrade: 80, + questionIds: [1, 2], + regradingStatus: RegradingStatus.PENDING, + }, + }); + expect( + mockEmailService.sendRegradingRequestNotification, + ).toHaveBeenCalledWith( + ["author1@example.com", "author2@example.com"], + "learner@example.com", + "Test Assignment", + 1, + 1, + 1, + "I believe my answer was correct", + 0.5, + 80, + [1, 2], + ); + }); + + it("should update existing regrading request", async () => { + const existingRequest = { + id: 2, + assignmentId: 1, + attemptId: 1, + userId: "learner@example.com", + regradingReason: "Old reason", + proposedGrade: 70, + questionIds: [1], + regradingStatus: RegradingStatus.REJECTED, + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockPrismaService.assignmentAttempt.findUnique.mockResolvedValue( + mockAssignmentAttempt, + ); + mockPrismaService.regradingRequest.findFirst.mockResolvedValue( + existingRequest, + ); + mockPrismaService.regradingRequest.update.mockResolvedValue({ + ...existingRequest, + regradingReason: "I believe my answer was correct", + proposedGrade: 80, + questionIds: [1, 2], + regradingStatus: RegradingStatus.PENDING, + updatedAt: new Date(), + }); + mockPrismaService.assignment.findUnique.mockResolvedValue(mockAssignment); + mockPrismaService.assignmentAuthor.findMany.mockResolvedValue( + mockAssignmentAuthors, + ); + mockPrismaService.authorSettings.findMany.mockResolvedValue([]); + mockEmailService.sendRegradingRequestNotification.mockResolvedValue(); + + const result = await service.processRegradingRequest( + 1, + 1, + mockRegradingRequestDto, + mockUserSession, + ); + + expect(result).toEqual({ success: true, id: 2 }); + expect(mockPrismaService.regradingRequest.update).toHaveBeenCalledWith({ + where: { id: 2 }, + data: { + regradingReason: "I believe my answer was correct", + proposedGrade: 80, + questionIds: [1, 2], + regradingStatus: RegradingStatus.PENDING, + updatedAt: expect.any(Date) as Date, + }, + }); + expect( + mockEmailService.sendRegradingRequestNotification, + ).toHaveBeenCalled(); + }); + + it("should handle multiple question IDs correctly", async () => { + mockPrismaService.assignmentAttempt.findUnique.mockResolvedValue( + mockAssignmentAttempt, + ); + mockPrismaService.regradingRequest.findFirst.mockResolvedValue(null); + mockPrismaService.regradingRequest.create.mockResolvedValue({ + id: 1, + ...mockRegradingRequestDto, + questionIds: [1, 2, 3, 4], + regradingStatus: RegradingStatus.PENDING, + createdAt: new Date(), + updatedAt: new Date(), + }); + mockPrismaService.assignment.findUnique.mockResolvedValue(mockAssignment); + mockPrismaService.assignmentAuthor.findMany.mockResolvedValue( + mockAssignmentAuthors, + ); + mockPrismaService.authorSettings.findMany.mockResolvedValue([]); + mockEmailService.sendRegradingRequestNotification.mockResolvedValue(); + + const multiQuestionDto = { + ...mockRegradingRequestDto, + questionIds: [1, 2, 3, 4], + }; + + await service.processRegradingRequest( + 1, + 1, + multiQuestionDto, + mockUserSession, + ); + + expect(mockPrismaService.regradingRequest.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + questionIds: [1, 2, 3, 4], + }) as Record, + }) as Record, + ); + + expect( + mockEmailService.sendRegradingRequestNotification, + ).toHaveBeenCalledWith( + expect.any(Array), + expect.any(String), + expect.any(String), + expect.any(Number), + expect.any(Number), + expect.any(Number), + expect.any(String), + expect.any(Number), + expect.any(Number), + [1, 2, 3, 4], + ); + }); + + it("should throw NotFoundException when attempt does not exist", async () => { + mockPrismaService.assignmentAttempt.findUnique.mockResolvedValue(null); + + await expect( + service.processRegradingRequest( + 1, + 1, + mockRegradingRequestDto, + mockUserSession, + ), + ).rejects.toThrow( + new NotFoundException("Assignment attempt with ID 1 not found."), + ); + }); + + it("should throw BadRequestException when assignment ID does not match", async () => { + mockPrismaService.assignmentAttempt.findUnique.mockResolvedValue({ + ...mockAssignmentAttempt, + assignmentId: 999, + }); + + await expect( + service.processRegradingRequest( + 1, + 1, + mockRegradingRequestDto, + mockUserSession, + ), + ).rejects.toThrow( + new BadRequestException("Assignment ID does not match the attempt."), + ); + }); + + it("should throw ForbiddenException when user does not own the attempt", async () => { + mockPrismaService.assignmentAttempt.findUnique.mockResolvedValue({ + ...mockAssignmentAttempt, + userId: "different-user@example.com", + }); + + await expect( + service.processRegradingRequest( + 1, + 1, + mockRegradingRequestDto, + mockUserSession, + ), + ).rejects.toThrow( + new ForbiddenException( + "You do not have permission to request regrading for this attempt.", + ), + ); + }); + + it("should handle email notification failures gracefully", async () => { + mockPrismaService.assignmentAttempt.findUnique.mockResolvedValue( + mockAssignmentAttempt, + ); + mockPrismaService.regradingRequest.findFirst.mockResolvedValue(null); + mockPrismaService.regradingRequest.create.mockResolvedValue({ + id: 1, + ...mockRegradingRequestDto, + regradingStatus: RegradingStatus.PENDING, + createdAt: new Date(), + updatedAt: new Date(), + }); + mockPrismaService.assignment.findUnique.mockResolvedValue(mockAssignment); + mockPrismaService.assignmentAuthor.findMany.mockResolvedValue( + mockAssignmentAuthors, + ); + mockEmailService.sendRegradingRequestNotification.mockRejectedValue( + new Error("Email service error"), + ); + + const result = await service.processRegradingRequest( + 1, + 1, + mockRegradingRequestDto, + mockUserSession, + ); + + expect(result).toEqual({ success: true, id: 1 }); + }); + + it("should handle empty question IDs array", async () => { + mockPrismaService.assignmentAttempt.findUnique.mockResolvedValue( + mockAssignmentAttempt, + ); + mockPrismaService.regradingRequest.findFirst.mockResolvedValue(null); + mockPrismaService.regradingRequest.create.mockResolvedValue({ + id: 1, + ...mockRegradingRequestDto, + questionIds: [], + regradingStatus: RegradingStatus.PENDING, + createdAt: new Date(), + updatedAt: new Date(), + }); + mockPrismaService.assignment.findUnique.mockResolvedValue(mockAssignment); + mockPrismaService.assignmentAuthor.findMany.mockResolvedValue( + mockAssignmentAuthors, + ); + mockPrismaService.authorSettings.findMany.mockResolvedValue([]); + mockEmailService.sendRegradingRequestNotification.mockResolvedValue(); + + const emptyQuestionDto = { + ...mockRegradingRequestDto, + questionIds: undefined, + }; + + await service.processRegradingRequest( + 1, + 1, + emptyQuestionDto, + mockUserSession, + ); + + expect(mockPrismaService.regradingRequest.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + questionIds: [], + }) as Record, + }) as Record, + ); + }); + + it("should handle assignment with no authors", async () => { + mockPrismaService.assignmentAttempt.findUnique.mockResolvedValue( + mockAssignmentAttempt, + ); + mockPrismaService.regradingRequest.findFirst.mockResolvedValue(null); + mockPrismaService.regradingRequest.create.mockResolvedValue({ + id: 1, + ...mockRegradingRequestDto, + regradingStatus: RegradingStatus.PENDING, + createdAt: new Date(), + updatedAt: new Date(), + }); + mockPrismaService.assignment.findUnique.mockResolvedValue(mockAssignment); + mockPrismaService.assignmentAuthor.findMany.mockResolvedValue([]); + + const result = await service.processRegradingRequest( + 1, + 1, + mockRegradingRequestDto, + mockUserSession, + ); + + expect(result).toEqual({ success: true, id: 1 }); + expect( + mockEmailService.sendRegradingRequestNotification, + ).not.toHaveBeenCalled(); + }); + }); + + describe("getRegradingStatus", () => { + const mockUserSession = { + userId: "learner@example.com", + role: UserRole.LEARNER, + assignmentId: 1, + groupId: "group-1", + }; + + it("should return regrading status when request exists", async () => { + const mockRequest = { + id: 1, + assignmentId: 1, + attemptId: 1, + userId: "learner@example.com", + regradingStatus: RegradingStatus.APPROVED, + regradingReason: "Valid concern", + proposedGrade: 80, + questionIds: [1, 2], + createdAt: new Date(), + updatedAt: new Date(), + }; + + mockPrismaService.regradingRequest.findFirst.mockResolvedValue( + mockRequest, + ); + + const result = await service.getRegradingStatus(1, 1, mockUserSession); + + expect(result).toEqual({ status: RegradingStatus.APPROVED }); + expect(mockPrismaService.regradingRequest.findFirst).toHaveBeenCalledWith( + { + where: { + assignmentId: 1, + attemptId: 1, + userId: "learner@example.com", + }, + }, + ); + }); + + it("should throw NotFoundException when request does not exist", async () => { + mockPrismaService.regradingRequest.findFirst.mockResolvedValue(null); + + await expect( + service.getRegradingStatus(1, 1, mockUserSession), + ).rejects.toThrow( + new NotFoundException( + "Regrading request for assignment 1 and attempt 1 not found.", + ), + ); + }); + + it("should return PENDING status", async () => { + mockPrismaService.regradingRequest.findFirst.mockResolvedValue({ + id: 1, + regradingStatus: RegradingStatus.PENDING, + assignmentId: 1, + attemptId: 1, + userId: "learner@example.com", + regradingReason: "Reason", + proposedGrade: null, + questionIds: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + + const result = await service.getRegradingStatus(1, 1, mockUserSession); + + expect(result).toEqual({ status: RegradingStatus.PENDING }); + }); + + it("should return REJECTED status", async () => { + mockPrismaService.regradingRequest.findFirst.mockResolvedValue({ + id: 1, + regradingStatus: RegradingStatus.REJECTED, + assignmentId: 1, + attemptId: 1, + userId: "learner@example.com", + regradingReason: "Reason", + proposedGrade: null, + questionIds: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + + const result = await service.getRegradingStatus(1, 1, mockUserSession); + + expect(result).toEqual({ status: RegradingStatus.REJECTED }); + }); + + it("should return COMPLETED status", async () => { + mockPrismaService.regradingRequest.findFirst.mockResolvedValue({ + id: 1, + regradingStatus: RegradingStatus.COMPLETED, + assignmentId: 1, + attemptId: 1, + userId: "learner@example.com", + regradingReason: "Reason", + proposedGrade: null, + questionIds: [], + createdAt: new Date(), + updatedAt: new Date(), + }); + + const result = await service.getRegradingStatus(1, 1, mockUserSession); + + expect(result).toEqual({ status: RegradingStatus.COMPLETED }); + }); + }); +}); diff --git a/apps/api/src/api/attempt/services/attempt-regrading.service.ts b/apps/api/src/api/attempt/services/attempt-regrading.service.ts index a7592050..86f55014 100644 --- a/apps/api/src/api/attempt/services/attempt-regrading.service.ts +++ b/apps/api/src/api/attempt/services/attempt-regrading.service.ts @@ -11,11 +11,15 @@ import { RequestRegradingResponseDto, } from "src/api/assignment/attempt/dto/assignment-attempt/feedback.request.dto"; import { UserSession } from "../../../auth/interfaces/user.session.interface"; +import { EmailService } from "../../../common/services/email.service"; import { PrismaService } from "../../../database/prisma.service"; @Injectable() export class AttemptRegradingService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly emailService: EmailService, + ) {} /** * Process a regrading request @@ -62,22 +66,22 @@ export class AttemptRegradingService { }, }); + let regradingRequestId: number; + if (existingRegradingRequest) { const updatedRegradingRequest = await this.prisma.regradingRequest.update( { where: { id: existingRegradingRequest.id }, data: { regradingReason: regradingRequestDto.reason, + proposedGrade: regradingRequestDto.proposedGrade, + questionIds: regradingRequestDto.questionIds || [], regradingStatus: RegradingStatus.PENDING, updatedAt: new Date(), }, }, ); - - return { - success: true, - id: updatedRegradingRequest.id, - }; + regradingRequestId = updatedRegradingRequest.id; } else { const regradingRequest = await this.prisma.regradingRequest.create({ data: { @@ -85,15 +89,27 @@ export class AttemptRegradingService { attemptId: attemptId, userId: userSession.userId, regradingReason: regradingRequestDto.reason, + proposedGrade: regradingRequestDto.proposedGrade, + questionIds: regradingRequestDto.questionIds || [], regradingStatus: RegradingStatus.PENDING, }, }); - - return { - success: true, - id: regradingRequest.id, - }; + regradingRequestId = regradingRequest.id; } + + await this.sendRegradingNotification( + assignmentId, + attemptId, + regradingRequestId, + regradingRequestDto, + userSession.userId, + assignmentAttempt.grade || 0, + ); + + return { + success: true, + id: regradingRequestId, + }; } /** @@ -126,4 +142,78 @@ export class AttemptRegradingService { status: regradingRequest.regradingStatus, }; } + + /** + * Send email notification to authors about regrading request + * @private + */ + private async sendRegradingNotification( + assignmentId: number, + attemptId: number, + regradingRequestId: number, + regradingRequestDto: RegradingRequestDto, + learnerUserId: string, + currentGrade: number, + ): Promise { + try { + const assignment = await this.prisma.assignment.findUnique({ + where: { id: assignmentId }, + select: { name: true }, + }); + + if (!assignment) { + return; + } + + const assignmentAuthors = await this.prisma.assignmentAuthor.findMany({ + where: { assignmentId: assignmentId }, + select: { userId: true }, + }); + + const authorEmails = assignmentAuthors.map((author) => author.userId); + + if (authorEmails.length === 0) { + return; + } + + const allAuthorSettings = await this.prisma.authorSettings.findMany({ + where: { + userId: { in: authorEmails }, + }, + select: { userId: true, emailOnRegradingRequest: true }, + }); + + const settingsMap = new Map( + allAuthorSettings.map((s) => [s.userId, s.emailOnRegradingRequest]), + ); + + const finalEmailList = authorEmails.filter((email) => { + const hasSetting = settingsMap.has(email); + if (!hasSetting) { + return true; + } + const shouldNotify = settingsMap.get(email); + return shouldNotify; + }); + + if (finalEmailList.length === 0) { + return; + } + + await this.emailService.sendRegradingRequestNotification( + finalEmailList, + learnerUserId, + assignment.name, + assignmentId, + attemptId, + regradingRequestId, + regradingRequestDto.reason, + currentGrade, + regradingRequestDto.proposedGrade ?? null, + regradingRequestDto.questionIds ?? [], + ); + } catch { + // Error already logged in console.error above, don't fail the regrading request + } + } } diff --git a/apps/api/src/api/attempt/services/attempt.service.ts b/apps/api/src/api/attempt/services/attempt.service.ts index e62e5ec5..904c1986 100644 --- a/apps/api/src/api/attempt/services/attempt.service.ts +++ b/apps/api/src/api/attempt/services/attempt.service.ts @@ -1,8 +1,12 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable unicorn/no-null */ /* eslint-disable @typescript-eslint/require-await */ -import { Injectable } from "@nestjs/common"; -import { GradingJob, ReportType } from "@prisma/client"; +import { + ForbiddenException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { GradingJob, RegradingStatus, ReportType } from "@prisma/client"; import { Response as ExpressResponse } from "express"; import { catchError, Observable, of, Subject } from "rxjs"; import { BaseAssignmentAttemptResponseDto } from "src/api/assignment/attempt/dto/assignment-attempt/base.assignment.attempt.response.dto"; @@ -25,6 +29,7 @@ import { UserSession, UserSessionRequest, } from "../../../auth/interfaces/user.session.interface"; +import { EmailService } from "../../../common/services/email.service"; import { PrismaService } from "../../../database/prisma.service"; import { AttemptFeedbackService } from "./attempt-feedback.service"; import { AttemptRegradingService } from "./attempt-regrading.service"; @@ -41,6 +46,7 @@ export class AttemptServiceV2 { private readonly regradingService: AttemptRegradingService, private readonly reportingService: AttemptReportingService, private readonly jobStatusService: JobStatusServiceV2, + private readonly emailService: EmailService, ) {} /** @@ -283,10 +289,6 @@ export class AttemptServiceV2 { } }) .catch((error) => { - console.error( - `Failed to get initial status for job ${gradingJobId}:`, - error, - ); subscriber.next({ type: "error", data: JSON.stringify({ @@ -334,15 +336,10 @@ export class AttemptServiceV2 { return; } } - } catch (error) { + } catch { consecutiveErrors++; pollInterval = Math.min(maxPollInterval, pollInterval + 2000); - console.error( - `Poll error for job ${gradingJobId} (attempt ${consecutiveErrors}):`, - error, - ); - if (consecutiveErrors >= 3) { subscriber.next({ type: "error", @@ -356,9 +353,6 @@ export class AttemptServiceV2 { } if (consecutiveErrors >= 10) { - console.error( - `Too many consecutive errors for job ${gradingJobId}, terminating stream`, - ); isStreamActive = false; subscriber.error( new Error( @@ -384,7 +378,6 @@ export class AttemptServiceV2 { } }, error: (error) => { - console.error(`Status subject error for job ${gradingJobId}:`, error); if (isStreamActive) { subscriber.next({ type: "error", @@ -410,10 +403,6 @@ export class AttemptServiceV2 { }; }).pipe( catchError((error: Error) => { - console.error( - `Critical stream error for grading job ${gradingJobId}:`, - error, - ); return of({ type: "error", data: JSON.stringify({ @@ -462,7 +451,6 @@ export class AttemptServiceV2 { const job = await this.getGradingJob(gradingJobId); if (!job) { - console.warn(`Grading job ${gradingJobId} not found during polling`); return { type: "error", data: JSON.stringify({ @@ -486,11 +474,7 @@ export class AttemptServiceV2 { parsedResult = job.result ? JSON.parse(job.result as string) : undefined; - } catch (parseError) { - console.warn( - `Failed to parse job result for ${gradingJobId}:`, - parseError, - ); + } catch { parsedResult = { error: "Result parsing failed", rawResult: job.result, @@ -510,7 +494,6 @@ export class AttemptServiceV2 { }), } as MessageEvent; } catch (error) { - console.error(`Failed to poll grading job ${gradingJobId}:`, error); throw new Error( `Database error while polling job ${gradingJobId}: ${ error instanceof Error ? error.message : "Unknown error" @@ -756,4 +739,164 @@ export class AttemptServiceV2 { userId, ); } + + /** + * Update individual question grades for an attempt (author only) + */ + async updateQuestionGrades( + assignmentId: number, + attemptId: number, + questionGrades: Record, + regradingRequestId: number | undefined, + userSession: UserSession, + ) { + const attempt = await this.prisma.assignmentAttempt.findFirst({ + where: { + id: attemptId, + assignmentId, + }, + include: { + questionResponses: true, + }, + }); + + if (!attempt) { + throw new NotFoundException( + `Attempt ${attemptId} not found for assignment ${assignmentId}`, + ); + } + + const authorAssignment = await this.prisma.assignmentAuthor.findFirst({ + where: { + assignmentId, + userId: userSession.userId, + }, + }); + + if (!authorAssignment && userSession.role !== UserRole.ADMIN) { + throw new ForbiddenException( + "You do not have permission to modify grades for this assignment", + ); + } + + const updatePromises = Object.entries(questionGrades).map( + async ([questionResponseId, newPoints]) => { + const responseId = Number(questionResponseId); + + const response = attempt.questionResponses.find( + (r) => r.id === responseId, + ); + if (!response) { + throw new NotFoundException( + `Question response ${responseId} not found in attempt ${attemptId}`, + ); + } + + return this.prisma.questionResponse.update({ + where: { id: responseId }, + data: { points: newPoints }, + }); + }, + ); + + await Promise.all(updatePromises); + + const updatedResponses = await this.prisma.questionResponse.findMany({ + where: { assignmentAttemptId: attemptId }, + }); + + const questionIds = updatedResponses.map((r) => r.questionId); + const questions = await this.prisma.question.findMany({ + where: { id: { in: questionIds } }, + select: { + id: true, + totalPoints: true, + }, + }); + + const questionPointsMap = new Map( + questions.map((q) => [q.id, q.totalPoints]), + ); + + const totalPossiblePoints = updatedResponses.reduce( + (sum, r) => sum + (questionPointsMap.get(r.questionId) || 0), + 0, + ); + + const totalEarnedPoints = updatedResponses.reduce( + (sum, r) => sum + r.points, + 0, + ); + + const newGrade = + totalPossiblePoints > 0 ? totalEarnedPoints / totalPossiblePoints : 0; + + const oldGrade = attempt.grade || 0; + + await this.prisma.assignmentAttempt.update({ + where: { id: attemptId }, + data: { grade: newGrade }, + }); + + if (regradingRequestId) { + await this.prisma.regradingRequest.update({ + where: { id: regradingRequestId }, + data: { regradingStatus: RegradingStatus.COMPLETED }, + }); + + await this.sendGradeUpdateNotification( + assignmentId, + attemptId, + attempt.userId, + oldGrade, + newGrade, + ); + } + + return { + success: true, + attemptId, + updatedGrade: newGrade * 100, + totalPoints: totalPossiblePoints, + earnedPoints: totalEarnedPoints, + questionsUpdated: Object.keys(questionGrades).length, + }; + } + + /** + * Send email notification to learner about grade update + * @private + */ + private async sendGradeUpdateNotification( + assignmentId: number, + attemptId: number, + learnerUserId: string, + oldGrade: number, + newGrade: number, + ): Promise { + try { + const assignment = await this.prisma.assignment.findUnique({ + where: { id: assignmentId }, + select: { name: true }, + }); + + if (!assignment) { + return; + } + + const learnerEmail = learnerUserId; + + await this.emailService.sendGradeUpdateNotification( + learnerEmail, + assignment.name, + assignmentId, + attemptId, + oldGrade, + newGrade, + "COMPLETED", + ); + } catch { + // Handle error + } + } } diff --git a/apps/api/src/api/scheduled-tasks/scheduled-tasks.module.ts b/apps/api/src/api/scheduled-tasks/scheduled-tasks.module.ts index cbe2f76b..22307999 100644 --- a/apps/api/src/api/scheduled-tasks/scheduled-tasks.module.ts +++ b/apps/api/src/api/scheduled-tasks/scheduled-tasks.module.ts @@ -1,5 +1,6 @@ import { Module } from "@nestjs/common"; import { ScheduleModule } from "@nestjs/schedule"; +import { EmailService } from "src/common/services/email.service"; import { PrismaService } from "../../database/prisma.service"; import { AdminService } from "../admin/admin.service"; import { LlmModule } from "../llm/llm.module"; @@ -7,7 +8,7 @@ import { ScheduledTasksService } from "./services/scheduled-tasks.service"; @Module({ imports: [ScheduleModule.forRoot(), LlmModule], - providers: [ScheduledTasksService, PrismaService, AdminService], + providers: [ScheduledTasksService, PrismaService, AdminService, EmailService], exports: [ScheduledTasksService], }) export class ScheduledTasksModule {} diff --git a/apps/api/src/auth/admin-auth.module.ts b/apps/api/src/auth/admin-auth.module.ts index a15e13b2..1d64f6bd 100644 --- a/apps/api/src/auth/admin-auth.module.ts +++ b/apps/api/src/auth/admin-auth.module.ts @@ -1,5 +1,6 @@ import { Module } from "@nestjs/common"; import { PrismaService } from "src/database/prisma.service"; +import { EmailService } from "src/common/services/email.service"; import { AdminAuthController } from "./controllers/admin-auth.controller"; import { AdminGuard } from "./guards/admin.guard"; import { AdminEmailService } from "./services/admin-email.service"; @@ -9,6 +10,7 @@ import { AdminVerificationService } from "./services/admin-verification.service" controllers: [AdminAuthController], providers: [ PrismaService, + EmailService, AdminVerificationService, AdminEmailService, AdminGuard, diff --git a/apps/api/src/auth/services/admin-email.service.ts b/apps/api/src/auth/services/admin-email.service.ts index 407be368..8c756d92 100644 --- a/apps/api/src/auth/services/admin-email.service.ts +++ b/apps/api/src/auth/services/admin-email.service.ts @@ -1,224 +1,44 @@ -/* eslint-disable @typescript-eslint/no-unsafe-call */ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable unicorn/prefer-module */ -/* eslint-disable @typescript-eslint/no-var-requires */ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ import { Injectable, Logger } from "@nestjs/common"; -import * as nodemailer from "nodemailer"; +import { EmailService, EmailOptions } from "src/common/services/email.service"; /** - * AdminEmailService supports both SendGrid and Gmail SMTP for sending emails. - * - * Environment Variables: - * - * EMAIL_PROVIDER - Choose email provider ('sendgrid' | 'google'). Defaults to 'sendgrid' - * - * SendGrid Configuration: - * - SENDGRID_API_KEY: SendGrid API key (required for SendGrid) - * - SENDGRID_FROM_EMAIL: From email address (defaults to 'noreply@markapp.com') - * - SENDGRID_FROM_NAME: From name (defaults to 'Mark Admin System') - * - * Gmail Configuration: - * - GMAIL_USER: Gmail email address (required for Gmail) - * - GMAIL_APP_PASSWORD: Gmail app password (required for Gmail) - * - * Fallback Strategy: - * - If preferred provider is not available, falls back to the other provider - * - If no providers are configured, uses console logging in development - * - Fails gracefully in production when no providers are available + * AdminEmailService handles admin-specific email operations. + * Uses the general EmailService for actual email delivery. */ - -type EmailProvider = "sendgrid" | "google" | "none"; -const sgMail = require("@sendgrid/mail"); -sgMail.setApiKey(process.env.SENDGRID_API_KEY); @Injectable() export class AdminEmailService { private readonly logger = new Logger(AdminEmailService.name); - private transporter: nodemailer.Transporter; - private emailProvider: EmailProvider; - - constructor() { - this.initializeEmailService(); - } - - private initializeEmailService() { - const providerPreference = - process.env.EMAIL_PROVIDER?.toLowerCase() || "sendgrid"; - - const sendGridApiKey = process.env.SENDGRID_API_KEY; - - const gmailUser = process.env.GMAIL_USER; - const gmailPassword = process.env.GMAIL_APP_PASSWORD; - - if (providerPreference === "sendgrid" && sendGridApiKey) { - try { - sgMail.setApiKey(sendGridApiKey); - this.emailProvider = "sendgrid"; - this.transporter = undefined; - this.logger.log("SendGrid email service initialized"); - return; - } catch (error) { - this.logger.error("Failed to initialize SendGrid:", error); - } - } - if (providerPreference === "google" && gmailUser && gmailPassword) { - this.emailProvider = "google"; - this.transporter = nodemailer.createTransport({ - host: "smtp.gmail.com", - port: 587, - secure: false, - auth: { - user: gmailUser, - pass: gmailPassword, - }, - requireTLS: true, - }); - this.logger.log("Gmail SMTP transporter initialized"); - return; - } else if (gmailUser && gmailPassword) { - this.emailProvider = "google"; - this.transporter = nodemailer.createTransport({ - host: "smtp.gmail.com", - port: 587, - secure: false, - auth: { - user: gmailUser, - pass: gmailPassword, - }, - requireTLS: true, - }); - this.logger.log("Gmail SMTP transporter initialized (fallback)"); - return; - } else if ( - sendGridApiKey && - sgMail && - typeof sgMail.setApiKey === "function" - ) { - try { - sgMail.setApiKey(sendGridApiKey); - this.emailProvider = "sendgrid"; - this.transporter = undefined; - this.logger.log("SendGrid email service initialized (fallback)"); - } catch (error) { - this.logger.error("Failed to initialize SendGrid as fallback:", error); - this.emailProvider = "none"; - this.transporter = undefined; - } - } else { - this.emailProvider = "none"; - this.transporter = undefined; - this.logger.warn( - "No email service configured. Set SENDGRID_API_KEY or GMAIL_USER/GMAIL_APP_PASSWORD. Email service will use console logging in development.", - ); - } - } + constructor(private readonly emailService: EmailService) {} /** - * Send verification code email to admin using configured email provider (SendGrid or Gmail) + * Send verification code email to admin */ async sendVerificationCode(email: string, code: string): Promise { try { - if (this.emailProvider === "none") { - if (process.env.NODE_ENV === "production") { - this.logger.error("Email service not configured for production"); - return false; - } else { - this.logger.log(` -=== ADMIN VERIFICATION CODE === -Email: ${email} -Code: ${code} -Expires: 10 minutes -Provider: Development Console -===============================`); - return true; - } - } - - if (this.emailProvider === "sendgrid") { - return await this.sendVerificationCodeSendGrid(email, code); - } else if (this.emailProvider === "google") { - return await this.sendVerificationCodeGmail(email, code); - } - - return false; - } catch (error) { - this.logger.error(`Failed to send verification code to ${email}:`, error); - return false; - } - } - - /** - * Send verification code using SendGrid - */ - private async sendVerificationCodeSendGrid( - email: string, - code: string, - ): Promise { - try { - if (!sgMail || typeof sgMail.send !== "function") { - this.logger.error("SendGrid not properly initialized"); - return false; - } - - const fromEmail = - process.env.SENDGRID_FROM_EMAIL || "noreply@markapp.com"; - const fromName = process.env.SENDGRID_FROM_NAME || "Mark Admin System"; - - const mailData = { - from: { - email: fromEmail, - name: fromName, - }, + const emailOptions: EmailOptions = { to: email, subject: "Mark Admin Access - Verification Code", html: this.getEmailTemplate(code), text: this.getPlainTextTemplate(code), - }; - - await sgMail.send(mailData); - - return true; - } catch (error) { - this.logger.error( - `Failed to send verification code via SendGrid to ${email}:`, - error, - ); - return false; - } - } - - /** - * Send verification code using Gmail SMTP - */ - private async sendVerificationCodeGmail( - email: string, - code: string, - ): Promise { - try { - if (!this.transporter) { - this.logger.error("Gmail transporter not initialized"); - return false; - } - - const mailOptions = { from: { + email: + process.env.SENDGRID_FROM_EMAIL || + process.env.GMAIL_USER || + "noreply@markapp.com", name: "Mark Admin System", - address: process.env.GMAIL_USER || "noreply@markapp.com", }, - to: email, - subject: "Mark Admin Access - Verification Code", - html: this.getEmailTemplate(code), - text: this.getPlainTextTemplate(code), }; - await this.transporter.sendMail(mailOptions); - return true; + const result = await this.emailService.sendEmail(emailOptions); + + if (!result) { + this.logger.error(`Failed to send verification code to ${email}`); + } + + return result; } catch (error) { - this.logger.error( - `Failed to send verification code via Gmail to ${email}:`, - error, - ); + this.logger.error(`Failed to send verification code to ${email}:`, error); return false; } } @@ -308,38 +128,7 @@ This is an automated message from Mark Admin System. * Test email service connection */ async testConnection(): Promise { - try { - if (this.emailProvider === "none") { - if (process.env.NODE_ENV === "production") { - this.logger.error("Email service not configured"); - return false; - } else { - this.logger.log( - "Email service ready (development mode - console logging)", - ); - return true; - } - } - - if (this.emailProvider === "sendgrid") { - this.logger.log("SendGrid email service ready"); - return true; - } - - if (this.emailProvider === "google" && this.transporter) { - await this.transporter.verify(); - this.logger.log("Gmail SMTP connection verified successfully"); - return true; - } - - return false; - } catch (error) { - this.logger.error( - `${this.emailProvider} email service connection failed:`, - error, - ); - return false; - } + return await this.emailService.testConnection(); } /** @@ -347,120 +136,36 @@ This is an automated message from Mark Admin System. */ async sendTestEmail(toEmail: string): Promise { try { - if (this.emailProvider === "none") { - this.logger.warn( - "Cannot send test email - email service not configured", - ); - return false; - } - - if (this.emailProvider === "sendgrid") { - return await this.sendTestEmailSendGrid(toEmail); - } else if (this.emailProvider === "google") { - return await this.sendTestEmailGmail(toEmail); - } - - return false; - } catch (error) { - this.logger.error(`Failed to send test email to ${toEmail}:`, error); - return false; - } - } - - /** - * Send test email using SendGrid - */ - private async sendTestEmailSendGrid(toEmail: string): Promise { - try { - if (!sgMail || typeof sgMail.send !== "function") { - this.logger.error("SendGrid not properly initialized"); - return false; - } - - const fromEmail = - process.env.SENDGRID_FROM_EMAIL || "noreply@markapp.com"; - const fromName = process.env.SENDGRID_FROM_NAME || "Mark Admin System"; - - const mailData = { - from: { - email: fromEmail, - name: fromName, - }, + const emailOptions: EmailOptions = { to: toEmail, subject: "Mark Admin - Email Configuration Test", html: `

🎉 Email Configuration Test

-

If you received this email, your SendGrid email configuration is working correctly!

-

Provider: SendGrid

+

If you received this email, your email configuration is working correctly!

Timestamp: ${new Date().toISOString()}

This is a test message from Mark Admin System.

`, text: ` Email Configuration Test -If you received this email, your SendGrid email configuration is working correctly! +If you received this email, your email configuration is working correctly! -Provider: SendGrid Timestamp: ${new Date().toISOString()} This is a test message from Mark Admin System. `, - }; - await sgMail.send(mailData); - return true; - } catch (error) { - this.logger.error( - `Failed to send test email via SendGrid to ${toEmail}:`, - error, - ); - return false; - } - } - - /** - * Send test email using Gmail SMTP - */ - private async sendTestEmailGmail(toEmail: string): Promise { - try { - if (!this.transporter) { - this.logger.error("Gmail transporter not initialized"); - return false; - } - - const mailOptions = { from: { + email: + process.env.SENDGRID_FROM_EMAIL || + process.env.GMAIL_USER || + "noreply@markapp.com", name: "Mark Admin System", - address: process.env.GMAIL_USER || "noreply@markapp.com", }, - to: toEmail, - subject: "Mark Admin - Email Configuration Test", - html: ` -

🎉 Email Configuration Test

-

If you received this email, your Gmail SMTP configuration is working correctly!

-

Provider: Gmail SMTP

-

Timestamp: ${new Date().toISOString()}

-

This is a test message from Mark Admin System.

- `, - text: ` -Email Configuration Test - -If you received this email, your Gmail SMTP configuration is working correctly! - -Provider: Gmail SMTP -Timestamp: ${new Date().toISOString()} - -This is a test message from Mark Admin System. - `, }; - await this.transporter.sendMail(mailOptions); - - return true; + return await this.emailService.sendEmail(emailOptions); } catch (error) { - this.logger.error( - `Failed to send test email via Gmail to ${toEmail}:`, - error, - ); + this.logger.error(`Failed to send test email to ${toEmail}:`, error); return false; } } diff --git a/apps/api/src/common/services/email.service.ts b/apps/api/src/common/services/email.service.ts new file mode 100644 index 00000000..2f0dd0ac --- /dev/null +++ b/apps/api/src/common/services/email.service.ts @@ -0,0 +1,581 @@ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable unicorn/prefer-module */ +/* eslint-disable @typescript-eslint/no-var-requires */ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +import { Injectable, Logger } from "@nestjs/common"; +import * as nodemailer from "nodemailer"; + +/** + * General EmailService for app-wide email sending + * Supports both SendGrid and Gmail SMTP + */ + +type EmailProvider = "sendgrid" | "google" | "none"; +const sgMail = require("@sendgrid/mail"); + +export interface EmailOptions { + to: string | string[]; + subject: string; + html: string; + text?: string; + from?: { + email: string; + name: string; + }; +} + +@Injectable() +export class EmailService { + private readonly logger = new Logger(EmailService.name); + private transporter: nodemailer.Transporter; + private emailProvider: EmailProvider; + private readonly defaultFromEmail: string; + private readonly defaultFromName: string; + + constructor() { + this.defaultFromEmail = + process.env.SENDGRID_FROM_EMAIL || + process.env.GMAIL_USER || + "noreply@markapp.com"; + this.defaultFromName = + process.env.SENDGRID_FROM_NAME || + process.env.APP_NAME || + "Mark Application"; + + this.initializeEmailService(); + } + + private initializeEmailService() { + const providerPreference = + process.env.EMAIL_PROVIDER?.toLowerCase() || "sendgrid"; + + const sendGridApiKey = process.env.SENDGRID_API_KEY; + const gmailUser = process.env.GMAIL_USER; + const gmailPassword = process.env.GMAIL_APP_PASSWORD; + + if (providerPreference === "sendgrid" && sendGridApiKey) { + try { + sgMail.setApiKey(sendGridApiKey); + this.emailProvider = "sendgrid"; + this.transporter = undefined; + this.logger.log("SendGrid email service initialized"); + return; + } catch (error) { + this.logger.error("Failed to initialize SendGrid:", error); + } + } + + if (providerPreference === "google" && gmailUser && gmailPassword) { + this.initializeGmailTransporter(gmailUser, gmailPassword); + return; + } + + if (gmailUser && gmailPassword) { + this.initializeGmailTransporter(gmailUser, gmailPassword); + return; + } else if (sendGridApiKey) { + try { + sgMail.setApiKey(sendGridApiKey); + this.emailProvider = "sendgrid"; + this.transporter = undefined; + this.logger.log("SendGrid email service initialized (fallback)"); + return; + } catch (error) { + this.logger.error("Failed to initialize SendGrid as fallback:", error); + } + } + + this.emailProvider = "none"; + this.transporter = undefined; + this.logger.warn( + "No email service configured. Set SENDGRID_API_KEY or GMAIL_USER/GMAIL_APP_PASSWORD.", + ); + } + + private initializeGmailTransporter(gmailUser: string, gmailPassword: string) { + this.emailProvider = "google"; + this.transporter = nodemailer.createTransport({ + host: "smtp.gmail.com", + port: 587, + secure: false, + auth: { + user: gmailUser, + pass: gmailPassword, + }, + requireTLS: true, + }); + this.logger.log("Gmail SMTP transporter initialized"); + } + + /** + * Send email using configured provider + */ + async sendEmail(options: EmailOptions): Promise { + try { + if (this.emailProvider === "none") { + if (process.env.NODE_ENV === "production") { + this.logger.error("Email service not configured for production"); + return false; + } else { + this.logger.log(` +=== EMAIL (Development Mode) === +To: ${Array.isArray(options.to) ? options.to.join(", ") : options.to} +Subject: ${options.subject} +Provider: Console +================================`); + return true; + } + } + + if (this.emailProvider === "sendgrid") { + return await this.sendEmailSendGrid(options); + } else if (this.emailProvider === "google") { + return await this.sendEmailGmail(options); + } + + return false; + } catch (error) { + this.logger.error( + `Failed to send email to ${JSON.stringify(options.to)}:`, + error, + ); + return false; + } + } + + /** + * Send email using SendGrid + */ + private async sendEmailSendGrid(options: EmailOptions): Promise { + try { + if (!sgMail || typeof sgMail.send !== "function") { + this.logger.error("SendGrid not properly initialized"); + return false; + } + + const mailData = { + from: options.from || { + email: this.defaultFromEmail, + name: this.defaultFromName, + }, + to: options.to, + subject: options.subject, + html: options.html, + text: options.text || this.htmlToText(options.html), + }; + + await sgMail.send(mailData); + return true; + } catch (error) { + this.logger.error(`Failed to send email via SendGrid:`, error); + return false; + } + } + + /** + * Send email using Gmail SMTP + */ + private async sendEmailGmail(options: EmailOptions): Promise { + try { + if (!this.transporter) { + this.logger.error("Gmail transporter not initialized"); + return false; + } + + const fromAddress = options.from + ? { name: options.from.name, address: options.from.email } + : { name: this.defaultFromName, address: this.defaultFromEmail }; + + const mailOptions = { + from: fromAddress, + to: options.to, + subject: options.subject, + html: options.html, + text: options.text || this.htmlToText(options.html), + }; + + await this.transporter.sendMail(mailOptions); + return true; + } catch (error) { + this.logger.error(`Failed to send email via Gmail:`, error); + return false; + } + } + + /** + * Simple HTML to text converter + */ + private htmlToText(html: string): string { + return html + .replaceAll(/<[^>]*>/g, "") + .replaceAll(" ", " ") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .trim(); + } + + /** + * Test email service connection + */ + async testConnection(): Promise { + try { + if (this.emailProvider === "none") { + if (process.env.NODE_ENV === "production") { + this.logger.error("Email service not configured"); + return false; + } else { + this.logger.log("Email service ready (development mode)"); + return true; + } + } + + if (this.emailProvider === "sendgrid") { + this.logger.log("SendGrid email service ready"); + return true; + } + + if (this.emailProvider === "google" && this.transporter) { + await this.transporter.verify(); + this.logger.log("Gmail SMTP connection verified successfully"); + return true; + } + + return false; + } catch (error) { + this.logger.error(`Email service connection failed:`, error); + return false; + } + } + + /** + * Get default from email + */ + getDefaultFromEmail(): string { + return this.defaultFromEmail; + } + + /** + * Get default from name + */ + getDefaultFromName(): string { + return this.defaultFromName; + } + + /** + * Check if email service is configured + */ + isConfigured(): boolean { + return this.emailProvider !== "none"; + } + + /** + * Send regrading request notification to authors + */ + async sendRegradingRequestNotification( + authorEmails: string[], + learnerUserId: string, + assignmentName: string, + assignmentId: number, + attemptId: number, + regradingRequestId: number, + reason: string, + currentGrade: number, + proposedGrade: number | null, + questionIds: number[], + ): Promise { + try { + const questionText = + questionIds.length > 0 + ? ` (Question${questionIds.length > 1 ? "s" : ""} ${questionIds + .map((id) => `Q${id}`) + .join(", ")})` + : ""; + const proposedGradeText = proposedGrade + ? `${(proposedGrade * 100).toFixed(1)}%` + : "N/A"; + + const emailOptions: EmailOptions = { + to: authorEmails, + subject: `Regrading Request for "${assignmentName}"${questionText}`, + html: ` + + + + + + + +
+
+

📝 New Regrading Request ${ + process.env.NODE_ENV === "production" + ? "" + : " (This is a test email)" + }

+
+
+

A learner has submitted a regrading request for your assignment.

+ +
+
+ Assignment: + ${assignmentName} +
+
+ Learner ID: + ${learnerUserId} +
+ ${ + questionIds.length > 0 + ? ` +
+ Question${ + questionIds.length > 1 ? "s" : "" + }: + ${questionIds + .map((id) => `Q${id}`) + .join(", ")} +
+ ` + : "" + } +
+ Current Grade: + ${(currentGrade * 100).toFixed( + 1, + )}% +
+
+ AI Proposed Grade: + ${proposedGradeText} +
+
+ +
+
Learner's Reason:
+
${reason}
+
+ +

+ + Review Request + +

+ +

+ You can review this request in the admin dashboard and manually adjust the grade if needed. +

+
+ +
+ + + `, + text: ` +New Regrading Request + +A learner has submitted a regrading request for your assignment. + +Assignment: ${assignmentName} +Learner ID: ${learnerUserId} +${ + questionIds.length > 0 + ? `Question${questionIds.length > 1 ? "s" : ""}: ${questionIds + .map((id) => `Q${id}`) + .join(", ")}\n` + : "" +}Current Grade: ${(currentGrade * 100).toFixed(1)}% +AI Proposed Grade: ${proposedGradeText} + +Learner's Reason: +${reason} + +Review this request in the admin dashboard: +${process.env.FRONTEND_URL || "http://localhost:3000"}/admin/regrading-requests + +This is an automated message from Mark Application. + `, + }; + + return await this.sendEmail(emailOptions); + } catch (error) { + this.logger.error( + "Failed to send regrading request notification:", + error, + ); + return false; + } + } + + /** + * Send grade update notification to learner + */ + async sendGradeUpdateNotification( + learnerEmail: string, + assignmentName: string, + assignmentId: number, + attemptId: number, + oldGrade: number, + newGrade: number, + status: "APPROVED" | "REJECTED" | "COMPLETED", + ): Promise { + try { + const isApproved = status === "APPROVED" || status === "COMPLETED"; + const gradeChanged = Math.abs(newGrade - oldGrade) > 0.001; + + let statusText = ""; + let statusColor = ""; + let headerColor = ""; + + if (isApproved && gradeChanged) { + statusText = "Your regrading request has been approved"; + statusColor = "#10b981"; + headerColor = "#10b981"; + } else if (isApproved && !gradeChanged) { + statusText = "Your regrading request has been reviewed"; + statusColor = "#3b82f6"; + headerColor = "#3b82f6"; + } else { + statusText = "Your regrading request has been reviewed"; + statusColor = "#ef4444"; + headerColor = "#ef4444"; + } + + const emailOptions: EmailOptions = { + to: learnerEmail, + subject: + process.env.NODE_ENV === "production" + ? `📊 Grade Update for "${assignmentName}"` + : `📊 Grade Update (This is a test) for "${assignmentName}"`, + html: ` + + + + + + + +
+
+

${ + process.env.NODE_ENV === "production" + ? "📊 Grade Update" + : "📊 Grade Update (This is a test)" + }

+
+
+
+

${statusText}

+
+ +

+ Assignment: ${assignmentName} +

+ + ${ + gradeChanged + ? ` +
+
+
Previous Grade
+
${(oldGrade * 100).toFixed( + 1, + )}%
+
+
+
+
New Grade
+
${(newGrade * 100).toFixed(1)}%
+
+
+ ` + : ` +
+
Your Grade
+
${(newGrade * 100).toFixed(1)}%
+

Your grade remains unchanged

+
+ ` + } + +
+ +
+ + + `, + text: ` +Grade Update + +${statusText} + +Assignment: ${assignmentName} + +${ + gradeChanged + ? `Previous Grade: ${(oldGrade * 100).toFixed(1)}% +New Grade: ${(newGrade * 100).toFixed(1)}%` + : `Your Grade: ${(newGrade * 100).toFixed(1)}% +Your grade remains unchanged.` +} + +View your assignment: +${ + process.env.FRONTEND_URL || "http://localhost:3000" +}/learner/${assignmentId}/successPage/${attemptId} + +This is an automated message from Mark Application. + `, + }; + + return await this.sendEmail(emailOptions); + } catch (error) { + this.logger.error("Failed to send grade update notification:", error); + return false; + } + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index d44e244e..84a47c5d 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -132,6 +132,9 @@ async function bootstrap() { await app.listen(port); logger.log(`Application is running on port ${port}`); logger.log(`Environment: ${process.env.NODE_ENV || "development"}`); + logger.log(`Admin Side: http://localhost:3010/admin`); + logger.log(`Author Side: http://localhost:3010/author`); + logger.log(`Learner Side: http://localhost:3010/learner`); /** * Configure server timeouts for handling long-running requests @@ -212,6 +215,5 @@ async function bootstrap() { * Using void operator to explicitly ignore the returned promise */ void bootstrap().catch((error) => { - console.error("Fatal error during application startup:", error); process.exit(1); }); diff --git a/apps/web/app/admin/components/AdminNav.tsx b/apps/web/app/admin/components/AdminNav.tsx new file mode 100644 index 00000000..4bb8c0c2 --- /dev/null +++ b/apps/web/app/admin/components/AdminNav.tsx @@ -0,0 +1,89 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { cn } from "@/lib/utils"; +import { Home, FileText, LogOut, Settings } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface AdminNavProps { + onLogout?: () => void; +} + +export function AdminNav({ onLogout }: AdminNavProps) { + const pathname = usePathname(); + + const navItems = [ + { + href: "/admin", + label: "Dashboard", + icon: Home, + }, + { + href: "/admin/regrading-requests", + label: "Regrading Requests", + icon: FileText, + }, + ]; + + const isSettingsActive = pathname === "/admin/settings"; + + return ( + + ); +} diff --git a/apps/web/app/admin/components/OptimizedAdminDashboard.tsx b/apps/web/app/admin/components/OptimizedAdminDashboard.tsx index 7531bd56..6c61c86b 100644 --- a/apps/web/app/admin/components/OptimizedAdminDashboard.tsx +++ b/apps/web/app/admin/components/OptimizedAdminDashboard.tsx @@ -37,6 +37,7 @@ import { } from "lucide-react"; import Link from "next/link"; import { queryClient } from "@/lib/query-client"; +import { AdminNav } from "./AdminNav"; interface AdminDashboardProps { sessionToken?: string | null; @@ -307,7 +308,6 @@ function AdminDashboardContent({ setIsPriceUpscalingModalOpen(false); resetUpscalingModal(); } catch (error) { - console.error("Failed to upscale prices:", error); alert( `Failed to upscale prices: ${error instanceof Error ? error.message : "Please try again."}`, ); @@ -335,7 +335,6 @@ function AdminDashboardContent({ alert(result.message || "No active price upscaling found to remove."); } } catch (error) { - console.error("Failed to remove upscaling:", error); alert( `Failed to remove price upscaling: ${error instanceof Error ? error.message : "Please try again."}`, ); @@ -374,601 +373,605 @@ function AdminDashboardContent({ } return ( -
-
-
-
-

- {isAdmin ? "Admin Dashboard" : "Author Dashboard"} -

- {stats && ( - - {isAdmin ? "Super Admin" : "Author"} - - )} -
-

- {isAdmin - ? "Manage all assignments, feedback and reports" - : "Manage your assignments and feedback"} -

-
-
- - {isAdmin && ( - <> - {currentUpscaling && ( -
- - Price Upscaling Active - -
+
+ +
+
+
+
+

+ {isAdmin ? "Admin Dashboard" : "Author Dashboard"} +

+ {stats && ( + + {isAdmin ? "Super Admin" : "Author"} + )} +
+

+ {isAdmin + ? "Manage all assignments, feedback and reports" + : "Manage your assignments and feedback"} +

+
+
+ + {isAdmin && ( + <> + {currentUpscaling && ( +
+ + Price Upscaling Active + +
+ )} - - - - - - - - - Price Upscaling (Super Admin Only) - - - Apply upscaling factors to AI pricing. You can set a - global factor or specific factors for each usage type. - - - -
- {currentUpscaling && ( -
- -
-

- Current Active Upscaling -

-
- {currentUpscaling.globalFactor && ( -
- Global Factor: {currentUpscaling.globalFactor}x -
- )} - {currentUpscaling.usageTypeFactors && ( -
Usage-specific factors applied
- )} -
- Applied:{" "} - {new Date( - currentUpscaling.effectiveDate, - ).toLocaleString()} -
- {currentUpscaling.reason && ( + + + + + + + + + Price Upscaling (Super Admin Only) + + + Apply upscaling factors to AI pricing. You can set a + global factor or specific factors for each usage type. + + + +
+ {currentUpscaling && ( +
+ +
+

+ Current Active Upscaling +

+
+ {currentUpscaling.globalFactor && ( +
+ Global Factor: {currentUpscaling.globalFactor} + x +
+ )} + {currentUpscaling.usageTypeFactors && ( +
Usage-specific factors applied
+ )}
- Reason: {currentUpscaling.reason} + Applied:{" "} + {new Date( + currentUpscaling.effectiveDate, + ).toLocaleString()}
- )} + {currentUpscaling.reason && ( +
+ Reason: {currentUpscaling.reason} +
+ )} +
+
- + Global Upscaling Factor (optional) + + + setGlobalUpscalingFactor(e.target.value) + } + className="mt-1" + /> + +

+ If set, this will be applied to all usage types + (multiplied with individual factors) +

- )} - -
- - - setGlobalUpscalingFactor(e.target.value) - } - className="mt-1" - /> - -

- If set, this will be applied to all usage types - (multiplied with individual factors) -

-
-
- -
- {Object.entries(usageTypeUpscaling).map( - ([usageType, value]) => ( -
- - - handleUsageTypeUpscalingChange( - usageType as keyof typeof usageTypeUpscaling, - e.target.value, - ) - } - className="mt-1" - /> -
- ), - )} +
+ +
+ {Object.entries(usageTypeUpscaling).map( + ([usageType, value]) => ( +
+ + + handleUsageTypeUpscalingChange( + usageType as keyof typeof usageTypeUpscaling, + e.target.value, + ) + } + className="mt-1" + /> +
+ ), + )} +
+

+ Individual factors are applied after the global factor + (if set) +

-

- Individual factors are applied after the global factor - (if set) -

-
- {(globalUpscalingFactor || - Object.values(usageTypeUpscaling).some((v) => - v.trim(), - )) && ( -
-
- - -
-
- {(() => { - const example = calculatePriceExample(); - const useRealData = stats && stats.costBreakdown; - return ( - <> -

- {useRealData - ? `Based on your current assignment data (average per assignment)` - : `Based on a typical assignment with average AI usage`} -

-
-
-
-
- Total Assignment Cost -
-
- Current → New -
-
-
-
- ${example.totalCurrentCost.toFixed(4)} → - ${example.totalNewCost.toFixed(4)} + {(globalUpscalingFactor || + Object.values(usageTypeUpscaling).some((v) => + v.trim(), + )) && ( +
+
+ + +
+
+ {(() => { + const example = calculatePriceExample(); + const useRealData = stats && stats.costBreakdown; + return ( + <> +

+ {useRealData + ? `Based on your current assignment data (average per assignment)` + : `Based on a typical assignment with average AI usage`} +

+
+
+
+
+ Total Assignment Cost +
+
+ Current → New +
-
0 ? "text-red-600" : example.percentageChange < 0 ? "text-green-600" : "text-gray-600"}`} - > - {example.percentageChange > 0 - ? "+" - : ""} - {example.percentageChange.toFixed(1)}% - change +
+
+ ${example.totalCurrentCost.toFixed(4)}{" "} + → ${example.totalNewCost.toFixed(4)} +
+
0 ? "text-red-600" : example.percentageChange < 0 ? "text-green-600" : "text-gray-600"}`} + > + {example.percentageChange > 0 + ? "+" + : ""} + {example.percentageChange.toFixed(1)}% + change +
-
-
- {Object.entries(example.breakdown) - .filter(([, data]) => data.factor !== 1) - .map(([usageType, data]) => ( -
-
- {usageType - .replace(/_/g, " ") - .toLowerCase() - .replace(/\b\w/g, (l) => - l.toUpperCase(), - )} -
-
-
- ${data.current.toFixed(4)} → $ - {data.new.toFixed(4)} +
+ {Object.entries(example.breakdown) + .filter(([, data]) => data.factor !== 1) + .map(([usageType, data]) => ( +
+
+ {usageType + .replace(/_/g, " ") + .toLowerCase() + .replace(/\b\w/g, (l) => + l.toUpperCase(), + )}
-
- ×{data.factor.toFixed(1)} +
+
+ ${data.current.toFixed(4)} → $ + {data.new.toFixed(4)} +
+
+ ×{data.factor.toFixed(1)} +
-
- ))} -
- - {Object.values(example.breakdown).every( - (data) => data.factor === 1, - ) && ( -
- No changes applied with current factors + ))}
- )} -
- - ); - })()} + + {Object.values(example.breakdown).every( + (data) => data.factor === 1, + ) && ( +
+ No changes applied with current factors +
+ )} +
+ + ); + })()} +
-
- )} - -
- -
+ )} + +
- +
+ + +
-
- -
+ +
- - - - - )} - {onLogout && ( - - )} -
-
- - {loadingStats ? ( -
-
- - Loading dashboard data... - -
- ) : stats ? ( - <> -
- - - -
- Assignments Created - - - -
- {stats.totalAssignments.toLocaleString()} -
-

- Total created -

-
- - - - - -
- Assignments Published - - - -
- {stats.publishedAssignments.toLocaleString()} -
-

- Currently active -

-
- - - - - -
- Total Unique Learners - - - -
- {stats.totalLearners.toLocaleString()} -
-

- Registered users -

-
- - - - - -
- Avg Rating - - - -
- {stats.averageAssignmentRating?.toFixed(1) || "0.0"} -
-

- Out of 5 stars -

-
- - - - - -
- AI Cost - - - -
- ${stats.totalCost.toFixed(0)} -
-

- Total spent -

-
- - - {isAdmin && ( - <> - - - -
- Total Reports - - - -
- {stats.totalReports.toLocaleString()} -
-

- All reports -

-
- - - - - -
- Open Reports - - - -
- {stats.openReports.toLocaleString()} -
-

- Need attention -

-
- + + + )} + {onLogout && ( + + )}
+
- - - -
- AI Cost Breakdown -
-

- Cost distribution across different AI services -

-
- -
-
-
- Grading + {loadingStats ? ( +
+
+ + Loading dashboard data... + +
+ ) : stats ? ( + <> +
+ + + +
+ Assignments Created + + + +
+ {stats.totalAssignments.toLocaleString()}
-
- ${Math.round(stats.costBreakdown.grading)} +

+ Total created +

+ + + + + + +
+ Assignments Published + + + +
+ {stats.publishedAssignments.toLocaleString()}
-
-
-
- Question Gen +

+ Currently active +

+ + + + + + +
+ Total Unique Learners + + + +
+ {stats.totalLearners.toLocaleString()}
-
- ${Math.round(stats.costBreakdown.questionGeneration)} +

+ Registered users +

+ + + + + + +
+ Avg Rating + + + +
+ {stats.averageAssignmentRating?.toFixed(1) || "0.0"}
-
-
-
- Translation +

+ Out of 5 stars +

+ + + + + + +
+ AI Cost + + + +
+ ${stats.totalCost.toFixed(0)} +
+

+ Total spent +

+
+ + + {isAdmin && ( + <> + + + +
+ Total Reports + + + +
+ {stats.totalReports.toLocaleString()} +
+

+ All reports +

+
+ + + + + +
+ Open Reports + + + +
+ {stats.openReports.toLocaleString()} +
+

+ Need attention +

+
+ + + )} +
+ + + + +
+ AI Cost Breakdown +
+

+ Cost distribution across different AI services +

+
+ +
+
+
+ Grading +
+
+ ${Math.round(stats.costBreakdown.grading)} +
-
- ${Math.round(stats.costBreakdown.translation)} +
+
+ Question Gen +
+
+ ${Math.round(stats.costBreakdown.questionGeneration)} +
-
-
-
- Other +
+
+ Translation +
+
+ ${Math.round(stats.costBreakdown.translation)} +
-
- ${Math.round(stats.costBreakdown.other)} +
+
+ Other +
+
+ ${Math.round(stats.costBreakdown.other)} +
-
- - - - ) : ( -
- No data available -
- )} - -
-
- +
-
- - - {activeTab === "assignments" && ( - - )} - {activeTab === "feedback" && ( - - )} - - {activeTab === "reports" && isAdmin && ( - - )} - - + + + {activeTab === "assignments" && ( + + )} + {activeTab === "feedback" && ( + + )} + + {activeTab === "reports" && isAdmin && ( + + )} + + +
); } diff --git a/apps/web/app/admin/page.tsx b/apps/web/app/admin/page.tsx index 1c8971ff..5b305b77 100644 --- a/apps/web/app/admin/page.tsx +++ b/apps/web/app/admin/page.tsx @@ -6,7 +6,7 @@ import { getUser } from "@/lib/talkToBackend"; import Loading from "@/components/Loading"; import animationData from "@/animations/LoadSN.json"; import { AdminLogin } from "./components/AdminLogin"; -import { OptimizedAdminDashboard } from "./components/AdminDashboard"; +import { OptimizedAdminDashboard } from "./components/OptimizedAdminDashboard"; export default function AdminPage() { const [isLoading, setIsLoading] = useState(true); @@ -54,8 +54,6 @@ export default function AdminPage() { localStorage.removeItem("adminExpiresAt"); } } catch (apiError) { - console.error("Error validating session with backend:", apiError); - localStorage.removeItem("adminSessionToken"); localStorage.removeItem("adminEmail"); localStorage.removeItem("adminExpiresAt"); @@ -67,7 +65,7 @@ export default function AdminPage() { } } } catch (error) { - console.error("Failed to check admin access:", error); + console.error("Error checking admin access:", error); } finally { setIsLoading(false); } @@ -99,7 +97,7 @@ export default function AdminPage() { body: JSON.stringify({ sessionToken: adminToken }), }); } catch (error) { - console.error("Failed to logout:", error); + console.error("Error logging out:", error); } } @@ -123,7 +121,7 @@ export default function AdminPage() { } return ( -
+
void; +} + +interface RegradingRequest { + id: number; + assignmentId: number; + userId: string; + attemptId: number; + regradingReason: string | null; + proposedGrade: number | null; + questionIds: number[]; + regradingStatus: string; + processedBy: string | null; + createdAt: string; + updatedAt: string; + assignment: { + id: number; + name: string; + }; + assignmentAttempt: { + id: number; + userId: string; + grade: number | null; + createdAt: string; + }; +} + +function RegradingRequestsTable({ + sessionToken, +}: { + sessionToken: string | null; +}) { + const queryClient = useQueryClient(); + const router = useRouter(); + const [selectedRequest, setSelectedRequest] = + useState(null); + const [isApproveDialogOpen, setIsApproveDialogOpen] = useState(false); + const [isRejectDialogOpen, setIsRejectDialogOpen] = useState(false); + const [newGrade, setNewGrade] = useState(""); + const [rejectionReason, setRejectionReason] = useState(""); + const [filterAssignmentName, setFilterAssignmentName] = useState(""); + const [filterUserId, setFilterUserId] = useState(""); + const [sortBy, setSortBy] = useState< + "date" | "assignment" | "status" | "grade" + >("date"); + const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); + const [viewReasonDialogOpen, setViewReasonDialogOpen] = useState(false); + const [selectedReasonRequest, setSelectedReasonRequest] = + useState(null); + + const { + data: requests, + isLoading, + error, + refetch, + } = useQuery({ + queryKey: ["regrading-requests"], + queryFn: async () => { + const response = await fetch("/api/v1/admin/regrading-requests", { + headers: { + "x-admin-token": sessionToken || "", + }, + }); + + if (!response.ok) { + throw new Error("Failed to fetch regrading requests"); + } + + const data = await response.json(); + return data; + }, + enabled: !!sessionToken, + }); + + const approveMutation = useMutation({ + mutationFn: async ({ id, grade }: { id: number; grade: number }) => { + const response = await fetch( + `/api/v1/admin/regrading-requests/${id}/approve`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-admin-token": sessionToken || "", + }, + body: JSON.stringify({ newGrade: grade }), + }, + ); + + if (!response.ok) { + throw new Error("Failed to approve request"); + } + + const result = await response.json(); + return result; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["regrading-requests"] }); + setIsApproveDialogOpen(false); + setSelectedRequest(null); + setNewGrade(""); + }, + }); + + const rejectMutation = useMutation({ + mutationFn: async ({ id, reason }: { id: number; reason: string }) => { + const response = await fetch( + `/api/v1/admin/regrading-requests/${id}/reject`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-admin-token": sessionToken || "", + }, + body: JSON.stringify({ reason }), + }, + ); + + if (!response.ok) { + throw new Error("Failed to reject request"); + } + + return response.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["regrading-requests"] }); + setIsRejectDialogOpen(false); + setSelectedRequest(null); + setRejectionReason(""); + }, + }); + + const handleApprove = (request: RegradingRequest) => { + setSelectedRequest(request); + const defaultGrade = + request.proposedGrade !== null && request.proposedGrade !== undefined + ? (request.proposedGrade * 100).toFixed(1) + : request.assignmentAttempt?.grade + ? (request.assignmentAttempt?.grade * 100).toFixed(1) + : ""; + setNewGrade(defaultGrade); + setIsApproveDialogOpen(true); + }; + + const handleReject = (request: RegradingRequest) => { + setSelectedRequest(request); + setIsRejectDialogOpen(true); + }; + + const handleManualReview = (request: RegradingRequest) => { + let url = `/learner/${request.assignmentId}/successPage/${request.attemptId}?authorReview=true®radingRequestId=${request.id}`; + if (request.questionIds && request.questionIds.length > 0) { + url += `&highlightQuestionIds=${request.questionIds.join(",")}`; + } + router.push(url); + }; + + const handleApproveSubmit = () => { + if (selectedRequest && newGrade) { + const gradeDecimal = Number(newGrade) / 100; + approveMutation.mutate({ + id: selectedRequest.id, + grade: gradeDecimal, + }); + } + }; + + const handleRejectSubmit = () => { + if (selectedRequest && rejectionReason) { + rejectMutation.mutate({ + id: selectedRequest.id, + reason: rejectionReason, + }); + } + }; + + const getStatusBadge = (status: string) => { + switch (status) { + case "PENDING": + return ( + + + Pending + + ); + case "APPROVED": + return ( + + + Approved + + ); + case "REJECTED": + return ( + + + Rejected + + ); + default: + return {status}; + } + }; + + const handleSort = (column: typeof sortBy) => { + if (sortBy === column) { + setSortOrder(sortOrder === "asc" ? "desc" : "asc"); + } else { + setSortBy(column); + setSortOrder("desc"); + } + }; + + const getSortIcon = (column: typeof sortBy) => { + if (sortBy !== column) { + return ; + } + return sortOrder === "asc" ? ( + + ) : ( + + ); + }; + + const handleViewReason = (request: RegradingRequest) => { + setSelectedReasonRequest(request); + setViewReasonDialogOpen(true); + }; + + const filteredAndSortedRequests = requests + ?.filter((request) => { + const matchesAssignment = filterAssignmentName + ? request.assignment?.name + .toLowerCase() + .includes(filterAssignmentName.toLowerCase()) + : true; + + const matchesUserId = filterUserId + ? request.userId.toLowerCase().includes(filterUserId.toLowerCase()) + : true; + + return matchesAssignment && matchesUserId; + }) + ?.sort((a, b) => { + let comparison = 0; + + switch (sortBy) { + case "date": + comparison = + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); + break; + case "assignment": + comparison = (a.assignment?.name || "").localeCompare( + b.assignment?.name || "", + ); + break; + case "status": + comparison = a.regradingStatus.localeCompare(b.regradingStatus); + break; + case "grade": { + const gradeA = a.assignmentAttempt?.grade ?? -1; + const gradeB = b.assignmentAttempt?.grade ?? -1; + comparison = gradeA - gradeB; + break; + } + } + + return sortOrder === "asc" ? comparison : -comparison; + }); + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( + + +

Error loading regrading requests

+
+
+ ); + } + + return ( +
+
+
+ setFilterAssignmentName(e.target.value)} + /> +
+
+ setFilterUserId(e.target.value)} + /> +
+ +
+ + + + + Regrading Requests ({filteredAndSortedRequests?.length || 0}) + + + + {filteredAndSortedRequests && filteredAndSortedRequests.length > 0 ? ( + + + + handleSort("assignment")} + > + Assignment {getSortIcon("assignment")} + + Student ID + handleSort("grade")} + > + Current Grade {getSortIcon("grade")} + + AI Proposed + Reason + handleSort("status")} + > + Status {getSortIcon("status")} + + Processed By + handleSort("date")} + > + Submitted {getSortIcon("date")} + + Actions + + + + {filteredAndSortedRequests.map((request) => ( + + + {request.assignment?.name} + + {request.userId} + + {request.assignmentAttempt?.grade !== null + ? `${(request.assignmentAttempt?.grade * 100).toFixed(1)}%` + : "N/A"} + + + {request.proposedGrade !== null ? ( +
+ + {(request.proposedGrade * 100).toFixed(1)}% + + {request.assignmentAttempt?.grade !== null && ( + + ( + {(request.proposedGrade - + request.assignmentAttempt.grade) * + 100 >= + 0 + ? "+" + : ""} + {( + (request.proposedGrade - + request.assignmentAttempt.grade) * + 100 + ).toFixed(1)} + %) + + )} +
+ ) : ( + + No proposal + + )} +
+ +
+ + {request.regradingReason || "No reason provided"} + + +
+
+ + {getStatusBadge(request.regradingStatus)} + + + {request.processedBy ? ( + + {request.processedBy} + + ) : ( + + )} + + + {request.createdAt + ? (() => { + const date = new Date(request.createdAt); + return isNaN(date.getTime()) + ? "N/A" + : date.toLocaleDateString(); + })() + : "N/A"} + + +
+ {request.regradingStatus === "PENDING" && ( + <> + + + + + )} + {request.regradingStatus !== "PENDING" && ( + + )} +
+
+
+ ))} +
+
+ ) : ( +

+ No regrading requests found +

+ )} +
+
+ + + + + Approve Regrading Request + + Enter the new grade for this assignment attempt. + + +
+
+ + setNewGrade(e.target.value)} + placeholder="Enter grade (0-100)" + /> +
+ {selectedRequest && ( +
+

+ Assignment:{" "} + {selectedRequest.assignment?.name} +

+

+ Current Grade:{" "} + {selectedRequest.assignmentAttempt?.grade !== null + ? `${(selectedRequest.assignmentAttempt?.grade * 100).toFixed(1)}%` + : "N/A"} +

+ {selectedRequest.proposedGrade !== null && ( +
+

+ AI Proposed Grade: +

+

+ {(selectedRequest.proposedGrade * 100).toFixed(1)}% + {selectedRequest.assignmentAttempt?.grade !== null && ( + + ( + {(selectedRequest.proposedGrade - + selectedRequest.assignmentAttempt.grade) * + 100 >= + 0 + ? "+" + : ""} + {( + (selectedRequest.proposedGrade - + selectedRequest.assignmentAttempt.grade) * + 100 + ).toFixed(1)} + % change) + + )} +

+

+ The AI has analyzed the student's submission and proposed + this grade based on their concerns. +

+
+ )} +

+ Reason:{" "} + {selectedRequest.regradingReason || "No reason provided"} +

+
+ )} +
+ + + + +
+
+ + + + + Reject Regrading Request + + Provide a reason for rejecting this regrading request. + + +
+
+ +