diff --git a/.dockerignore b/.dockerignore index 3fc14498..999083e6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,2 +1,42 @@ *.test.ts **/node_modules + +# Environment files +.env +.env.local +.env.*.local +dev.env + +# IDE +.vscode +.idea +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Git +.git +.gitignore + +# Logs +*.log +logs + +# Test coverage +coverage + +# Docker Compose (not needed in images) +docker-compose.yml +docker-compose.*.yml + +# Documentation +docs +*.md +!README.md + +# Misc +.turbo +.cache diff --git a/.env.template b/.env.template index c3dcada0..5570933e 100644 --- a/.env.template +++ b/.env.template @@ -15,12 +15,20 @@ API_GATEWAY_HOST= # e.g. http://localhost:8000 DEBUG_MODE= # true | false # ────── PostgreSQL ────── -POSTGRES_HOST= # e.g. localhost -POSTGRES_PORT= # e.g. 5432 +POSTGRES_HOST= # e.g. localhost (for local dev) +POSTGRES_PORT= # e.g. 6001 (host port, maps to 5432 in container) POSTGRES_USER= # e.g. mark-pg-user POSTGRES_PASSWORD= # ← secret! POSTGRES_DB= # e.g. mark-pg +DATABASE_URL= # e.g. postgresql://mark-pg-user:password@localhost:6001/mark-pg ############################################################################### # Add other variables (JWT_SECRET, REDIS_URL, etc.) below this line as needed ############################################################################### + +# ────── Redis Cache ────── +REDIS_URL= # e.g. redis://localhost:6379 (full connection URL) +# OR use individual settings: +REDIS_HOST= # e.g. localhost +REDIS_PORT= # e.g. 6379 +REDIS_PASSWORD= # Optional: Redis password if authentication is enabled diff --git a/apps/api/package.json b/apps/api/package.json index c5928568..b5401835 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -17,7 +17,7 @@ "start:debug": "yarn start --debug --watch", "start:dev": "yarn start --watch", "start:prod": "node dist/main", - "test": "jest --detectOpenHandles --forceExit", + "test": "jest --detectOpenHandles --forceExit --silent --reporters=default", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json", diff --git a/apps/api/prisma/migrations/20251030025234_add_composite_indexes_and_persisted_costs/migration.sql b/apps/api/prisma/migrations/20251030025234_add_composite_indexes_and_persisted_costs/migration.sql new file mode 100644 index 00000000..9f8ce0be --- /dev/null +++ b/apps/api/prisma/migrations/20251030025234_add_composite_indexes_and_persisted_costs/migration.sql @@ -0,0 +1,16 @@ +-- AlterTable +ALTER TABLE "AIUsage" ADD COLUMN "inputCost" DOUBLE PRECISION, +ADD COLUMN "inputTokenPrice" DOUBLE PRECISION, +ADD COLUMN "outputCost" DOUBLE PRECISION, +ADD COLUMN "outputTokenPrice" DOUBLE PRECISION, +ADD COLUMN "pricingDate" TIMESTAMP(3), +ADD COLUMN "totalCost" DOUBLE PRECISION; + +-- CreateIndex +CREATE INDEX "AIUsage_assignmentId_createdAt_usageType_modelKey_idx" ON "AIUsage"("assignmentId", "createdAt", "usageType", "modelKey"); + +-- CreateIndex +CREATE INDEX "AssignmentAttempt_assignmentId_submitted_createdAt_idx" ON "AssignmentAttempt"("assignmentId", "submitted", "createdAt"); + +-- CreateIndex +CREATE INDEX "QuestionResponse_questionId_assignmentAttemptId_idx" ON "QuestionResponse"("questionId", "assignmentAttemptId"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index fab58ec1..1853fd50 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -137,7 +137,14 @@ model AIUsage { userId String? /// The ID of the user who used the AI feature user UserCredential? @relation(fields: [userId], references: [userId]) /// Relation to the UserCredential model modelKey String? /// The key of the LLM model actually used for this AI operation + inputCost Float? /// Persisted input cost at write time + outputCost Float? /// Persisted output cost at write time + totalCost Float? /// Persisted total cost at write time + inputTokenPrice Float? /// Price per token (input) used for calculation + outputTokenPrice Float? /// Price per token (output) used for calculation + pricingDate DateTime? /// Date of pricing used for calculation @@unique([assignmentId, usageType]) /// Ensure each assignment has a unique usage record for each AI feature + @@index([assignmentId, createdAt, usageType, modelKey]) /// Composite index for analytics queries } /// Enum to track different types of AI usage @@ -402,13 +409,14 @@ model AssignmentAttempt { preferredLanguage String? GradingJob GradingJob[] - + @@index([assignmentId]) @@index([userId]) @@index([assignmentId, submitted]) @@index([createdAt]) @@index([assignmentId, userId]) @@index([assignmentVersionId]) + @@index([assignmentId, submitted, createdAt]) /// Composite index for analytics queries } model AssignmentAttemptQuestionVariant { @@ -455,6 +463,8 @@ model QuestionResponse { feedback Json /// Feedback on the student's response, stored as JSON metadata Json? /// Optional additional metadata about the response gradedAt DateTime? /// Timestamp for when the response was graded + + @@index([questionId, assignmentAttemptId]) /// Composite index for analytics queries } /// This model captures feedback and regrading requests for assignments diff --git a/apps/api/src/api/admin/__tests__/admin-analytics.performance.spec.ts b/apps/api/src/api/admin/__tests__/admin-analytics.performance.spec.ts new file mode 100644 index 00000000..5398a0ef --- /dev/null +++ b/apps/api/src/api/admin/__tests__/admin-analytics.performance.spec.ts @@ -0,0 +1,690 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AdminService } from '../admin.service'; +import { PrismaService } from '../../../database/prisma.service'; +import { RedisService } from '../../../cache/redis.service'; +import { LLMPricingService } from '../../llm/core/services/llm-pricing.service'; +import { LLM_PRICING_SERVICE } from '../../llm/llm.constants'; +import { UserRole } from '../../../auth/interfaces/user.session.interface'; + +/** + * Performance tests for admin analytics endpoints + * + * Tests verify that optimizations meet performance targets: + * - Cold Request (BASIC): < 600ms + * - Cold Request (DETAILED): < 1200ms + * - Warm Request (cached): < 250ms + * - Query Count: < 10 per request (vs 50-100+ before) + */ +describe('Admin Analytics Performance Tests', () => { + let adminService: AdminService; + let prismaService: PrismaService; + let redisService: RedisService; + + // Performance tracking + const queryLog: Array<{ query: string; timestamp: number }> = []; + let queryCount = 0; + + const mockAdminSession = { + email: 'admin@test.com', + role: UserRole.ADMIN, + sessionToken: 'test-token', + userId: 'admin-user-id', + }; + + const mockAssignment = { + id: 1, + name: 'Test Assignment', + type: 'AI_GRADED', + published: true, + introduction: 'Test intro', + instructions: 'Test instructions', + timeEstimateMinutes: 30, + allotedTimeMinutes: 45, + passingGrade: 0.7, + updatedAt: new Date(), + questions: Array.from({ length: 10 }, (_, index) => ({ + id: index + 1, + question: `Question ${index + 1}`, + type: 'MULTIPLE_CHOICE', + totalPoints: 10, + isDeleted: false, + variants: [], + translations: [], + })), + AIUsage: Array.from({ length: 5 }, (_, index) => ({ + id: index + 1, + assignmentId: 1, + tokensIn: 1000, + tokensOut: 500, + usageType: 'grading', + modelKey: 'gpt-4', + createdAt: new Date(), + usageCount: 1, + })), + AssignmentFeedback: [], + Report: [], + AssignmentAuthor: [ + { + userId: 'author-1', + assignmentId: 1, + }, + ], + }; + + // Mock Prisma to track query performance + const createMockPrismaService = () => { + return { + assignment: { + findFirst: jest.fn().mockImplementation(async () => { + queryCount++; + queryLog.push({ query: 'assignment.findFirst', timestamp: Date.now() }); + await new Promise(resolve => setTimeout(resolve, 10)); // Simulate DB latency + return mockAssignment; + }), + findMany: jest.fn().mockImplementation(async () => { + queryCount++; + queryLog.push({ query: 'assignment.findMany', timestamp: Date.now() }); + await new Promise(resolve => setTimeout(resolve, 10)); + return [mockAssignment]; + }), + count: jest.fn().mockImplementation(async () => { + queryCount++; + queryLog.push({ query: 'assignment.count', timestamp: Date.now() }); + await new Promise(resolve => setTimeout(resolve, 5)); + return 1; + }), + }, + assignmentAttempt: { + count: jest.fn().mockImplementation(async () => { + queryCount++; + queryLog.push({ query: 'assignmentAttempt.count', timestamp: Date.now() }); + await new Promise(resolve => setTimeout(resolve, 5)); + return 100; + }), + aggregate: jest.fn().mockImplementation(async () => { + queryCount++; + queryLog.push({ query: 'assignmentAttempt.aggregate', timestamp: Date.now() }); + await new Promise(resolve => setTimeout(resolve, 10)); + return { _avg: { grade: 0.85 } }; + }), + groupBy: jest.fn().mockImplementation(async () => { + queryCount++; + queryLog.push({ query: 'assignmentAttempt.groupBy', timestamp: Date.now() }); + await new Promise(resolve => setTimeout(resolve, 10)); + return Array.from({ length: 50 }, (_, index) => ({ + assignmentId: 1, + userId: `user-${index}`, + _count: { id: 2 }, + _avg: { grade: 0.85 }, + })); + }), + findMany: jest.fn().mockImplementation(async () => { + queryCount++; + queryLog.push({ query: 'assignmentAttempt.findMany', timestamp: Date.now() }); + await new Promise(resolve => setTimeout(resolve, 10)); + return []; + }), + }, + questionResponse: { + count: jest.fn().mockImplementation(async () => { + queryCount++; + queryLog.push({ query: 'questionResponse.count', timestamp: Date.now() }); + await new Promise(resolve => setTimeout(resolve, 5)); + return 80; + }), + aggregate: jest.fn().mockImplementation(async () => { + queryCount++; + queryLog.push({ query: 'questionResponse.aggregate', timestamp: Date.now() }); + await new Promise(resolve => setTimeout(resolve, 10)); + return { _avg: { points: 8.5 } }; + }), + groupBy: jest.fn().mockImplementation(async (arguments_) => { + queryCount++; + queryLog.push({ query: 'questionResponse.groupBy', timestamp: Date.now() }); + await new Promise(resolve => setTimeout(resolve, 15)); + + // Return stats for each question + const questionIds = arguments_.where.questionId.in || []; + return questionIds.map((qId: number) => ({ + questionId: qId, + _count: { id: 80 }, + _avg: { points: 8.5 }, + })); + }), + }, + aIUsage: { + findMany: jest.fn().mockImplementation(async () => { + queryCount++; + queryLog.push({ query: 'aIUsage.findMany', timestamp: Date.now() }); + await new Promise(resolve => setTimeout(resolve, 10)); + return mockAssignment.AIUsage; + }), + }, + assignmentFeedback: { + groupBy: jest.fn().mockImplementation(async () => { + queryCount++; + queryLog.push({ query: 'assignmentFeedback.groupBy', timestamp: Date.now() }); + await new Promise(resolve => setTimeout(resolve, 5)); + return [{ + assignmentId: 1, + _avg: { assignmentRating: 4.5 }, + _count: { id: 10 }, + }]; + }), + }, + user: { + findMany: jest.fn().mockResolvedValue([]), + }, + }; + }; + + const createMockRedisService = () => { + const cache = new Map(); + + return { + getOrSet: jest.fn().mockImplementation(async (key: string, factory: () => Promise, options?: any) => { + // Simulate cache miss on first call, hit on subsequent calls + if (!cache.has(key)) { + const value = await factory(); + cache.set(key, value); + return value; + } + return cache.get(key); + }), + get: jest.fn().mockImplementation(async (key: string) => cache.get(key)), + set: jest.fn().mockImplementation(async (key: string, value: any) => { + cache.set(key, value); + }), + del: jest.fn().mockImplementation(async (key: string) => { + cache.delete(key); + }), + clearCache: () => cache.clear(), + }; + }; + + const mockLLMPricingService = { + getPricingForModel: jest.fn().mockResolvedValue({ + inputPrice: 0.000_01, + outputPrice: 0.000_03, + effectiveDate: new Date(), + }), + calculateCostWithBreakdown: jest.fn().mockImplementation(async (modelKey, tokensIn, tokensOut, usageDate) => { + const inputPrice = 0.000_01; + const outputPrice = 0.000_03; + const inputCost = tokensIn * inputPrice; + const outputCost = tokensOut * outputPrice; + const totalCost = inputCost + outputCost; + + return { + inputCost, + outputCost, + totalCost, + modelKey, + inputTokenPrice: inputPrice, + outputTokenPrice: outputPrice, + pricingEffectiveDate: new Date(), + usageDate: usageDate || new Date(), + usageType: 'grading', + tokensIn, + tokensOut, + calculationSteps: { + inputCalculation: `${tokensIn} tokens × $${inputPrice} = $${inputCost}`, + outputCalculation: `${tokensOut} tokens × $${outputPrice} = $${outputCost}`, + totalCalculation: `$${inputCost} + $${outputCost} = $${totalCost}`, + }, + }; + }), + }; + + beforeEach(async () => { + // Reset performance tracking + queryLog.length = 0; + queryCount = 0; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AdminService, + { + provide: PrismaService, + useValue: createMockPrismaService(), + }, + { + provide: RedisService, + useValue: createMockRedisService(), + }, + { + provide: LLM_PRICING_SERVICE, + useValue: mockLLMPricingService, + }, + ], + }).compile(); + + adminService = module.get(AdminService); + prismaService = module.get(PrismaService); + redisService = module.get(RedisService); + }); + + afterEach(() => { + // Clear cache between tests + (redisService as any).clearCache?.(); + }); + + describe('getAssignmentAnalytics Performance', () => { + it('should complete BASIC tier request in < 600ms (cold cache)', async () => { + const startTime = Date.now(); + + await adminService.getAssignmentAnalytics( + mockAdminSession, + 1, + 10, + undefined, + false // BASIC tier + ); + + const duration = Date.now() - startTime; + + console.log(`\n📊 BASIC tier (cold): ${duration}ms`); + console.log(` Query count: ${queryCount}`); + + expect(duration).toBeLessThan(600); + expect(queryCount).toBeLessThan(15); // Allow some overhead for batched queries + }); + + it('should complete DETAILED tier request in < 1200ms (cold cache)', async () => { + const startTime = Date.now(); + + await adminService.getAssignmentAnalytics( + mockAdminSession, + 1, + 10, + undefined, + true // DETAILED tier + ); + + const duration = Date.now() - startTime; + + console.log(`\n📊 DETAILED tier (cold): ${duration}ms`); + console.log(` Query count: ${queryCount}`); + + expect(duration).toBeLessThan(1200); + expect(queryCount).toBeLessThan(15); // Still should use batched queries + }); + + it('should complete cached request in < 250ms (warm cache)', async () => { + // Prime the cache + await adminService.getAssignmentAnalytics( + mockAdminSession, + 1, + 10, + undefined, + false + ); + + // Reset query counter + queryCount = 0; + queryLog.length = 0; + + // Second request should hit cache + const startTime = Date.now(); + + await adminService.getAssignmentAnalytics( + mockAdminSession, + 1, + 10, + undefined, + false + ); + + const duration = Date.now() - startTime; + + console.log(`\n📊 Cached request: ${duration}ms`); + console.log(` Query count: ${queryCount} (should be 0)`); + + expect(duration).toBeLessThan(250); + expect(queryCount).toBe(0); // No DB queries on cache hit + }); + + it('should use batched queries for multiple assignments', async () => { + queryCount = 0; + + await adminService.getAssignmentAnalytics( + mockAdminSession, + 1, + 10, + undefined, + false + ); + + // Should use batched queries, not N individual queries + // With 1 assignment and batching: + // - 1 count query + // - 1 findMany for assignments + // - 3 groupBy queries (attempts, unique learners, feedback) + // - 1 findMany for AI usage + // Total: ~6-8 queries (not 50+ from before) + + console.log(`\n📊 Total queries for analytics: ${queryCount}`); + console.log(' Query breakdown:'); + const queryBreakdown = queryLog.reduce((accumulator, log) => { + accumulator[log.query] = (accumulator[log.query] || 0) + 1; + return accumulator; + }, {} as Record); + + for (const [query, count] of Object.entries(queryBreakdown)) { + console.log(` - ${query}: ${count}`); + } + + expect(queryCount).toBeLessThan(10); + }); + }); + + describe('getDetailedAssignmentInsights Performance', () => { + it('should complete BASIC tier insights in < 600ms (cold cache)', async () => { + const startTime = Date.now(); + + await adminService.getDetailedAssignmentInsights( + mockAdminSession, + 1, + false // BASIC tier - no question insights + ); + + const duration = Date.now() - startTime; + + console.log(`\n📊 Insights BASIC tier (cold): ${duration}ms`); + console.log(` Query count: ${queryCount}`); + + expect(duration).toBeLessThan(600); + expect(queryCount).toBeLessThan(10); + }); + + it('should complete DETAILED tier insights in < 1200ms (cold cache)', async () => { + const startTime = Date.now(); + + await adminService.getDetailedAssignmentInsights( + mockAdminSession, + 1, + true // DETAILED tier - includes question insights + ); + + const duration = Date.now() - startTime; + + console.log(`\n📊 Insights DETAILED tier (cold): ${duration}ms`); + console.log(` Query count: ${queryCount}`); + + expect(duration).toBeLessThan(1200); + expect(queryCount).toBeLessThan(25); // More queries for question insights (1 groupBy + N count queries), but still batched + }); + + it('should skip expensive question queries when details=false', async () => { + queryCount = 0; + queryLog.length = 0; + + await adminService.getDetailedAssignmentInsights( + mockAdminSession, + 1, + false + ); + + // Should NOT call questionResponse.groupBy when details=false + const questionResponseQueries = queryLog.filter( + log => log.query === 'questionResponse.groupBy' + ); + + console.log(`\n📊 Question response queries (BASIC): ${questionResponseQueries.length}`); + + expect(questionResponseQueries.length).toBe(0); + }); + + it('should use batched query for question insights when details=true', async () => { + queryCount = 0; + queryLog.length = 0; + + await adminService.getDetailedAssignmentInsights( + mockAdminSession, + 1, + true + ); + + // Should use ONE batched groupBy instead of N individual queries + const questionResponseGroupBy = queryLog.filter( + log => log.query === 'questionResponse.groupBy' + ); + + const questionResponseCount = queryLog.filter( + log => log.query === 'questionResponse.count' + ); + + console.log(`\n📊 Question insights queries (DETAILED):`); + console.log(` - groupBy calls: ${questionResponseGroupBy.length} (should be 1)`); + console.log(` - count calls: ${questionResponseCount.length} (should be ~10 for correct counts)`); + + // With 10 questions, we should have: + // - 1 groupBy to get stats for all questions + // - 10 count queries to get correct answer counts (one per question) + // Total: 11 queries instead of 30+ (3 per question) + + expect(questionResponseGroupBy.length).toBe(1); + expect(questionResponseCount.length).toBe(10); + }); + }); + + describe('Cache Performance', () => { + it('should have separate cache keys for BASIC and DETAILED tiers', async () => { + const getOrSetSpy = jest.spyOn(redisService, 'getOrSet'); + + // Request BASIC tier + await adminService.getAssignmentAnalytics( + mockAdminSession, + 1, + 10, + undefined, + false + ); + + const basicCacheKey = getOrSetSpy.mock.calls[0][0]; + + // Request DETAILED tier + await adminService.getAssignmentAnalytics( + mockAdminSession, + 1, + 10, + undefined, + true + ); + + const detailedCacheKey = getOrSetSpy.mock.calls[1][0]; + + console.log(`\n📊 Cache keys:`); + console.log(` BASIC: ${basicCacheKey}`); + console.log(` DETAILED: ${detailedCacheKey}`); + + // Cache keys should be different to prevent serving BASIC data for DETAILED requests + expect(basicCacheKey).not.toBe(detailedCacheKey); + expect(basicCacheKey).toContain('basic'); + expect(detailedCacheKey).toContain('detailed'); + }); + + it('should cache responses independently for different tiers', async () => { + // Prime both caches + const basicResult = await adminService.getAssignmentAnalytics( + mockAdminSession, + 1, + 10, + undefined, + false + ); + + const detailedResult = await adminService.getAssignmentAnalytics( + mockAdminSession, + 1, + 10, + undefined, + true + ); + + // Reset query counter + queryCount = 0; + + // Both subsequent requests should hit cache + await adminService.getAssignmentAnalytics( + mockAdminSession, + 1, + 10, + undefined, + false + ); + + await adminService.getAssignmentAnalytics( + mockAdminSession, + 1, + 10, + undefined, + true + ); + + console.log(`\n📊 Queries after caching both tiers: ${queryCount} (should be 0)`); + + expect(queryCount).toBe(0); + }); + }); + + describe('Response Payload Size', () => { + it('should return smaller payload for BASIC tier', async () => { + const basicResult = await adminService.getAssignmentAnalytics( + mockAdminSession, + 1, + 10, + undefined, + false + ); + + const detailedResult = await adminService.getAssignmentAnalytics( + mockAdminSession, + 1, + 10, + undefined, + true + ); + + const basicSize = JSON.stringify(basicResult).length; + const detailedSize = JSON.stringify(detailedResult).length; + + const reduction = ((detailedSize - basicSize) / detailedSize) * 100; + + console.log(`\n📊 Payload sizes:`); + console.log(` BASIC: ${basicSize} bytes`); + console.log(` DETAILED: ${detailedSize} bytes`); + console.log(` Reduction: ${reduction.toFixed(1)}%`); + + // BASIC should be at least 30% smaller (target is 60-80% but depends on data) + expect(basicSize).toBeLessThan(detailedSize); + expect(reduction).toBeGreaterThan(30); + }); + + it('should exclude detailedCostBreakdown in BASIC tier', async () => { + const basicResult: any = await adminService.getAssignmentAnalytics( + mockAdminSession, + 1, + 10, + undefined, + false + ); + + const detailedResult: any = await adminService.getAssignmentAnalytics( + mockAdminSession, + 1, + 10, + undefined, + true + ); + + // BASIC should not have detailedCostBreakdown + expect(basicResult.data[0]?.insights?.detailedCostBreakdown).toBeUndefined(); + + // DETAILED should have it + expect(detailedResult.data[0]?.insights?.detailedCostBreakdown).toBeDefined(); + + console.log(`\n📊 Cost breakdown included:`); + console.log(` BASIC: ${!!basicResult.data[0]?.insights?.detailedCostBreakdown}`); + console.log(` DETAILED: ${!!detailedResult.data[0]?.insights?.detailedCostBreakdown}`); + }); + }); + + describe('Performance Regression Tests', () => { + it('should maintain performance with input validation', async () => { + const startTime = Date.now(); + + // Request with max limit + await adminService.getAssignmentAnalytics( + mockAdminSession, + 1, + 25, // MAX_LIMIT + undefined, + false + ); + + const duration = Date.now() - startTime; + + console.log(`\n📊 Max limit request: ${duration}ms`); + + // Should still be fast even with max limit + expect(duration).toBeLessThan(800); + }); + + it('should handle search queries efficiently', async () => { + const startTime = Date.now(); + + await adminService.getAssignmentAnalytics( + mockAdminSession, + 1, + 10, + 'Test Assignment', // search term + false + ); + + const duration = Date.now() - startTime; + + console.log(`\n📊 Search query request: ${duration}ms`); + + expect(duration).toBeLessThan(600); + }); + }); + + describe('Performance Summary', () => { + it('should log comprehensive performance metrics', async () => { + console.log('\n' + '='.repeat(60)); + console.log('📊 ADMIN ANALYTICS PERFORMANCE SUMMARY'); + console.log('='.repeat(60)); + + const tests = [ + { name: 'BASIC (cold)', tier: false, target: 600 }, + { name: 'DETAILED (cold)', tier: true, target: 1200 }, + ]; + + for (const test of tests) { + queryCount = 0; + queryLog.length = 0; + (redisService as any).clearCache?.(); + + const start = Date.now(); + await adminService.getAssignmentAnalytics( + mockAdminSession, + 1, + 10, + undefined, + test.tier + ); + const duration = Date.now() - start; + + const status = duration < test.target ? '✅' : '❌'; + + console.log(`\n${status} ${test.name}`); + console.log(` Duration: ${duration}ms (target: <${test.target}ms)`); + console.log(` Queries: ${queryCount} (target: <10)`); + console.log(` Status: ${duration < test.target ? 'PASS' : 'FAIL'}`); + } + + console.log('\n' + '='.repeat(60)); + }); + }); +}); diff --git a/apps/api/src/api/admin/admin.controller.spec.ts b/apps/api/src/api/admin/admin.controller.spec.ts index c6fbbb5f..d3049aad 100644 --- a/apps/api/src/api/admin/admin.controller.spec.ts +++ b/apps/api/src/api/admin/admin.controller.spec.ts @@ -1,15 +1,66 @@ import { Test, TestingModule } from "@nestjs/testing"; +import { WinstonModule } from "nest-winston"; import { AdminVerificationService } from "../../auth/services/admin-verification.service"; +import { RedisService } from "../../cache/redis.service"; import { PrismaService } from "../../database/prisma.service"; import { LLM_PRICING_SERVICE } from "../llm/llm.constants"; import { AdminController } from "./admin.controller"; import { AdminRepository } from "./admin.repository"; import { AdminService } from "./admin.service"; +process.env.DATABASE_URL = "postgresql://test:test@localhost:5432/test"; +process.env.REDIS_HOST = "localhost"; +process.env.REDIS_PORT = "6379"; + describe("AdminController", () => { let controller: AdminController; const originalDatabaseUrl = process.env.DATABASE_URL; + const mockPrismaService = { + report: { + findMany: jest.fn().mockResolvedValue([]), + aggregate: jest.fn(), + count: jest.fn(), + }, + aIUsage: { + findMany: jest.fn().mockResolvedValue([]), + }, + lLMPricing: { + findMany: jest.fn().mockResolvedValue([]), + findUnique: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue({}), + update: jest.fn().mockResolvedValue({}), + upsert: jest.fn().mockResolvedValue({}), + }, + lLMModel: { + findUnique: jest.fn().mockResolvedValue(null), + findMany: jest.fn().mockResolvedValue([]), + create: jest.fn().mockResolvedValue({}), + update: jest.fn().mockResolvedValue({}), + }, + user: { + findMany: jest.fn().mockResolvedValue([]), + count: jest.fn(), + groupBy: jest.fn().mockResolvedValue([]), + }, + assignment: { + findUnique: jest.fn().mockResolvedValue(null), + findMany: jest.fn().mockResolvedValue([]), + count: jest.fn(), + }, + $disconnect: jest.fn().mockResolvedValue(), + $connect: jest.fn().mockResolvedValue(), + }; + + const mockRedisService = { + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + flush: jest.fn().mockResolvedValue(), + connect: jest.fn().mockResolvedValue(), + disconnect: jest.fn().mockResolvedValue(), + }; + beforeAll(() => { process.env.DATABASE_URL = originalDatabaseUrl ?? "postgresql://user:pass@localhost:5432/test"; // pragma: allowlist secret @@ -41,11 +92,17 @@ describe("AdminController", () => { }; const module: TestingModule = await Test.createTestingModule({ + imports: [ + WinstonModule.forRoot({ + transports: [], + }), + ], controllers: [AdminController], providers: [ AdminService, - PrismaService, AdminRepository, + { provide: PrismaService, useValue: mockPrismaService }, + { provide: RedisService, useValue: mockRedisService }, { provide: LLM_PRICING_SERVICE, useValue: mockLlmPricingService }, { provide: AdminVerificationService, diff --git a/apps/api/src/api/admin/admin.module.ts b/apps/api/src/api/admin/admin.module.ts index 55ec830b..47ec8d5a 100644 --- a/apps/api/src/api/admin/admin.module.ts +++ b/apps/api/src/api/admin/admin.module.ts @@ -1,5 +1,6 @@ -import { Module } from "@nestjs/common"; +import { Module, forwardRef } from "@nestjs/common"; import { PassportModule } from "@nestjs/passport"; +import { RedisModule } from "src/cache/redis.module"; import { PrismaService } from "src/database/prisma.service"; import { AdminAuthModule } from "../../auth/admin-auth.module"; import { AuthModule } from "../../auth/auth.module"; @@ -21,7 +22,8 @@ import { RegradingRequestsController } from "./controllers/regrading-requests.co PassportModule, AdminAuthModule, LlmModule, - ScheduledTasksModule, + forwardRef(() => ScheduledTasksModule), + RedisModule, ], controllers: [ AdminController, @@ -33,5 +35,6 @@ import { RegradingRequestsController } from "./controllers/regrading-requests.co AssignmentAnalyticsController, ], providers: [AdminService, PrismaService, AdminRepository], + exports: [AdminService], }) 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 f434f15b..ed5ef74c 100644 --- a/apps/api/src/api/admin/admin.service.spec.ts +++ b/apps/api/src/api/admin/admin.service.spec.ts @@ -1,12 +1,64 @@ import { Test, TestingModule } from "@nestjs/testing"; +import { WinstonModule } from "nest-winston"; +import { RedisService } from "../../cache/redis.service"; import { PrismaService } from "../../database/prisma.service"; import { LLM_PRICING_SERVICE } from "../llm/llm.constants"; +import { AdminRepository } from "./admin.repository"; import { AdminService } from "./admin.service"; +process.env.DATABASE_URL = "postgresql://test:test@localhost:5432/test"; +process.env.REDIS_HOST = "localhost"; +process.env.REDIS_PORT = "6379"; + describe("AdminService", () => { let service: AdminService; const originalDatabaseUrl = process.env.DATABASE_URL; + const mockPrismaService = { + report: { + findMany: jest.fn().mockResolvedValue([]), + aggregate: jest.fn(), + count: jest.fn(), + }, + aIUsage: { + findMany: jest.fn().mockResolvedValue([]), + }, + lLMPricing: { + findMany: jest.fn().mockResolvedValue([]), + findUnique: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue({}), + update: jest.fn().mockResolvedValue({}), + upsert: jest.fn().mockResolvedValue({}), + }, + lLMModel: { + findUnique: jest.fn().mockResolvedValue(null), + findMany: jest.fn().mockResolvedValue([]), + create: jest.fn().mockResolvedValue({}), + update: jest.fn().mockResolvedValue({}), + }, + user: { + findMany: jest.fn().mockResolvedValue([]), + count: jest.fn(), + groupBy: jest.fn().mockResolvedValue([]), + }, + assignment: { + findUnique: jest.fn().mockResolvedValue(null), + findMany: jest.fn().mockResolvedValue([]), + count: jest.fn(), + }, + $disconnect: jest.fn().mockResolvedValue(), + $connect: jest.fn().mockResolvedValue(), + }; + + const mockRedisService = { + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + flush: jest.fn().mockResolvedValue(), + connect: jest.fn().mockResolvedValue(), + disconnect: jest.fn().mockResolvedValue(), + }; + beforeAll(() => { process.env.DATABASE_URL = originalDatabaseUrl ?? "postgresql://user:pass@localhost:5432/test"; // pragma: allowlist secret @@ -27,9 +79,16 @@ describe("AdminService", () => { }; const module: TestingModule = await Test.createTestingModule({ + imports: [ + WinstonModule.forRoot({ + transports: [], + }), + ], providers: [ AdminService, - PrismaService, + AdminRepository, + { provide: PrismaService, useValue: mockPrismaService }, + { provide: RedisService, useValue: mockRedisService }, { provide: LLM_PRICING_SERVICE, useValue: mockLlmPricingService }, ], }).compile(); diff --git a/apps/api/src/api/admin/admin.service.ts b/apps/api/src/api/admin/admin.service.ts index 061c1cc9..06b4217e 100644 --- a/apps/api/src/api/admin/admin.service.ts +++ b/apps/api/src/api/admin/admin.service.ts @@ -4,11 +4,18 @@ /* eslint-disable @typescript-eslint/no-unsafe-argument */ /* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/restrict-template-expressions */ -import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common"; +import { + BadRequestException, + Inject, + Injectable, + Logger, + NotFoundException, +} from "@nestjs/common"; import { UserRole, UserSession, } from "../../auth/interfaces/user.session.interface"; +import { RedisService } from "../../cache/redis.service"; import { PrismaService } from "../../database/prisma.service"; import { LLMPricingService } from "../llm/core/services/llm-pricing.service"; import { LLM_PRICING_SERVICE } from "../llm/llm.constants"; @@ -32,209 +39,194 @@ interface DashboardFilters { @Injectable() export class AdminService { private readonly logger = new Logger(AdminService.name); - private readonly insightsCache = new Map< - string, - { data: any; cachedAt: number } - >(); - private readonly INSIGHTS_CACHE_TTL = 1 * 60 * 1000; // 1 minute cache + private readonly INSIGHTS_CACHE_TTL = 300; + private readonly ANALYTICS_CACHE_TTL = 180; + private readonly DASHBOARD_CACHE_TTL = 120; + private readonly QUICK_ACTION_CACHE_TTL = 300; constructor( private readonly prisma: PrismaService, + private readonly redisService: RedisService, @Inject(LLM_PRICING_SERVICE) - private readonly llmPricingService: LLMPricingService, + private readonly llmPricingService: LLMPricingService ) {} /** - * Helper method to get cached insights data - */ - private getCachedInsights(assignmentId: number): any | null { - const cacheKey = `insights:${assignmentId}`; - const cached = this.insightsCache.get(cacheKey); - - if (cached && Date.now() - cached.cachedAt < this.INSIGHTS_CACHE_TTL) { - this.logger.debug(`Cache hit for assignment ${assignmentId} insights`); - return cached.data; - } - - if (cached) { - this.insightsCache.delete(cacheKey); - } - - return null; - } - - /** - * Helper method to cache insights data + * Helper method to generate cache keys */ - private setCachedInsights(assignmentId: number, data: any): void { - const cacheKey = `insights:${assignmentId}`; - this.insightsCache.set(cacheKey, { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - data, - cachedAt: Date.now(), - }); - this.logger.debug(`Cached insights for assignment ${assignmentId}`); + private getCacheKey(prefix: string, ...parts: (string | number)[]): string { + return `admin:${prefix}:${parts.join(":")}`; } /** * Helper method to invalidate insights cache for an assignment */ - private invalidateInsightsCache(assignmentId: number): void { - const cacheKey = `insights:${assignmentId}`; - this.insightsCache.delete(cacheKey); + private async invalidateInsightsCache(assignmentId: number): Promise { + const cacheKey = this.getCacheKey("insights", assignmentId); + await this.redisService.del(cacheKey); this.logger.debug( - `Invalidated insights cache for assignment ${assignmentId}`, + `Invalidated insights cache for assignment ${assignmentId}` ); } /** * Public method to invalidate insights cache when assignment data changes */ - invalidateAssignmentInsightsCache(assignmentId: number): void { - this.invalidateInsightsCache(assignmentId); + async invalidateAssignmentInsightsCache(assignmentId: number): Promise { + await this.invalidateInsightsCache(assignmentId); + await this.redisService.delPattern(this.getCacheKey("analytics", "*")); + await this.redisService.delPattern(this.getCacheKey("dashboard", "*")); + } + + /** + * Invalidate all admin caches + */ + async invalidateAllCaches(): Promise { + await this.redisService.delPattern(this.getCacheKey("*")); + this.logger.log("Invalidated all admin caches"); } async getBasicAssignmentAnalytics(assignmentId: number) { - // Check if the assignment exists - const assignment = await this.prisma.assignment.findUnique({ - where: { id: assignmentId }, - include: { - questions: { - where: { isDeleted: false }, - }, - }, - }); + const cacheKey = this.getCacheKey("basic-analytics", assignmentId); + + return this.redisService.getOrSet( + cacheKey, + async () => { + const assignment = await this.prisma.assignment.findUnique({ + where: { id: assignmentId }, + include: { + questions: { + where: { isDeleted: false }, + }, + }, + }); - if (!assignment) { - throw new Error(`Assignment with ID ${assignmentId} not found`); - } + if (!assignment) { + throw new Error(`Assignment with ID ${assignmentId} not found`); + } - // Get all attempts for this assignment - const attempts = await this.prisma.assignmentAttempt.findMany({ - where: { - assignmentId, - submitted: true, - }, - include: { - questionResponses: true, - }, - }); + const attempts = await this.prisma.assignmentAttempt.findMany({ + where: { + assignmentId, + submitted: true, + }, + include: { + questionResponses: true, + }, + }); - // Calculate average score - const totalGrades = attempts.reduce( - (sum, attempt) => sum + (attempt.grade || 0), - 0, - ); - const averageScore = - attempts.length > 0 ? (totalGrades / attempts.length) * 100 : 0; - - // Calculate median score - const grades = attempts - .map((attempt) => attempt.grade || 0) - .sort((a, b) => a - b); - const medianIndex = Math.floor(grades.length / 2); - const medianScore = - grades.length > 0 - ? (grades.length % 2 === 0 - ? (grades[medianIndex - 1] + grades[medianIndex]) / 2 - : grades[medianIndex]) * 100 - : 0; - - // Calculate completion rate - const totalAttempts = attempts.length; - const completedAttempts = attempts.filter( - (attempt) => attempt.submitted, - ).length; - const completionRate = - totalAttempts > 0 ? (completedAttempts / totalAttempts) * 100 : 0; - - // Calculate average completion time - const completionTimes = attempts - .map((attempt) => { - if (attempt.createdAt && attempt.expiresAt) { - return ( - new Date(attempt.expiresAt).getTime() - - new Date(attempt.createdAt).getTime() - ); - } - return 0; - }) - .filter((time) => time > 0); - - const avgTimeMs = - completionTimes.length > 0 - ? completionTimes.reduce((sum, time) => sum + time, 0) / - completionTimes.length - : 0; - const averageCompletionTime = Math.round(avgTimeMs / (1000 * 60)); // Convert to minutes - - // Calculate score distribution - const scoreRanges = [ - "0-10", - "11-20", - "21-30", - "31-40", - "41-50", - "51-60", - "61-70", - "71-80", - "81-90", - "91-100", - ]; - const scoreDistribution = scoreRanges.map((range) => { - const [min, max] = range.split("-").map(Number); - const count = grades.filter((grade) => { - const score = grade * 100; - return score >= min && score <= max; - }).length; - return { range, count }; - }); + const totalGrades = attempts.reduce( + (sum, attempt) => sum + (attempt.grade || 0), + 0 + ); + const averageScore = + attempts.length > 0 ? (totalGrades / attempts.length) * 100 : 0; + + const grades = attempts + .map((attempt) => attempt.grade || 0) + .sort((a, b) => a - b); + const medianIndex = Math.floor(grades.length / 2); + const medianScore = + grades.length > 0 + ? (grades.length % 2 === 0 + ? (grades[medianIndex - 1] + grades[medianIndex]) / 2 + : grades[medianIndex]) * 100 + : 0; - // Calculate question breakdown - const questionBreakdown = assignment.questions.map((question) => { - const responses = attempts.flatMap((attempt) => - attempt.questionResponses.filter( - (response) => response.questionId === question.id, - ), - ); + const totalAttempts = attempts.length; + const completedAttempts = attempts.filter( + (attempt) => attempt.submitted + ).length; + const completionRate = + totalAttempts > 0 ? (completedAttempts / totalAttempts) * 100 : 0; + + const completionTimes = attempts + .map((attempt) => { + if (attempt.createdAt && attempt.expiresAt) { + return ( + new Date(attempt.expiresAt).getTime() - + new Date(attempt.createdAt).getTime() + ); + } + return 0; + }) + .filter((time) => time > 0); - const totalPoints = responses.reduce( - (sum, response) => sum + response.points, - 0, - ); - const averageScore = - responses.length > 0 - ? (totalPoints / (responses.length * question.totalPoints)) * 100 - : 0; + const avgTimeMs = + completionTimes.length > 0 + ? completionTimes.reduce((sum, time) => sum + time, 0) / + completionTimes.length + : 0; + const averageCompletionTime = Math.round(avgTimeMs / (1000 * 60)); + + const scoreRanges = [ + "0-10", + "11-20", + "21-30", + "31-40", + "41-50", + "51-60", + "61-70", + "71-80", + "81-90", + "91-100", + ]; + const scoreDistribution = scoreRanges.map((range) => { + const [min, max] = range.split("-").map(Number); + const count = grades.filter((grade) => { + const score = grade * 100; + return score >= min && score <= max; + }).length; + return { range, count }; + }); - const incorrectResponses = responses.filter( - (response) => response.points < question.totalPoints, - ); - const incorrectRate = - responses.length > 0 - ? (incorrectResponses.length / responses.length) * 100 - : 0; + const questionBreakdown = assignment.questions.map((question) => { + const responses = attempts.flatMap((attempt) => + attempt.questionResponses.filter( + (response) => response.questionId === question.id + ) + ); - return { - questionId: question.id, - averageScore, - incorrectRate, - }; - }); + const totalPoints = responses.reduce( + (sum, response) => sum + response.points, + 0 + ); + const averageScore = + responses.length > 0 + ? (totalPoints / (responses.length * question.totalPoints)) * 100 + : 0; + + const incorrectResponses = responses.filter( + (response) => response.points < question.totalPoints + ); + const incorrectRate = + responses.length > 0 + ? (incorrectResponses.length / responses.length) * 100 + : 0; + + return { + questionId: question.id, + averageScore, + incorrectRate, + }; + }); - // total number of unique users who attempted the assignment - const uniqueUsers = new Set(attempts.map((attempt) => attempt.userId)).size; + const uniqueUsers = new Set(attempts.map((attempt) => attempt.userId)) + .size; - return { - averageScore, - medianScore, - completionRate, - totalAttempts, - averageCompletionTime, - scoreDistribution, - questionBreakdown, - uniqueUsers, - }; + return { + averageScore, + medianScore, + completionRate, + totalAttempts, + averageCompletionTime, + scoreDistribution, + questionBreakdown, + uniqueUsers, + }; + }, + { ttl: this.ANALYTICS_CACHE_TTL } + ); } /** @@ -265,7 +257,7 @@ export class AdminService { } catch (error) { this.logger.error( `Error fetching attempts for assignment ${assignmentId}:`, - error, + error ); return []; } @@ -277,15 +269,14 @@ export class AdminService { async precomputePopularInsights(): Promise { try { this.logger.log( - "Starting precomputation of insights for popular assignments", + "Starting precomputation of insights for popular assignments" ); - // Find the most accessed assignments in the last 7 days const popularAssignments = await this.prisma.assignmentAttempt.groupBy({ by: ["assignmentId"], where: { createdAt: { - gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), // Last 7 days + gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), }, }, _count: { @@ -296,14 +287,13 @@ export class AdminService { assignmentId: "desc", }, }, - take: 20, // Top 20 most active assignments + take: 20, }); this.logger.log( - `Found ${popularAssignments.length} popular assignments to precompute`, + `Found ${popularAssignments.length} popular assignments to precompute` ); - // Create a mock admin session for precomputation const adminSession = { assignmentId: 1, role: UserRole.ADMIN, @@ -311,7 +301,6 @@ export class AdminService { userId: "system-user", }; - // Process assignments in smaller batches to avoid overwhelming the system const batchSize = 5; for ( let index = 0; @@ -323,31 +312,29 @@ export class AdminService { await Promise.all( batch.map(async (assignment) => { try { - // This will compute and cache the insights await this.getDetailedAssignmentInsights( adminSession, - assignment.assignmentId, + assignment.assignmentId ); this.logger.debug( - `Precomputed insights for assignment ${assignment.assignmentId}`, + `Precomputed insights for assignment ${assignment.assignmentId}` ); } catch (error) { this.logger.warn( `Failed to precompute insights for assignment ${assignment.assignmentId}:`, - error, + error ); } - }), + }) ); - // Add a small delay between batches if (index + batchSize < popularAssignments.length) { - await new Promise((resolve) => setTimeout(resolve, 1000)); // 1 second delay + await new Promise((resolve) => setTimeout(resolve, 1000)); } } this.logger.log( - `Completed precomputation of insights for ${popularAssignments.length} assignments`, + `Completed precomputation of insights for ${popularAssignments.length} assignments` ); } catch (error) { this.logger.error("Error during insights precomputation:", error); @@ -364,7 +351,7 @@ export class AdminService { createdAt: Date; usageType?: string; modelKey?: string; - }>, + }> ): Promise<{ totalCost: number; costBreakdown: { @@ -402,16 +389,13 @@ export class AdminService { }; for (const usage of aiUsageRecords) { - // Use the actual model key from the database if available let modelKey = usage.modelKey; if (!modelKey) { - // Log warning for missing model key - this shouldn't happen with new records this.logger.warn( - `Missing model key for usage record from ${usage.createdAt.toISOString()}, falling back based on usage type`, + `Missing model key for usage record from ${usage.createdAt.toISOString()}, falling back based on usage type` ); - // Fallback logic for older records without model key stored const usageType = usage.usageType?.toLowerCase() || ""; if (usageType.includes("translation")) { modelKey = "gpt-4o-mini"; @@ -426,7 +410,7 @@ export class AdminService { ) { modelKey = "gpt-4o"; } else { - modelKey = "gpt-4o-mini"; // Default for unknown types + modelKey = "gpt-4o-mini"; } } @@ -436,13 +420,12 @@ export class AdminService { usage.tokensIn, usage.tokensOut, usage.createdAt, - usage.usageType, // Pass usage type for upscaling-aware calculations + usage.usageType ); if (costBreakdown) { totalCost += costBreakdown.totalCost; - // Categorize costs by usage type const usageType = usage.usageType?.toLowerCase() || "other"; if (usageType.includes("grading")) { costByType.grading += costBreakdown.totalCost; @@ -457,21 +440,20 @@ export class AdminService { costByType.other += costBreakdown.totalCost; } - // Create detailed calculation steps for transparency (showing per million token pricing) const inputPricePerMillion = costBreakdown.inputTokenPrice * 1_000_000; const outputPricePerMillion = costBreakdown.outputTokenPrice * 1_000_000; const calculationSteps = { inputCalculation: `${usage.tokensIn.toLocaleString()} tokens × $${inputPricePerMillion.toFixed( - 2, + 2 )}/1M tokens = $${costBreakdown.inputCost.toFixed(8)}`, outputCalculation: `${usage.tokensOut.toLocaleString()} tokens × $${outputPricePerMillion.toFixed( - 2, + 2 )}/1M tokens = $${costBreakdown.outputCost.toFixed(8)}`, totalCalculation: `$${costBreakdown.inputCost.toFixed( - 8, + 8 )} + $${costBreakdown.outputCost.toFixed( - 8, + 8 )} = $${costBreakdown.totalCost.toFixed(8)}`, }; @@ -490,9 +472,8 @@ export class AdminService { calculationSteps, }); } else { - // Enhanced fallback with proper logging and transparency this.logger.error( - `No pricing found for ${modelKey} at ${usage.createdAt.toISOString()}, using emergency fallback`, + `No pricing found for ${modelKey} at ${usage.createdAt.toISOString()}, using emergency fallback` ); const fallbackPrices: Record< @@ -517,13 +498,13 @@ export class AdminService { const outputPricePerMillion = prices.output * 1_000_000; const calculationSteps = { inputCalculation: `${usage.tokensIn.toLocaleString()} tokens × $${inputPricePerMillion.toFixed( - 2, + 2 )}/1M tokens = $${inputCost.toFixed(8)} (fallback)`, outputCalculation: `${usage.tokensOut.toLocaleString()} tokens × $${outputPricePerMillion.toFixed( - 2, + 2 )}/1M tokens = $${outputCost.toFixed(8)} (fallback)`, totalCalculation: `$${inputCost.toFixed(8)} + $${outputCost.toFixed( - 8, + 8 )} = $${fallbackCost.toFixed(8)} (fallback)`, }; @@ -537,7 +518,7 @@ export class AdminService { modelKey: `${modelKey} (fallback)`, inputTokenPrice: prices.input, outputTokenPrice: prices.output, - pricingEffectiveDate: new Date(), // Use current date for fallback + pricingEffectiveDate: new Date(), usageType: usage.usageType, calculationSteps, }); @@ -555,7 +536,7 @@ export class AdminService { * Helper method to get author activity insights */ private async getAuthorActivity( - assignmentAuthors: { userId: string; createdAt: Date }[], + assignmentAuthors: { userId: string; createdAt: Date }[] ) { if (!assignmentAuthors || assignmentAuthors.length === 0) { return { @@ -566,10 +547,9 @@ export class AdminService { } const authorIds = assignmentAuthors.map( - (author: { userId: string }) => author.userId, + (author: { userId: string }) => author.userId ); - // Get all assignments by these authors to understand their activity const authorAssignments = await this.prisma.assignment.findMany({ where: { AssignmentAuthor: { @@ -592,7 +572,6 @@ export class AdminService { }, }); - // Get assignment attempt counts separately since it's not a direct relation const attemptCounts = await this.prisma.assignmentAttempt.groupBy({ by: ["assignmentId"], where: { @@ -605,11 +584,10 @@ export class AdminService { }, }); - // Get recent activity for these authors (simplified approach) const validAssignmentIds = authorAssignments .map((a) => a.id) .filter( - (id) => id !== null && id !== undefined && typeof id === "number", + (id) => id !== null && id !== undefined && typeof id === "number" ); const recentActivity = @@ -630,39 +608,34 @@ export class AdminService { }) : []; - // Analyze author contributions const authorStats = authorIds.map((authorId) => { const authoredAssignments = authorAssignments.filter((assignment) => - assignment.AssignmentAuthor.some( - (author) => author.userId === authorId, - ), + assignment.AssignmentAuthor.some((author) => author.userId === authorId) ); const totalAssignments = authoredAssignments.length; const totalQuestions = authoredAssignments.reduce( (sum, assignment) => sum + assignment._count.questions, - 0, + 0 ); const totalAIUsage = authoredAssignments.reduce( (sum, assignment) => sum + assignment._count.AIUsage, - 0, + 0 ); const totalFeedback = authoredAssignments.reduce( (sum, assignment) => sum + assignment._count.AssignmentFeedback, - 0, + 0 ); - // Calculate total attempts from attempt counts const authorAssignmentIds = new Set(authoredAssignments.map((a) => a.id)); const totalAttempts = attemptCounts .filter((count) => authorAssignmentIds.has(count.assignmentId)) .reduce((sum, count) => sum + count._count.id, 0); - // Get recent activity for this author const authorRecentActivity = recentActivity.filter((attempt) => authoredAssignments.some( - (assignment) => assignment.id === attempt.assignmentId, - ), + (assignment) => assignment.id === attempt.assignmentId + ) ); return { @@ -684,34 +657,32 @@ export class AdminService { joinedAt: assignmentAuthors.find( (author: { userId: string; createdAt: Date }) => - author.userId === authorId, + author.userId === authorId )?.createdAt || new Date(), isActiveContributor: totalAssignments >= 3, activityScore: Math.round( - totalAssignments * 2 + totalQuestions * 0.5 + totalAttempts * 0.1, + totalAssignments * 2 + totalQuestions * 0.5 + totalAttempts * 0.1 ), }; }); - // Sort authors by activity score authorStats.sort((a, b) => b.activityScore - a.activityScore); - // Generate insights const activityInsights = []; const totalAuthors = authorStats.length; const activeAuthors = authorStats.filter( - (author) => author.isActiveContributor, + (author) => author.isActiveContributor ).length; const mostActiveAuthor = authorStats[0]; if (totalAuthors > 1) { activityInsights.push( - `This assignment has ${totalAuthors} contributing authors`, + `This assignment has ${totalAuthors} contributing authors` ); if (activeAuthors > 0) { activityInsights.push( - `${activeAuthors} of ${totalAuthors} authors are active contributors (3+ assignments)`, + `${activeAuthors} of ${totalAuthors} authors are active contributors (3+ assignments)` ); } @@ -719,17 +690,17 @@ export class AdminService { activityInsights.push( `Most active contributor: ${String(mostActiveAuthor.userId)} with ${ mostActiveAuthor.totalAssignments - } assignments`, + } assignments` ); } } else if (totalAuthors === 1) { const singleAuthor = authorStats[0]; activityInsights.push( - `Single author assignment by ${String(singleAuthor.userId)}`, + `Single author assignment by ${String(singleAuthor.userId)}` ); if (singleAuthor.totalAssignments > 1) { activityInsights.push( - `Author has created ${singleAuthor.totalAssignments} total assignments`, + `Author has created ${singleAuthor.totalAssignments} total assignments` ); } } @@ -743,7 +714,7 @@ export class AdminService { async cloneAssignment( id: number, - groupId: string, + groupId: string ): Promise { const assignment = await this.prisma.assignment.findUnique({ where: { id: id }, @@ -801,38 +772,59 @@ export class AdminService { }; } - // Method to get flagged submissions async getFlaggedSubmissions() { - return this.prisma.regradingRequest.findMany({ - where: { - regradingStatus: "PENDING", - }, - orderBy: { - createdAt: "desc", + const cacheKey = this.getCacheKey("flagged-submissions", "all"); + + return this.redisService.getOrSet( + cacheKey, + async () => { + return this.prisma.regradingRequest.findMany({ + where: { + regradingStatus: "PENDING", + }, + orderBy: { + createdAt: "desc", + }, + }); }, - }); + { ttl: this.ANALYTICS_CACHE_TTL } + ); } - // Method to dismiss a flagged submission async dismissFlaggedSubmission(id: number) { - return this.prisma.regradingRequest.update({ + const result = await this.prisma.regradingRequest.update({ where: { id }, data: { regradingStatus: "REJECTED", }, }); + + await this.redisService.delPattern( + this.getCacheKey("flagged-submissions", "*") + ); + await this.redisService.delPattern( + this.getCacheKey("regrading-requests", "*") + ); + + return result; } - // Method to get regrading requests async getRegradingRequests() { - return this.prisma.regradingRequest.findMany({ - orderBy: { - createdAt: "desc", + const cacheKey = this.getCacheKey("regrading-requests", "all"); + + return this.redisService.getOrSet( + cacheKey, + async () => { + return this.prisma.regradingRequest.findMany({ + orderBy: { + createdAt: "desc", + }, + }); }, - }); + { ttl: this.ANALYTICS_CACHE_TTL } + ); } - // Method to approve a regrading request async approveRegradingRequest(id: number, newGrade: number) { const request = await this.prisma.regradingRequest.findUnique({ where: { id }, @@ -842,7 +834,6 @@ export class AdminService { throw new Error(`Regrading request with ID ${id} not found`); } - // Update the regrading request status await this.prisma.regradingRequest.update({ where: { id }, data: { @@ -850,18 +841,21 @@ export class AdminService { }, }); - // Update the assignment attempt grade await this.prisma.assignmentAttempt.update({ where: { id: request.attemptId }, data: { - grade: newGrade / 100, // Convert percentage to decimal + grade: newGrade / 100, }, }); + await this.invalidateAssignmentInsightsCache(request.assignmentId); + await this.redisService.delPattern( + this.getCacheKey("regrading-requests", "*") + ); + return { success: true }; } - // Method to reject a regrading request async rejectRegradingRequest(id: number, reason: string) { const request = await this.prisma.regradingRequest.findUnique({ where: { id }, @@ -871,7 +865,6 @@ export class AdminService { throw new Error(`Regrading request with ID ${id} not found`); } - // Update the regrading request status await this.prisma.regradingRequest.update({ where: { id }, data: { @@ -880,11 +873,15 @@ export class AdminService { }, }); + await this.redisService.delPattern( + this.getCacheKey("regrading-requests", "*") + ); + return { success: true }; } async addAssignmentToGroup( assignmentId: number, - groupId: string, + groupId: string ): Promise { const assignment = await this.prisma.assignment.findUnique({ where: { id: assignmentId }, @@ -892,7 +889,7 @@ export class AdminService { if (!assignment) { throw new NotFoundException( - `Assignment with Id ${assignmentId} not found.`, + `Assignment with Id ${assignmentId} not found.` ); } @@ -941,7 +938,7 @@ export class AdminService { } async createAssignment( - createAssignmentRequestDto: AdminCreateAssignmentRequestDto, + createAssignmentRequestDto: AdminCreateAssignmentRequestDto ): Promise { const assignment = await this.prisma.assignment.create({ data: { @@ -994,13 +991,15 @@ export class AdminService { async updateAssignment( id: number, - updateAssignmentDto: AdminUpdateAssignmentRequestDto, + updateAssignmentDto: AdminUpdateAssignmentRequestDto ): Promise { const result = await this.prisma.assignment.update({ where: { id }, data: updateAssignmentDto, }); + await this.invalidateAssignmentInsightsCache(id); + return { id: result.id, success: true, @@ -1011,7 +1010,7 @@ export class AdminService { async replaceAssignment( id: number, - updateAssignmentDto: AdminReplaceAssignmentRequestDto, + updateAssignmentDto: AdminReplaceAssignmentRequestDto ): Promise { const result = await this.prisma.assignment.update({ where: { id }, @@ -1031,160 +1030,161 @@ export class AdminService { page: number, limit: number, search?: string, + details?: boolean, ) { - const isAdmin = adminSession.role === UserRole.ADMIN; - const skip = (page - 1) * limit; - - // Build where clause based on role and search - const searchCondition = search - ? { - OR: [ - { name: { contains: search, mode: "insensitive" as const } }, - // Check if search term is a number and search by ID - ...(Number.isNaN(Number(search)) - ? [] - : [{ id: { equals: Number(search) } }]), - ], - } - : {}; - - const whereClause = { - ...searchCondition, - ...(isAdmin - ? {} - : { - AssignmentAuthor: { - some: { - userId: adminSession.userId, - }, - }, - }), - }; + const cacheKey = this.getCacheKey( + "analytics", + adminSession.userId || "admin", + page, + limit, + search || "all", + details ? 'detailed' : 'basic' + ); - // Get total count for pagination (separate optimized query) - const totalCount = await this.prisma.assignment.count({ - where: whereClause, - }); + return this.redisService.getOrSet( + cacheKey, + async () => { + const isAdmin = adminSession.role === UserRole.ADMIN; + const skip = (page - 1) * limit; + + const searchCondition = search + ? { + OR: [ + { name: { contains: search, mode: "insensitive" as const } }, + ...(Number.isNaN(Number(search)) + ? [] + : [{ id: { equals: Number(search) } }]), + ], + } + : {}; - // Get assignments with only essential data for the list view - const assignments = await this.prisma.assignment.findMany({ - where: whereClause, - skip, - take: limit, - select: { - id: true, - name: true, - published: true, - updatedAt: true, - }, - orderBy: { updatedAt: "desc" }, - }); + const whereClause = { + ...searchCondition, + ...(isAdmin + ? {} + : { + AssignmentAuthor: { + some: { + userId: adminSession.userId, + }, + }, + }), + }; - if (assignments.length === 0) { - return { - data: [], - pagination: { - total: totalCount, - page, - limit, - totalPages: Math.ceil(totalCount / limit), - }, - }; - } + const totalCount = await this.prisma.assignment.count({ + where: whereClause, + }); - const assignmentIds = assignments.map((a) => a.id); + const assignments = await this.prisma.assignment.findMany({ + where: whereClause, + skip, + take: limit, + select: { + id: true, + name: true, + published: true, + updatedAt: true, + }, + orderBy: { updatedAt: "desc" }, + }); - // Batch fetch all analytics data with optimized queries - const [attemptStats, uniqueLearnersStats, feedbackStats] = - await Promise.all([ - // Get attempt statistics in separate queries for accuracy - Promise.all([ - // Get total attempts count (all attempts) - this.prisma.assignmentAttempt.groupBy({ - by: ["assignmentId"], - where: { - assignmentId: { in: assignmentIds }, - }, - _count: { - id: true, - }, - }), - // Get completed attempts count and average grade (only submitted attempts) - this.prisma.assignmentAttempt.groupBy({ - by: ["assignmentId"], - where: { - assignmentId: { in: assignmentIds }, - submitted: true, - }, - _count: { - id: true, - }, - _avg: { - grade: true, + if (assignments.length === 0) { + return { + data: [], + pagination: { + total: totalCount, + page, + limit, + totalPages: Math.ceil(totalCount / limit), }, - }), - ]).then(([totalStats, submittedStats]) => { - const totalStatsMap = new Map( - totalStats.map((s) => [s.assignmentId, s._count.id]), - ); - const submittedStatsMap = new Map( - submittedStats.map((s) => [s.assignmentId, s]), - ); + }; + } - return { totalStatsMap, submittedStatsMap }; - }), + const assignmentIds = assignments.map((a) => a.id); - // Get unique learner counts in one query - use distinct users per assignment - Promise.all( - assignmentIds.map(async (assignmentId) => { - const uniqueUsers = await this.prisma.assignmentAttempt.findMany({ - where: { assignmentId }, - distinct: ["userId"], - select: { userId: true }, - }); - return { assignmentId, uniqueUsersCount: uniqueUsers.length }; - }), - ), + const [attemptStats, uniqueLearnersStats, feedbackStats] = + await Promise.all([ + Promise.all([ + this.prisma.assignmentAttempt.groupBy({ + by: ["assignmentId"], + where: { + assignmentId: { in: assignmentIds }, + }, + _count: { + id: true, + }, + }), + this.prisma.assignmentAttempt.groupBy({ + by: ["assignmentId"], + where: { + assignmentId: { in: assignmentIds }, + submitted: true, + }, + _count: { + id: true, + }, + _avg: { + grade: true, + }, + }), + ]).then(([totalStats, submittedStats]) => { + const totalStatsMap = new Map( + totalStats.map((s) => [s.assignmentId, s._count.id]) + ); + const submittedStatsMap = new Map( + submittedStats.map((s) => [s.assignmentId, s]) + ); - // Get feedback statistics in one query - this.prisma.assignmentFeedback.groupBy({ - by: ["assignmentId"], - where: { - assignmentId: { in: assignmentIds }, - assignmentRating: { not: undefined }, - }, - _avg: { - assignmentRating: true, - }, - _count: { - id: true, - }, - }), - ]); + return { totalStatsMap, submittedStatsMap }; + }), - const { totalStatsMap, submittedStatsMap } = attemptStats; - const uniqueLearnersMap = new Map( - uniqueLearnersStats.map((s) => [s.assignmentId, s.uniqueUsersCount]), - ); - const feedbackMap = new Map(feedbackStats.map((s) => [s.assignmentId, s])); - // const questionMap = new Map(questionStats.map(s => [s.assignmentId, s])); + // Batched query for unique learners per assignment + this.prisma.assignmentAttempt.groupBy({ + by: ["assignmentId", "userId"], + where: { + assignmentId: { in: assignmentIds }, + }, + }).then(results => { + // Count unique users per assignment + const countsMap = new Map(); + for (const result of results) { + const count = countsMap.get(result.assignmentId) || 0; + countsMap.set(result.assignmentId, count + 1); + } + return assignmentIds.map(id => ({ + assignmentId: id, + uniqueUsersCount: countsMap.get(id) || 0, + })); + }), - const analyticsData = await Promise.all( - assignments.map(async (assignment) => { - const totalAttempts = totalStatsMap.get(assignment.id) || 0; - const submittedData = submittedStatsMap.get(assignment.id); - const completedAttempts = submittedData?._count.id || 0; - const uniqueLearners = uniqueLearnersMap.get(assignment.id) || 0; - const feedback = feedbackMap.get(assignment.id); - // const questions = questionMap.get(assignment.id); // Future use - - const averageGrade = (submittedData?._avg.grade || 0) * 100; // Convert to percentage - const averageRating = feedback?._avg.assignmentRating || 0; - - // Get detailed AI usage data for accurate cost calculation - const aiUsageDetails = await this.prisma.aIUsage.findMany({ - where: { assignmentId: assignment.id }, + this.prisma.assignmentFeedback.groupBy({ + by: ["assignmentId"], + where: { + assignmentId: { in: assignmentIds }, + assignmentRating: { not: undefined }, + }, + _avg: { + assignmentRating: true, + }, + _count: { + id: true, + }, + }), + ]); + + const { totalStatsMap, submittedStatsMap } = attemptStats; + const uniqueLearnersMap = new Map( + uniqueLearnersStats.map((s) => [s.assignmentId, s.uniqueUsersCount]) + ); + const feedbackMap = new Map( + feedbackStats.map((s) => [s.assignmentId, s]) + ); + + // Batched query: Fetch all AI usage records for all assignments at once + const allAiUsage = await this.prisma.aIUsage.findMany({ + where: { assignmentId: { in: assignmentIds } }, select: { + assignmentId: true, tokensIn: true, tokensOut: true, createdAt: true, @@ -1193,189 +1193,279 @@ export class AdminService { }, }); - // Calculate accurate costs using historical pricing and actual models - const costData = await this.calculateHistoricalCosts(aiUsageDetails); - const totalCost = costData.totalCost; - - // Generate simple performance insights - const performanceInsights: string[] = []; - if (totalAttempts > 0) { - const completionRate = (completedAttempts / totalAttempts) * 100; - if (completionRate < 70) { - performanceInsights.push( - `Low completion rate (${Math.round( - completionRate, - )}%) - consider reducing difficulty`, - ); - } - if (averageGrade > 85) { - performanceInsights.push( - `High average grade (${Math.round( - averageGrade, - )}%) - learners are doing well`, - ); - } else if (averageGrade < 60) { - performanceInsights.push( - `Low average grade (${Math.round( - averageGrade, - )}%) - may need clearer instructions`, - ); + // Group AI usage by assignmentId + const aiUsageByAssignment = new Map(); + for (const usage of allAiUsage) { + if (!aiUsageByAssignment.has(usage.assignmentId)) { + aiUsageByAssignment.set(usage.assignmentId, []); } + aiUsageByAssignment.get(usage.assignmentId)!.push(usage); } - // Accurate cost breakdown based on actual usage types - const costBreakdown = { - grading: Math.round(costData.costBreakdown.grading * 100) / 100, - questionGeneration: - Math.round(costData.costBreakdown.questionGeneration * 100) / 100, - translation: - Math.round(costData.costBreakdown.translation * 100) / 100, - other: Math.round(costData.costBreakdown.other * 100) / 100, - }; + const analyticsData = await Promise.all( + assignments.map(async (assignment) => { + const totalAttempts = totalStatsMap.get(assignment.id) || 0; + const submittedData = submittedStatsMap.get(assignment.id); + const completedAttempts = submittedData?._count.id || 0; + const uniqueLearners = uniqueLearnersMap.get(assignment.id) || 0; + const feedback = feedbackMap.get(assignment.id); + + const averageGrade = (submittedData?._avg.grade || 0) * 100; + const averageRating = feedback?._avg.assignmentRating || 0; + + const aiUsageDetails = aiUsageByAssignment.get(assignment.id) || []; + + const costData = await this.calculateHistoricalCosts( + aiUsageDetails + ); + const totalCost = costData.totalCost; + + const performanceInsights: string[] = []; + if (totalAttempts > 0) { + const completionRate = (completedAttempts / totalAttempts) * 100; + if (completionRate < 70) { + performanceInsights.push( + `Low completion rate (${Math.round( + completionRate + )}%) - consider reducing difficulty` + ); + } + if (averageGrade > 85) { + performanceInsights.push( + `High average grade (${Math.round( + averageGrade + )}%) - learners are doing well` + ); + } else if (averageGrade < 60) { + performanceInsights.push( + `Low average grade (${Math.round( + averageGrade + )}%) - may need clearer instructions` + ); + } + } + + const costBreakdown = { + grading: Math.round(costData.costBreakdown.grading * 100) / 100, + questionGeneration: + Math.round(costData.costBreakdown.questionGeneration * 100) / + 100, + translation: + Math.round(costData.costBreakdown.translation * 100) / 100, + other: Math.round(costData.costBreakdown.other * 100) / 100, + }; + + return { + id: assignment.id, + name: assignment.name, + totalCost, + uniqueLearners, + totalAttempts, + completedAttempts, + averageGrade, + averageRating, + published: assignment.published, + insights: { + questionInsights: [], + performanceInsights, + costBreakdown, + ...(details && { + detailedCostBreakdown: costData.detailedBreakdown.map( + (detail) => ({ + ...detail, + usageDate: detail.usageDate.toISOString(), + pricingEffectiveDate: + detail.pricingEffectiveDate.toISOString(), + }) + ), + }), + }, + }; + }) + ); return { - id: assignment.id, - name: assignment.name, - totalCost, - uniqueLearners, - totalAttempts, - completedAttempts, - averageGrade, - averageRating, - published: assignment.published, - insights: { - questionInsights: [], // Simplified - can be loaded on-demand - performanceInsights, - costBreakdown, - detailedCostBreakdown: costData.detailedBreakdown.map((detail) => ({ - ...detail, - usageDate: detail.usageDate.toISOString(), - pricingEffectiveDate: detail.pricingEffectiveDate.toISOString(), - })), + data: analyticsData, + pagination: { + total: totalCount, + page, + limit, + totalPages: Math.ceil(totalCount / limit), }, }; - }), - ); - - return { - data: analyticsData, - pagination: { - total: totalCount, - page, - limit, - totalPages: Math.ceil(totalCount / limit), }, - }; + { ttl: this.ANALYTICS_CACHE_TTL } + ); } async getDashboardStats( adminSession: UserSession & { userId?: string }, - filters?: DashboardFilters, + filters?: DashboardFilters ) { - const isAdmin = adminSession.role === UserRole.ADMIN; - - // Build base where clauses for different queries with filters - const assignmentWhere: any = isAdmin - ? {} - : { - AssignmentAuthor: { - some: { - userId: adminSession.userId, - }, - }, - }; + const cacheKey = this.getCacheKey( + "dashboard", + adminSession.userId || "admin", + JSON.stringify(filters || {}) + ); - // Apply assignment filters - if (filters?.assignmentId) { - assignmentWhere.id = filters.assignmentId; - } - if (filters?.assignmentName) { - assignmentWhere.name = { - contains: filters.assignmentName, - mode: "insensitive", - }; - } + return this.redisService.getOrSet( + cacheKey, + async () => { + const isAdmin = adminSession.role === UserRole.ADMIN; + + const assignmentWhere: any = isAdmin + ? {} + : { + AssignmentAuthor: { + some: { + userId: adminSession.userId, + }, + }, + }; - // Build date filter for time-based queries - const dateFilter: any = {}; - if (filters?.startDate || filters?.endDate) { - if (filters.startDate) { - dateFilter.gte = new Date(filters.startDate); - } - if (filters.endDate) { - dateFilter.lte = new Date(filters.endDate); - } - } + if (filters?.assignmentId) { + assignmentWhere.id = filters.assignmentId; + } + if (filters?.assignmentName) { + assignmentWhere.name = { + contains: filters.assignmentName, + mode: "insensitive", + }; + } - // For non-admins, we need to get assignment IDs first for attempt/feedback queries - let assignmentIds: number[] = []; - if (!isAdmin) { - const assignments = await this.prisma.assignment.findMany({ - where: assignmentWhere, - select: { id: true }, - }); - assignmentIds = assignments.map((a) => a.id); - } else if (filters?.assignmentId || filters?.assignmentName) { - // For admins with assignment filters, also get the filtered assignment IDs - const assignments = await this.prisma.assignment.findMany({ - where: assignmentWhere, - select: { id: true }, - }); - assignmentIds = assignments.map((a) => a.id); - } + const dateFilter: any = {}; + if (filters?.startDate || filters?.endDate) { + if (filters.startDate) { + dateFilter.gte = new Date(filters.startDate); + } + if (filters.endDate) { + dateFilter.lte = new Date(filters.endDate); + } + } - // Optimized parallel queries - const [ - totalAssignments, - publishedAssignments, - attemptStats, - feedbackCount, - reportCounts, - recentAttempts, - learnerCount, - aiUsageStats, - averageAssignmentRating, - ] = await Promise.all([ - // Total assignments - this.prisma.assignment.count({ where: assignmentWhere }), - - // Published assignments - this.prisma.assignment.count({ - where: { ...assignmentWhere, published: true }, - }), - - // Attempt statistics (total attempts + unique users in one query) - isAdmin || assignmentIds.length > 0 - ? this.prisma.assignmentAttempt - .aggregate({ + let assignmentIds: number[] = []; + const hasDateFilter = filters?.startDate || filters?.endDate; + + if (!isAdmin) { + const assignments = await this.prisma.assignment.findMany({ + where: assignmentWhere, + select: { id: true }, + }); + assignmentIds = assignments.map((a) => a.id); + } else if (filters?.assignmentId || filters?.assignmentName || hasDateFilter) { + if (hasDateFilter) { + const attemptsInDateRange = await this.prisma.assignmentAttempt.groupBy({ + by: ["assignmentId"], where: { - ...(isAdmin ? {} : { assignmentId: { in: assignmentIds } }), - ...(assignmentIds.length > 0 && isAdmin - ? { assignmentId: { in: assignmentIds } } - : {}), - ...(Object.keys(dateFilter).length > 0 - ? { createdAt: dateFilter } - : {}), - ...(filters?.userId - ? { - userId: { contains: filters.userId, mode: "insensitive" }, - } - : {}), + ...(Object.keys(dateFilter).length > 0 ? { createdAt: dateFilter } : {}), }, - _count: { id: true }, - }) - .then(async (totalAttempts) => { - const uniqueUsers = await this.prisma.assignmentAttempt.groupBy({ - by: ["userId"], - where: { - ...(isAdmin ? {} : { assignmentId: { in: assignmentIds } }), - ...(assignmentIds.length > 0 && isAdmin - ? { assignmentId: { in: assignmentIds } } - : {}), - ...(Object.keys(dateFilter).length > 0 - ? { createdAt: dateFilter } - : {}), - ...(filters?.userId + }); + const assignmentIdsWithActivity = attemptsInDateRange.map((a) => a.assignmentId); + + const assignments = await this.prisma.assignment.findMany({ + where: { + ...assignmentWhere, + id: { in: assignmentIdsWithActivity }, + }, + select: { id: true }, + }); + assignmentIds = assignments.map((a) => a.id); + } else { + const assignments = await this.prisma.assignment.findMany({ + where: assignmentWhere, + select: { id: true }, + }); + assignmentIds = assignments.map((a) => a.id); + } + } + + const [ + totalAssignments, + publishedAssignments, + attemptStats, + feedbackCount, + reportCounts, + recentAttempts, + learnerCount, + aiUsageStats, + averageAssignmentRating, + ] = await Promise.all([ + hasDateFilter && assignmentIds.length >= 0 + ? Promise.resolve(assignmentIds.length) + : this.prisma.assignment.count({ where: assignmentWhere }), + + hasDateFilter && assignmentIds.length >= 0 + ? this.prisma.assignment.count({ + where: { id: { in: assignmentIds }, published: true }, + }) + : this.prisma.assignment.count({ + where: { ...assignmentWhere, published: true }, + }), + + isAdmin || assignmentIds.length > 0 + ? this.prisma.assignmentAttempt + .aggregate({ + where: { + ...(isAdmin ? {} : { assignmentId: { in: assignmentIds } }), + ...(assignmentIds.length > 0 && isAdmin + ? { assignmentId: { in: assignmentIds } } + : {}), + ...(Object.keys(dateFilter).length > 0 + ? { createdAt: dateFilter } + : {}), + ...(filters?.userId + ? { + userId: { + contains: filters.userId, + mode: "insensitive", + }, + } + : {}), + }, + _count: { id: true }, + }) + .then(async (totalAttempts) => { + const uniqueUsers = + await this.prisma.assignmentAttempt.groupBy({ + by: ["userId"], + where: { + ...(isAdmin + ? {} + : { assignmentId: { in: assignmentIds } }), + ...(assignmentIds.length > 0 && isAdmin + ? { assignmentId: { in: assignmentIds } } + : {}), + ...(Object.keys(dateFilter).length > 0 + ? { createdAt: dateFilter } + : {}), + ...(filters?.userId + ? { + userId: { + contains: filters.userId, + mode: "insensitive", + }, + } + : {}), + }, + }); + return { + totalAttempts: totalAttempts._count.id, + totalUsers: uniqueUsers.length, + }; + }) + : Promise.resolve({ totalAttempts: 0, totalUsers: 0 }), + + isAdmin || assignmentIds.length > 0 + ? this.prisma.assignmentFeedback.count({ + where: { + ...(isAdmin ? {} : { assignmentId: { in: assignmentIds } }), + ...(assignmentIds.length > 0 && isAdmin + ? { assignmentId: { in: assignmentIds } } + : {}), + ...(Object.keys(dateFilter).length > 0 + ? { createdAt: dateFilter } + : {}), + ...(filters?.userId ? { userId: { contains: filters.userId, @@ -1384,52 +1474,55 @@ export class AdminService { } : {}), }, - }); - return { - totalAttempts: totalAttempts._count.id, - totalUsers: uniqueUsers.length, - }; - }) - : Promise.resolve({ totalAttempts: 0, totalUsers: 0 }), + }) + : 0, - // Total feedback - isAdmin || assignmentIds.length > 0 - ? this.prisma.assignmentFeedback.count({ - where: { - ...(isAdmin ? {} : { assignmentId: { in: assignmentIds } }), - ...(assignmentIds.length > 0 && isAdmin - ? { assignmentId: { in: assignmentIds } } - : {}), - ...(Object.keys(dateFilter).length > 0 - ? { createdAt: dateFilter } - : {}), - ...(filters?.userId - ? { userId: { contains: filters.userId, mode: "insensitive" } } - : {}), - }, - }) - : 0, + isAdmin + ? this.prisma.report + .aggregate({ + _count: { id: true }, + where: { + ...(Object.keys(dateFilter).length > 0 + ? { createdAt: dateFilter } + : {}), + ...(filters?.userId + ? { + userId: { + contains: filters.userId, + mode: "insensitive", + }, + } + : {}), + }, + }) + .then(async (total) => { + const open = await this.prisma.report.count({ + where: { + status: "OPEN", + ...(Object.keys(dateFilter).length > 0 + ? { createdAt: dateFilter } + : {}), + ...(filters?.userId + ? { + userId: { + contains: filters.userId, + mode: "insensitive", + }, + } + : {}), + }, + }); + return { totalReports: total._count.id, openReports: open }; + }) + : { totalReports: 0, openReports: 0 }, - // Report counts (admin only) - isAdmin - ? this.prisma.report - .aggregate({ - _count: { id: true }, - where: { - ...(Object.keys(dateFilter).length > 0 - ? { createdAt: dateFilter } - : {}), - ...(filters?.userId - ? { - userId: { contains: filters.userId, mode: "insensitive" }, - } - : {}), - }, - }) - .then(async (total) => { - const open = await this.prisma.report.count({ + isAdmin || assignmentIds.length > 0 + ? this.prisma.assignmentAttempt.findMany({ where: { - status: "OPEN", + ...(isAdmin ? {} : { assignmentId: { in: assignmentIds } }), + ...(assignmentIds.length > 0 && isAdmin + ? { assignmentId: { in: assignmentIds } } + : {}), ...(Object.keys(dateFilter).length > 0 ? { createdAt: dateFilter } : {}), @@ -1442,307 +1535,285 @@ export class AdminService { } : {}), }, - }); - return { totalReports: total._count.id, openReports: open }; - }) - : { totalReports: 0, openReports: 0 }, - - // Recent activity with assignment names in one query - isAdmin || assignmentIds.length > 0 - ? this.prisma.assignmentAttempt.findMany({ - where: { - ...(isAdmin ? {} : { assignmentId: { in: assignmentIds } }), - ...(assignmentIds.length > 0 && isAdmin - ? { assignmentId: { in: assignmentIds } } - : {}), - ...(Object.keys(dateFilter).length > 0 - ? { createdAt: dateFilter } - : {}), - ...(filters?.userId - ? { userId: { contains: filters.userId, mode: "insensitive" } } - : {}), - }, - take: 10, - orderBy: { createdAt: "desc" }, - select: { - id: true, - userId: true, - submitted: true, - grade: true, - createdAt: true, - assignmentId: true, - }, - }) - : [], + take: 10, + orderBy: { createdAt: "desc" }, + select: { + id: true, + userId: true, + submitted: true, + grade: true, + createdAt: true, + assignmentId: true, + }, + }) + : [], - // total number of unique leaners - isAdmin || assignmentIds.length > 0 - ? this.prisma.assignmentAttempt - .groupBy({ - by: ["userId"], - where: { - ...(isAdmin ? {} : { assignmentId: { in: assignmentIds } }), - ...(assignmentIds.length > 0 && isAdmin - ? { assignmentId: { in: assignmentIds } } - : {}), - ...(Object.keys(dateFilter).length > 0 - ? { createdAt: dateFilter } - : {}), - ...(filters?.userId - ? { - userId: { contains: filters.userId, mode: "insensitive" }, - } - : {}), - }, - }) - .then((users) => users.length) - : 0, + isAdmin || assignmentIds.length > 0 + ? this.prisma.assignmentAttempt + .groupBy({ + by: ["userId"], + where: { + ...(isAdmin ? {} : { assignmentId: { in: assignmentIds } }), + ...(assignmentIds.length > 0 && isAdmin + ? { assignmentId: { in: assignmentIds } } + : {}), + ...(Object.keys(dateFilter).length > 0 + ? { createdAt: dateFilter } + : {}), + ...(filters?.userId + ? { + userId: { + contains: filters.userId, + mode: "insensitive", + }, + } + : {}), + }, + }) + .then((users) => users.length) + : 0, - // AI usage data for cost calculation - isAdmin || assignmentIds.length > 0 - ? this.prisma.aIUsage.findMany({ - where: { - ...(isAdmin - ? {} - : { - assignment: { - AssignmentAuthor: { - some: { userId: adminSession.userId }, - }, - }, - }), - ...(assignmentIds.length > 0 && isAdmin - ? { assignmentId: { in: assignmentIds } } - : {}), - ...(Object.keys(dateFilter).length > 0 - ? { createdAt: dateFilter } - : {}), - }, - select: { - tokensIn: true, - tokensOut: true, - createdAt: true, - usageType: true, - modelKey: true, - }, - }) - : [], + isAdmin || assignmentIds.length > 0 + ? this.prisma.aIUsage.findMany({ + where: { + ...(isAdmin + ? {} + : { + assignment: { + AssignmentAuthor: { + some: { userId: adminSession.userId }, + }, + }, + }), + ...(assignmentIds.length > 0 && isAdmin + ? { assignmentId: { in: assignmentIds } } + : {}), + ...(Object.keys(dateFilter).length > 0 + ? { createdAt: dateFilter } + : {}), + }, + select: { + tokensIn: true, + tokensOut: true, + createdAt: true, + usageType: true, + modelKey: true, + }, + }) + : [], - // Average Assignment Rating for all assignments - isAdmin || assignmentIds.length > 0 - ? this.prisma.assignmentFeedback.aggregate({ - where: { - ...(isAdmin ? {} : { assignmentId: { in: assignmentIds } }), - ...(assignmentIds.length > 0 && isAdmin - ? { assignmentId: { in: assignmentIds } } - : {}), - ...(Object.keys(dateFilter).length > 0 - ? { createdAt: dateFilter } - : {}), - ...(filters?.userId - ? { userId: { contains: filters.userId, mode: "insensitive" } } - : {}), - }, - _avg: { assignmentRating: true }, - }) - : { _avg: { assignmentRating: 0 } }, - ]); - - // Calculate total cost using historical pricing - const costData = await this.calculateHistoricalCosts(aiUsageStats); - const totalCost = costData.totalCost; - - // Get assignment names for recent attempts - const assignmentNames = new Map(); - if (recentAttempts.length > 0) { - const uniqueAssignmentIds = [ - ...new Set(recentAttempts.map((a: any) => a.assignmentId)), - ]; - const assignments = await this.prisma.assignment.findMany({ - where: { id: { in: uniqueAssignmentIds } }, - select: { id: true, name: true }, - }); - for (const assignment of assignments) { - assignmentNames.set(assignment.id, assignment.name); - } - } + isAdmin || assignmentIds.length > 0 + ? this.prisma.assignmentFeedback.aggregate({ + where: { + ...(isAdmin ? {} : { assignmentId: { in: assignmentIds } }), + ...(assignmentIds.length > 0 && isAdmin + ? { assignmentId: { in: assignmentIds } } + : {}), + ...(Object.keys(dateFilter).length > 0 + ? { createdAt: dateFilter } + : {}), + ...(filters?.userId + ? { + userId: { + contains: filters.userId, + mode: "insensitive", + }, + } + : {}), + }, + _avg: { assignmentRating: true }, + }) + : { _avg: { assignmentRating: 0 } }, + ]); - return { - totalAssignments, - publishedAssignments, - totalReports: reportCounts.totalReports, - openReports: reportCounts.openReports, - totalFeedback: feedbackCount, - totalLearners: learnerCount, - totalAttempts: attemptStats.totalAttempts, - totalUsers: attemptStats.totalUsers, - averageAssignmentRating: - averageAssignmentRating._avg.assignmentRating || 0, - totalCost: Math.round(totalCost * 100) / 100, - costBreakdown: { - grading: Math.round(costData.costBreakdown.grading * 100) / 100, - questionGeneration: - Math.round(costData.costBreakdown.questionGeneration * 100) / 100, - translation: Math.round(costData.costBreakdown.translation * 100) / 100, - other: Math.round(costData.costBreakdown.other * 100) / 100, + const costData = await this.calculateHistoricalCosts(aiUsageStats); + const totalCost = costData.totalCost; + + const assignmentNames = new Map(); + if (recentAttempts.length > 0) { + const uniqueAssignmentIds = [ + ...new Set(recentAttempts.map((a: any) => a.assignmentId)), + ]; + const assignments = await this.prisma.assignment.findMany({ + where: { id: { in: uniqueAssignmentIds } }, + select: { id: true, name: true }, + }); + for (const assignment of assignments) { + assignmentNames.set(assignment.id, assignment.name); + } + } + + return { + totalAssignments, + publishedAssignments, + totalReports: reportCounts.totalReports, + openReports: reportCounts.openReports, + totalFeedback: feedbackCount, + totalLearners: learnerCount, + totalAttempts: attemptStats.totalAttempts, + totalUsers: attemptStats.totalUsers, + averageAssignmentRating: + averageAssignmentRating._avg.assignmentRating || 0, + totalCost: Math.round(totalCost * 100) / 100, + costBreakdown: { + grading: Math.round(costData.costBreakdown.grading * 100) / 100, + questionGeneration: + Math.round(costData.costBreakdown.questionGeneration * 100) / 100, + translation: + Math.round(costData.costBreakdown.translation * 100) / 100, + other: Math.round(costData.costBreakdown.other * 100) / 100, + }, + userRole: isAdmin ? ("admin" as const) : ("author" as const), + recentActivity: recentAttempts.map((attempt: any) => ({ + id: attempt.id, + assignmentName: + assignmentNames.get(attempt.assignmentId) ?? "Unknown", + userId: attempt.userId, + submitted: attempt.submitted, + grade: attempt.grade, + createdAt: attempt.createdAt, + })), + }; }, - userRole: isAdmin ? ("admin" as const) : ("author" as const), - recentActivity: recentAttempts.map((attempt: any) => ({ - id: attempt.id, - assignmentName: assignmentNames.get(attempt.assignmentId) ?? "Unknown", - userId: attempt.userId, - submitted: attempt.submitted, - grade: attempt.grade, - createdAt: attempt.createdAt, - })), - }; + { ttl: this.DASHBOARD_CACHE_TTL } + ); } async getDetailedAssignmentInsights( adminSession: UserSession, assignmentId: number, + details?: boolean ) { - try { - // Check cache first - const cachedInsights = this.getCachedInsights(assignmentId); - if (cachedInsights) { - return cachedInsights; - } - // Validate assignmentId - if (!assignmentId || assignmentId <= 0) { - throw new Error(`Invalid assignment ID: ${assignmentId}`); - } + const cacheKey = this.getCacheKey("insights", assignmentId, details ? 'detailed' : 'basic'); - const isAdmin = adminSession.role === UserRole.ADMIN; + return this.redisService.getOrSet( + cacheKey, + async () => { + try { + if (!assignmentId || assignmentId <= 0) { + throw new Error(`Invalid assignment ID: ${assignmentId}`); + } - // Check if user has access to this assignment - const assignment = await this.prisma.assignment.findFirst({ - where: { - id: assignmentId, - ...(isAdmin - ? {} - : { - AssignmentAuthor: { - some: { - userId: adminSession.userId, - }, - }, - }), - }, - include: { - questions: { - where: { isDeleted: false }, + const isAdmin = adminSession.role === UserRole.ADMIN; + + const assignment = await this.prisma.assignment.findFirst({ + where: { + id: assignmentId, + ...(isAdmin + ? {} + : { + AssignmentAuthor: { + some: { + userId: adminSession.userId, + }, + }, + }), + }, include: { - translations: true, - variants: { + questions: { where: { isDeleted: false }, + include: { + translations: true, + variants: { + where: { isDeleted: false }, + }, + }, }, + AIUsage: true, + AssignmentFeedback: true, + Report: true, + AssignmentAuthor: true, }, - }, - AIUsage: true, - AssignmentFeedback: true, - Report: true, - AssignmentAuthor: true, - }, - }); - - if (!assignment) { - throw new NotFoundException( - `Assignment with ID ${assignmentId} not found or access denied`, - ); - } + }); - // Get assignment attempts count and basic stats with error handling - let totalAttempts = 0; - let submittedAttempts = 0; - let calculatedAverageGrade = 0; + if (!assignment) { + throw new NotFoundException( + `Assignment with ID ${assignmentId} not found or access denied` + ); + } - try { - // Get simple counts first - totalAttempts = await this.prisma.assignmentAttempt.count({ - where: { assignmentId }, - }); + let totalAttempts = 0; + let submittedAttempts = 0; + let calculatedAverageGrade = 0; - submittedAttempts = await this.prisma.assignmentAttempt.count({ - where: { assignmentId, submitted: true }, - }); + try { + totalAttempts = await this.prisma.assignmentAttempt.count({ + where: { assignmentId }, + }); - // Get average grade with simple aggregation - const gradeAvg = await this.prisma.assignmentAttempt.aggregate({ - where: { assignmentId, submitted: true }, - _avg: { grade: true }, - }); - calculatedAverageGrade = (gradeAvg._avg.grade || 0) * 100; // Convert to percentage - } catch (error) { - this.logger.error( - `Error fetching attempt statistics for assignment ${assignmentId}:`, - error, - ); - // Use fallback values (already initialized above) - } + submittedAttempts = await this.prisma.assignmentAttempt.count({ + where: { assignmentId, submitted: true }, + }); - // Process question insights in batches to prevent connection pool exhaustion - const questionInsights = []; - const batchSize = 3; // Process 3 questions at a time to avoid connection pool issues + const gradeAvg = await this.prisma.assignmentAttempt.aggregate({ + where: { assignmentId, submitted: true }, + _avg: { grade: true }, + }); + calculatedAverageGrade = (gradeAvg._avg.grade || 0) * 100; + } catch (error) { + this.logger.error( + `Error fetching attempt statistics for assignment ${assignmentId}:`, + error + ); + } - for ( - let index = 0; - index < assignment.questions.length; - index += batchSize - ) { - const batch = assignment.questions.slice(index, index + batchSize); + // Batched aggregation: get all question stats only if details requested + let questionInsights = []; - try { - const batchResults = await Promise.all( - batch.map(async (question) => { - let totalResponses = 0; - let correctCount = 0; - let averagePoints = 0; - - try { - // Get total response count for this question - totalResponses = await this.prisma.questionResponse.count({ - where: { - questionId: question.id, - assignmentAttempt: { assignmentId }, - }, - }); + if (details) { + try { + const questionIds = assignment.questions.map(q => q.id); - if (totalResponses > 0) { - // Get count of correct responses using aggregate - correctCount = await this.prisma.questionResponse.count({ - where: { - questionId: question.id, - assignmentAttempt: { assignmentId }, - points: question.totalPoints, - }, - }); + // Single query to get total responses and average points for all questions + const responseStats = await this.prisma.questionResponse.groupBy({ + by: ['questionId'], + where: { + questionId: { in: questionIds }, + assignmentAttempt: { assignmentId }, + }, + _count: { id: true }, + _avg: { points: true }, + }); - // Get average points using aggregate - const pointsAvg = - await this.prisma.questionResponse.aggregate({ - where: { - questionId: question.id, - assignmentAttempt: { assignmentId }, - }, - _avg: { points: true }, - }); - averagePoints = pointsAvg._avg.points || 0; + // Build a map of questionId -> stats for quick lookup + const statsMap = new Map( + responseStats.map(stat => [ + stat.questionId, + { + totalResponses: stat._count.id, + averagePoints: stat._avg.points || 0, } - } catch (error) { - this.logger.error( - `Error fetching response statistics for question ${question.id}:`, - error, - ); - // Use fallback values (already initialized above) - } + ]) + ); + + // Get correct counts: fetch responses where points = question.totalPoints + // Build OR conditions for each question + const correctCountQueries = assignment.questions.map(question => ({ + questionId: question.id, + points: question.totalPoints, + assignmentAttempt: { assignmentId }, + })); + + const correctCounts = await Promise.all( + correctCountQueries.map(where => + this.prisma.questionResponse.count({ where }) + ) + ); + + const correctCountMap = new Map( + assignment.questions.map((q, index) => [q.id, correctCounts[index]]) + ); + + // Build question insights from aggregated data + questionInsights = assignment.questions.map(question => { + const stats = statsMap.get(question.id) || { totalResponses: 0, averagePoints: 0 }; + const correctCount = correctCountMap.get(question.id) || 0; + const totalResponses = stats.totalResponses; const correctPercentage = totalResponses > 0 ? (correctCount / totalResponses) * 100 : 0; - let insight = `${Math.round( - correctPercentage, - )}% of learners answered correctly`; + let insight = `${Math.round(correctPercentage)}% of learners answered correctly`; if (correctPercentage < 50) { insight += ` - consider reviewing this question`; } @@ -1753,7 +1824,7 @@ export class AdminService { type: question.type, totalPoints: question.totalPoints, correctPercentage, - averagePoints, + averagePoints: stats.averagePoints, responseCount: totalResponses, insight, variants: question.variants.length, @@ -1761,306 +1832,298 @@ export class AdminService { languageCode: t.languageCode, })), }; - }), - ); - questionInsights.push(...batchResults); - - // Add a small delay between batches to prevent connection pool exhaustion - if (index + batchSize < assignment.questions.length) { - await new Promise((resolve) => setTimeout(resolve, 100)); - } - } catch (error) { - this.logger.error( - `Error processing question batch starting at index ${index}:`, - error, - ); - // Continue with empty results for this batch to prevent total failure - const fallbackResults = batch.map((question) => ({ - id: question.id, - question: question.question, - type: question.type, - totalPoints: question.totalPoints, - correctPercentage: 0, - averagePoints: 0, - responseCount: 0, - insight: "Data unavailable due to processing error", - variants: question.variants?.length || 0, - translations: - question.translations?.map((t) => ({ + }); + } catch (error) { + this.logger.error( + `Error fetching batched question statistics for assignment ${assignmentId}:`, + error + ); + // Fallback to empty stats on error + questionInsights = assignment.questions.map(question => ({ + id: question.id, + question: question.question, + type: question.type, + totalPoints: question.totalPoints, + correctPercentage: 0, + averagePoints: 0, + responseCount: 0, + insight: "Data unavailable due to processing error", + variants: question.variants?.length || 0, + translations: question.translations?.map((t) => ({ languageCode: t.languageCode, })) || [], - })); - questionInsights.push(...fallbackResults); - } - } - - // Calculate analytics - const uniqueLearners = await this.prisma.assignmentAttempt.groupBy({ - by: ["userId"], - where: { assignmentId }, - }); + })); + } + } - // Use the calculated stats instead of processing attempts array - const completedAttempts = submittedAttempts; - const averageGrade = calculatedAverageGrade; - - // Calculate total cost using historical pricing - const aiUsageRecords = assignment.AIUsage.map((usage) => ({ - tokensIn: usage.tokensIn, - tokensOut: usage.tokensOut, - createdAt: usage.createdAt, - usageType: usage.usageType, - modelKey: usage.modelKey, - })); + const uniqueLearners = await this.prisma.assignmentAttempt.groupBy({ + by: ["userId"], + where: { assignmentId }, + }); - const costData = await this.calculateHistoricalCosts(aiUsageRecords); - const totalCost = costData.totalCost; + const completedAttempts = submittedAttempts; + const averageGrade = calculatedAverageGrade; - // Get author information and activity analysis - const authorActivity = await this.getAuthorActivity( - assignment.AssignmentAuthor, - ); + const aiUsageRecords = assignment.AIUsage.map((usage) => ({ + tokensIn: usage.tokensIn, + tokensOut: usage.tokensOut, + createdAt: usage.createdAt, + usageType: usage.usageType, + modelKey: usage.modelKey, + })); - const aiUsageWithCost = assignment.AIUsage.map((usage, index) => { - const detailedCost = costData.detailedBreakdown[index] || { - totalCost: 0, - inputCost: 0, - outputCost: 0, - modelKey: "unknown", - inputTokenPrice: 0, - outputTokenPrice: 0, - pricingEffectiveDate: new Date(), - calculationSteps: { - inputCalculation: "0 tokens × $0 = $0 (missing)", - outputCalculation: "0 tokens × $0 = $0 (missing)", - totalCalculation: "$0 + $0 = $0 (missing)", - }, - }; + const costData = await this.calculateHistoricalCosts(aiUsageRecords); + const totalCost = costData.totalCost; - return { - usageType: usage.usageType, - tokensIn: usage.tokensIn, - tokensOut: usage.tokensOut, - usageCount: usage.usageCount, - inputCost: detailedCost.inputCost, - outputCost: detailedCost.outputCost, - totalCost: detailedCost.totalCost, - modelUsed: detailedCost.modelKey, - inputTokenPrice: detailedCost.inputTokenPrice, - outputTokenPrice: detailedCost.outputTokenPrice, - pricingEffectiveDate: detailedCost.pricingEffectiveDate.toISOString(), - calculationSteps: detailedCost.calculationSteps, - createdAt: usage.createdAt.toISOString(), - }; - }); + const authorActivity = await this.getAuthorActivity( + assignment.AssignmentAuthor + ); - // Calculate average rating - const ratings = assignment.AssignmentFeedback.map( - (f) => f.assignmentRating, - ).filter((r) => r !== null); - const averageRating = - ratings.length > 0 - ? ratings.reduce((sum, r) => sum + r, 0) / ratings.length - : 0; - - // Calculate total points for assignment - const totalPoints = assignment.questions.reduce( - (sum, q) => sum + q.totalPoints, - 0, - ); + const aiUsageWithCost = assignment.AIUsage.map((usage, index) => { + const detailedCost = costData.detailedBreakdown[index] || { + totalCost: 0, + inputCost: 0, + outputCost: 0, + modelKey: "unknown", + inputTokenPrice: 0, + outputTokenPrice: 0, + pricingEffectiveDate: new Date(), + calculationSteps: { + inputCalculation: "0 tokens × $0 = $0 (missing)", + outputCalculation: "0 tokens × $0 = $0 (missing)", + totalCalculation: "$0 + $0 = $0 (missing)", + }, + }; + + return { + usageType: usage.usageType, + tokensIn: usage.tokensIn, + tokensOut: usage.tokensOut, + usageCount: usage.usageCount, + inputCost: detailedCost.inputCost, + outputCost: detailedCost.outputCost, + totalCost: detailedCost.totalCost, + modelUsed: detailedCost.modelKey, + inputTokenPrice: detailedCost.inputTokenPrice, + outputTokenPrice: detailedCost.outputTokenPrice, + pricingEffectiveDate: + detailedCost.pricingEffectiveDate.toISOString(), + calculationSteps: detailedCost.calculationSteps, + createdAt: usage.createdAt.toISOString(), + }; + }); + + const ratings = assignment.AssignmentFeedback.map( + (f) => f.assignmentRating + ).filter((r) => r !== null); + const averageRating = + ratings.length > 0 + ? ratings.reduce((sum, r) => sum + r, 0) / ratings.length + : 0; + + const totalPoints = assignment.questions.reduce( + (sum, q) => sum + q.totalPoints, + 0 + ); - // Cost breakdown from historical pricing data - const costBreakdown = { - grading: Math.round(costData.costBreakdown.grading * 100) / 100, - questionGeneration: - Math.round(costData.costBreakdown.questionGeneration * 100) / 100, - translation: Math.round(costData.costBreakdown.translation * 100) / 100, - other: Math.round(costData.costBreakdown.other * 100) / 100, - }; + const costBreakdown = { + grading: Math.round(costData.costBreakdown.grading * 100) / 100, + questionGeneration: + Math.round(costData.costBreakdown.questionGeneration * 100) / 100, + translation: + Math.round(costData.costBreakdown.translation * 100) / 100, + other: Math.round(costData.costBreakdown.other * 100) / 100, + }; + + const performanceInsights: string[] = []; + if (completedAttempts > 0 && totalAttempts > 0) { + const completionRate = (completedAttempts / totalAttempts) * 100; + if (completionRate < 70) { + performanceInsights.push( + `Low completion rate (${Math.round( + completionRate + )}%) - consider reducing difficulty` + ); + } + if (averageGrade > 85) { + performanceInsights.push( + `High average grade (${Math.round( + averageGrade + )}%) - learners are doing well` + ); + } + if (averageGrade < 60) { + performanceInsights.push( + `Low average grade (${Math.round( + averageGrade + )}%) - may need clearer instructions` + ); + } + } - // Generate performance insights - const performanceInsights: string[] = []; - if (completedAttempts > 0 && totalAttempts > 0) { - const completionRate = (completedAttempts / totalAttempts) * 100; - if (completionRate < 70) { - performanceInsights.push( - `Low completion rate (${Math.round( - completionRate, - )}%) - consider reducing difficulty`, - ); - } - if (averageGrade > 85) { - performanceInsights.push( - `High average grade (${Math.round( - averageGrade, - )}%) - learners are doing well`, - ); - } - if (averageGrade < 60) { - performanceInsights.push( - `Low average grade (${Math.round( + const insights = { + assignment: { + id: assignment.id, + name: assignment.name, + type: assignment.type, + published: assignment.published, + introduction: assignment.introduction, + instructions: assignment.instructions, + timeEstimateMinutes: assignment.timeEstimateMinutes, + allotedTimeMinutes: assignment.allotedTimeMinutes, + passingGrade: assignment.passingGrade, + createdAt: assignment.updatedAt.toISOString(), + updatedAt: assignment.updatedAt.toISOString(), + totalPoints, + }, + analytics: { + totalCost, + uniqueLearners: uniqueLearners.length, + totalAttempts, + completedAttempts, averageGrade, - )}%) - may need clearer instructions`, - ); - } - } - - const insights = { - assignment: { - id: assignment.id, - name: assignment.name, - type: assignment.type, - published: assignment.published, - introduction: assignment.introduction, - instructions: assignment.instructions, - timeEstimateMinutes: assignment.timeEstimateMinutes, - allotedTimeMinutes: assignment.allotedTimeMinutes, - passingGrade: assignment.passingGrade, - createdAt: assignment.updatedAt.toISOString(), - updatedAt: assignment.updatedAt.toISOString(), - totalPoints, - }, - analytics: { - totalCost, - uniqueLearners: uniqueLearners.length, - totalAttempts, - completedAttempts, - averageGrade, - averageRating, - costBreakdown, - performanceInsights, - }, - questions: questionInsights, - attempts: await this.getAssignmentAttempts(assignmentId), - feedback: assignment.AssignmentFeedback.map((feedback) => ({ - id: feedback.id, - userId: feedback.userId, - assignmentRating: feedback.assignmentRating, - aiGradingRating: feedback.aiGradingRating, - aiFeedbackRating: feedback.aiFeedbackRating, - comments: feedback.comments, - createdAt: feedback.createdAt.toISOString(), - })), - reports: assignment.Report.map((report) => ({ - id: report.id, - issueType: report.issueType, - description: report.description, - status: report.status, - createdAt: report.createdAt.toISOString(), - })), - aiUsage: aiUsageWithCost, - costCalculationDetails: { - totalCost: Math.round(totalCost * 100) / 100, - breakdown: costData.detailedBreakdown.map((detail) => ({ - usageType: detail.usageType || "Unknown", - tokensIn: detail.tokensIn, - tokensOut: detail.tokensOut, - modelUsed: detail.modelKey, - inputTokenPrice: detail.inputTokenPrice, - outputTokenPrice: detail.outputTokenPrice, - inputCost: Math.round(detail.inputCost * 100_000_000) / 100_000_000, // 8 decimal places - outputCost: - Math.round(detail.outputCost * 100_000_000) / 100_000_000, - totalCost: Math.round(detail.totalCost * 100_000_000) / 100_000_000, - pricingEffectiveDate: detail.pricingEffectiveDate.toISOString(), - usageDate: detail.usageDate.toISOString(), - calculationSteps: detail.calculationSteps, - })), - summary: { - totalInputTokens: costData.detailedBreakdown.reduce( - (sum, d) => sum + d.tokensIn, - 0, - ), - totalOutputTokens: costData.detailedBreakdown.reduce( - (sum, d) => sum + d.tokensOut, - 0, - ), - totalInputCost: - Math.round( - costData.detailedBreakdown.reduce( - (sum, d) => sum + d.inputCost, - 0, - ) * 100_000_000, - ) / 100_000_000, - totalOutputCost: - Math.round( - costData.detailedBreakdown.reduce( - (sum, d) => sum + d.outputCost, - 0, - ) * 100_000_000, - ) / 100_000_000, - averageInputPrice: - costData.detailedBreakdown.length > 0 - ? costData.detailedBreakdown.reduce( - (sum, d) => sum + d.inputTokenPrice, - 0, - ) / costData.detailedBreakdown.length - : 0, - averageOutputPrice: - costData.detailedBreakdown.length > 0 - ? costData.detailedBreakdown.reduce( - (sum, d) => sum + d.outputTokenPrice, - 0, - ) / costData.detailedBreakdown.length - : 0, - // eslint-disable-next-line unicorn/no-array-reduce - modelDistribution: costData.detailedBreakdown.reduce( - (accumulator: Record, detail) => { - accumulator[detail.modelKey] = - (accumulator[detail.modelKey] || 0) + detail.totalCost; - return accumulator; + averageRating, + costBreakdown, + performanceInsights, + }, + questions: questionInsights, + attempts: await this.getAssignmentAttempts(assignmentId), + feedback: assignment.AssignmentFeedback.map((feedback) => ({ + id: feedback.id, + userId: feedback.userId, + assignmentRating: feedback.assignmentRating, + aiGradingRating: feedback.aiGradingRating, + aiFeedbackRating: feedback.aiFeedbackRating, + comments: feedback.comments, + createdAt: feedback.createdAt.toISOString(), + })), + reports: assignment.Report.map((report) => ({ + id: report.id, + issueType: report.issueType, + description: report.description, + status: report.status, + createdAt: report.createdAt.toISOString(), + })), + ...(details && { + aiUsage: aiUsageWithCost, + costCalculationDetails: { + totalCost: Math.round(totalCost * 100) / 100, + breakdown: costData.detailedBreakdown.map((detail) => ({ + usageType: detail.usageType || "Unknown", + tokensIn: detail.tokensIn, + tokensOut: detail.tokensOut, + modelUsed: detail.modelKey, + inputTokenPrice: detail.inputTokenPrice, + outputTokenPrice: detail.outputTokenPrice, + inputCost: + Math.round(detail.inputCost * 100_000_000) / 100_000_000, + outputCost: + Math.round(detail.outputCost * 100_000_000) / 100_000_000, + totalCost: + Math.round(detail.totalCost * 100_000_000) / 100_000_000, + pricingEffectiveDate: detail.pricingEffectiveDate.toISOString(), + usageDate: detail.usageDate.toISOString(), + calculationSteps: detail.calculationSteps, + })), + summary: { + totalInputTokens: costData.detailedBreakdown.reduce( + (sum, d) => sum + d.tokensIn, + 0 + ), + totalOutputTokens: costData.detailedBreakdown.reduce( + (sum, d) => sum + d.tokensOut, + 0 + ), + totalInputCost: + Math.round( + costData.detailedBreakdown.reduce( + (sum, d) => sum + d.inputCost, + 0 + ) * 100_000_000 + ) / 100_000_000, + totalOutputCost: + Math.round( + costData.detailedBreakdown.reduce( + (sum, d) => sum + d.outputCost, + 0 + ) * 100_000_000 + ) / 100_000_000, + averageInputPrice: + costData.detailedBreakdown.length > 0 + ? costData.detailedBreakdown.reduce( + (sum, d) => sum + d.inputTokenPrice, + 0 + ) / costData.detailedBreakdown.length + : 0, + averageOutputPrice: + costData.detailedBreakdown.length > 0 + ? costData.detailedBreakdown.reduce( + (sum, d) => sum + d.outputTokenPrice, + 0 + ) / costData.detailedBreakdown.length + : 0, + // eslint-disable-next-line unicorn/no-array-reduce + modelDistribution: costData.detailedBreakdown.reduce( + (accumulator: Record, detail) => { + accumulator[detail.modelKey] = + (accumulator[detail.modelKey] || 0) + detail.totalCost; + return accumulator; + }, + {} as Record + ), + usageTypeDistribution: { + grading: + Math.round(costData.costBreakdown.grading * 100) / 100, + questionGeneration: + Math.round( + costData.costBreakdown.questionGeneration * 100 + ) / 100, + translation: + Math.round(costData.costBreakdown.translation * 100) / 100, + other: Math.round(costData.costBreakdown.other * 100) / 100, + }, }, - {} as Record, - ), - usageTypeDistribution: { - grading: Math.round(costData.costBreakdown.grading * 100) / 100, - questionGeneration: - Math.round(costData.costBreakdown.questionGeneration * 100) / - 100, - translation: - Math.round(costData.costBreakdown.translation * 100) / 100, - other: Math.round(costData.costBreakdown.other * 100) / 100, + }, + }), + authorActivity: { + totalAuthors: authorActivity.totalAuthors, + authors: authorActivity.authors, + activityInsights: authorActivity.activityInsights, }, - }, - }, - authorActivity: { - totalAuthors: authorActivity.totalAuthors, - authors: authorActivity.authors, - activityInsights: authorActivity.activityInsights, - }, - }; + }; - // Cache the result before returning - this.setCachedInsights(assignmentId, insights); - - return insights; - } catch (error) { - this.logger.error( - `Error getting detailed assignment insights for assignment ${assignmentId}:`, - error, - ); + return insights; + } catch (error) { + this.logger.error( + `Error getting detailed assignment insights for assignment ${assignmentId}:`, + error + ); - // Return a safe fallback response - return { - insights: { - questionInsights: [], - performanceInsights: [ - "Unable to load detailed insights due to a data processing error. Please try again later.", - ], - costBreakdown: { - grading: 0, - questionGeneration: 0, - translation: 0, - other: 0, - }, - }, - authorActivity: { - totalAuthors: 0, - authors: [], - activityInsights: ["Author activity data is currently unavailable."], - }, - }; - } + return { + insights: { + questionInsights: [], + performanceInsights: [ + "Unable to load detailed insights due to a data processing error. Please try again later.", + ], + costBreakdown: { + grading: 0, + questionGeneration: 0, + translation: 0, + other: 0, + }, + }, + authorActivity: { + totalAuthors: 0, + authors: [], + activityInsights: [ + "Author activity data is currently unavailable.", + ], + }, + }; + } + }, + { ttl: this.INSIGHTS_CACHE_TTL } + ); } async removeAssignment(id: number): Promise { @@ -2117,6 +2180,8 @@ export class AdminService { where: { id }, }); + await this.invalidateAssignmentInsightsCache(id); + return { id: id, success: true, @@ -2128,72 +2193,100 @@ export class AdminService { async executeQuickAction( adminSession: { email: string; role: UserRole; userId?: string }, action: string, - limit = 10, + limit = 10 ) { - const isAdmin = adminSession.role === UserRole.ADMIN; - - // Build base where clauses for different queries - const assignmentWhere: any = isAdmin - ? {} - : { - AssignmentAuthor: { - some: { - userId: adminSession.userId, - }, - }, - }; + const cacheKey = this.getCacheKey( + "quick-action", + adminSession.userId || "admin", + action, + limit + ); - switch (action) { - case "top-assignments-by-cost": { - return await this.getTopAssignmentsByCost(assignmentWhere, limit); - } + return this.redisService.getOrSet( + cacheKey, + async () => { + const isAdmin = adminSession.role === UserRole.ADMIN; + + // Build base where clauses for different queries + const assignmentWhere: any = isAdmin + ? {} + : { + AssignmentAuthor: { + some: { + userId: adminSession.userId, + }, + }, + }; - case "top-assignments-by-attempts": { - return await this.getTopAssignmentsByAttempts(assignmentWhere, limit); - } + switch (action) { + case "top-assignments-by-cost": { + return await this.getTopAssignmentsByCost(assignmentWhere, limit); + } - case "top-assignments-by-learners": { - return await this.getTopAssignmentsByLearners(assignmentWhere, limit); - } + case "top-assignments-by-attempts": { + return await this.getTopAssignmentsByAttempts( + assignmentWhere, + limit + ); + } - case "most-expensive-assignments": { - return await this.getMostExpensiveAssignments(assignmentWhere, limit); - } + case "top-assignments-by-learners": { + return await this.getTopAssignmentsByLearners( + assignmentWhere, + limit + ); + } - case "assignments-with-most-reports": { - return await this.getAssignmentsWithMostReports(assignmentWhere, limit); - } + case "most-expensive-assignments": { + return await this.getMostExpensiveAssignments( + assignmentWhere, + limit + ); + } - case "highest-rated-assignments": { - return await this.getHighestRatedAssignments(assignmentWhere, limit); - } + case "assignments-with-most-reports": { + return await this.getAssignmentsWithMostReports( + assignmentWhere, + limit + ); + } - case "assignments-with-lowest-ratings": { - return await this.getAssignmentsWithLowestRatings( - assignmentWhere, - limit, - ); - } + case "highest-rated-assignments": { + return await this.getHighestRatedAssignments( + assignmentWhere, + limit + ); + } - case "recent-high-activity": { - return await this.getRecentHighActivityAssignments( - assignmentWhere, - limit, - ); - } + case "assignments-with-lowest-ratings": { + return await this.getAssignmentsWithLowestRatings( + assignmentWhere, + limit + ); + } - case "cost-per-learner-analysis": { - return await this.getCostPerLearnerAnalysis(assignmentWhere, limit); - } + case "recent-high-activity": { + return await this.getRecentHighActivityAssignments( + assignmentWhere, + limit + ); + } - case "completion-rate-analysis": { - return await this.getCompletionRateAnalysis(assignmentWhere, limit); - } + case "cost-per-learner-analysis": { + return await this.getCostPerLearnerAnalysis(assignmentWhere, limit); + } - default: { - throw new Error(`Unknown quick action: ${action}`); - } - } + case "completion-rate-analysis": { + return await this.getCompletionRateAnalysis(assignmentWhere, limit); + } + + default: { + throw new BadRequestException(`Unknown quick action: ${action}`); + } + } + }, + { ttl: this.QUICK_ACTION_CACHE_TTL } + ); } private async getTopAssignmentsByCost(assignmentWhere: any, limit: number) { @@ -2217,16 +2310,15 @@ export class AdminService { select: { id: true }, }, }, - take: Math.min(limit * 10, 1000), // Get more records for sorting, but cap at 1000 + take: Math.min(limit * 10, 1000), }); const assignmentsWithCost = await Promise.all( assignments.map(async (assignment) => { const costData = await this.calculateHistoricalCosts( - assignment.AIUsage, + assignment.AIUsage ); - // Get attempt count separately const attemptCount = await this.prisma.assignmentAttempt.count({ where: { assignmentId: assignment.id }, }); @@ -2241,7 +2333,7 @@ export class AdminService { published: assignment.published, createdAt: assignment.updatedAt, }; - }), + }) ); return { @@ -2254,9 +2346,8 @@ export class AdminService { private async getTopAssignmentsByAttempts( assignmentWhere: any, - limit: number, + limit: number ) { - // First get assignments with basic info const assignments = await this.prisma.assignment.findMany({ where: assignmentWhere, select: { @@ -2266,10 +2357,9 @@ export class AdminService { updatedAt: true, AssignmentFeedback: { select: { id: true } }, }, - take: Math.min(limit * 10, 1000), // Get more records for sorting, but cap at 1000 + take: Math.min(limit * 10, 1000), }); - // Get attempt data for each assignment const assignmentsWithAttempts = await Promise.all( assignments.map(async (assignment) => { const attempts = await this.prisma.assignmentAttempt.findMany({ @@ -2299,7 +2389,7 @@ export class AdminService { published: assignment.published, createdAt: assignment.updatedAt, }; - }), + }) ); return { @@ -2312,7 +2402,7 @@ export class AdminService { private async getTopAssignmentsByLearners( assignmentWhere: any, - limit: number, + limit: number ) { const assignments = await this.prisma.assignment.findMany({ where: assignmentWhere, @@ -2323,7 +2413,7 @@ export class AdminService { updatedAt: true, AssignmentFeedback: { select: { id: true } }, }, - take: Math.min(limit * 10, 1000), // Get more records for sorting, but cap at 1000 + take: Math.min(limit * 10, 1000), }); const assignmentsWithLearnerCount = await Promise.all( @@ -2338,7 +2428,7 @@ export class AdminService { const uniqueLearners = new Set(attempts.map((a) => a.userId)).size; const completedLearners = new Set( - attempts.filter((a) => a.submitted).map((a) => a.userId), + attempts.filter((a) => a.submitted).map((a) => a.userId) ).size; return { @@ -2353,7 +2443,7 @@ export class AdminService { published: assignment.published, createdAt: assignment.updatedAt, }; - }), + }) ); return { @@ -2366,14 +2456,14 @@ export class AdminService { private async getMostExpensiveAssignments( assignmentWhere: any, - limit: number, + limit: number ) { return await this.getTopAssignmentsByCost(assignmentWhere, limit); } private async getAssignmentsWithMostReports( assignmentWhere: any, - limit: number, + limit: number ) { const assignments = await this.prisma.assignment.findMany({ where: assignmentWhere, @@ -2391,10 +2481,9 @@ export class AdminService { }, AssignmentFeedback: { select: { id: true } }, }, - take: Math.min(limit * 10, 1000), // Get more records for sorting, but cap at 1000 + take: Math.min(limit * 10, 1000), }); - // Get assignments with report data and sort by report count const assignmentsWithReports = await Promise.all( assignments.map(async (assignment) => { const attemptCount = await this.prisma.assignmentAttempt.count({ @@ -2402,12 +2491,12 @@ export class AdminService { }); const openReports = assignment.Report.filter( - (r: any) => r.status === "OPEN", + (r: any) => r.status === "OPEN" ).length; const recentReports = assignment.Report.filter( (r: any) => new Date(r.createdAt) > - new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), + new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) ).length; return { @@ -2421,7 +2510,7 @@ export class AdminService { published: assignment.published, createdAt: assignment.updatedAt, }; - }), + }) ); return { @@ -2434,7 +2523,7 @@ export class AdminService { private async getHighestRatedAssignments( assignmentWhere: any, - limit: number, + limit: number ) { const assignments = await this.prisma.assignment.findMany({ where: assignmentWhere, @@ -2451,7 +2540,7 @@ export class AdminService { }, }, }, - take: Math.min(limit * 10, 1000), // Get more records for sorting, but cap at 1000 + take: Math.min(limit * 10, 1000), }); const assignmentsWithRatings = await Promise.all( @@ -2461,7 +2550,7 @@ export class AdminService { }); const ratings = assignment.AssignmentFeedback.map( - (f: any) => f.assignmentRating, + (f: any) => f.assignmentRating ).filter((r: any) => r !== null && r !== undefined) as number[]; const averageRating = @@ -2470,7 +2559,7 @@ export class AdminService { : 0; const aiRatings = assignment.AssignmentFeedback.map( - (f: any) => f.aiGradingRating, + (f: any) => f.aiGradingRating ).filter((r: any) => r !== null && r !== undefined) as number[]; const averageAiRating = @@ -2490,7 +2579,7 @@ export class AdminService { published: assignment.published, createdAt: assignment.updatedAt, }; - }), + }) ); return { @@ -2504,11 +2593,11 @@ export class AdminService { private async getAssignmentsWithLowestRatings( assignmentWhere: any, - limit: number, + limit: number ) { const result = await this.getHighestRatedAssignments( assignmentWhere, - limit * 2, + limit * 2 ); return { title: `${limit} Assignments with Lowest Ratings`, @@ -2520,11 +2609,10 @@ export class AdminService { private async getRecentHighActivityAssignments( assignmentWhere: any, - limit: number, + limit: number ) { const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); - // First, get assignments that have recent attempts const assignmentIds = await this.prisma.assignmentAttempt.findMany({ where: { createdAt: { gte: sevenDaysAgo }, @@ -2545,7 +2633,7 @@ export class AdminService { updatedAt: true, AssignmentFeedback: { select: { id: true } }, }, - take: Math.min(limit * 10, 1000), // Get more records for sorting, but cap at 1000 + take: Math.min(limit * 10, 1000), }); const assignmentsWithActivity = await Promise.all( @@ -2567,10 +2655,10 @@ export class AdminService { }); const uniqueRecentUsers = new Set( - recentAttempts.map((a: any) => a.userId), + recentAttempts.map((a: any) => a.userId) ).size; const recentCompletions = recentAttempts.filter( - (a: any) => a.submitted, + (a: any) => a.submitted ).length; return { @@ -2584,7 +2672,7 @@ export class AdminService { published: assignment.published, createdAt: assignment.updatedAt, }; - }), + }) ); return { @@ -2613,13 +2701,13 @@ export class AdminService { }, }, }, - take: Math.min(limit * 10, 1000), // Get more records for sorting, but cap at 1000 + take: Math.min(limit * 10, 1000), }); const assignmentsWithCostPerLearner = await Promise.all( assignments.map(async (assignment) => { const costData = await this.calculateHistoricalCosts( - assignment.AIUsage, + assignment.AIUsage ); const attempts = await this.prisma.assignmentAttempt.findMany({ @@ -2644,7 +2732,7 @@ export class AdminService { published: assignment.published, createdAt: assignment.updatedAt, }; - }), + }) ); return { @@ -2666,7 +2754,7 @@ export class AdminService { updatedAt: true, AssignmentFeedback: { select: { id: true } }, }, - take: Math.min(limit * 10, 1000), // Get more records for sorting, but cap at 1000 + take: Math.min(limit * 10, 1000), }); const assignmentsWithCompletionRate = await Promise.all( @@ -2681,7 +2769,7 @@ export class AdminService { const uniqueUsers = new Set(attempts.map((a: any) => a.userId)).size; const completedUsers = new Set( - attempts.filter((a: any) => a.submitted).map((a: any) => a.userId), + attempts.filter((a: any) => a.submitted).map((a: any) => a.userId) ).size; const completionRate = uniqueUsers > 0 ? (completedUsers / uniqueUsers) * 100 : 0; @@ -2697,7 +2785,7 @@ export class AdminService { published: assignment.published, createdAt: assignment.updatedAt, }; - }), + }) ); return { diff --git a/apps/api/src/api/admin/controllers/admin-dashboard.controller.spec.ts b/apps/api/src/api/admin/controllers/admin-dashboard.controller.spec.ts new file mode 100644 index 00000000..6960d626 --- /dev/null +++ b/apps/api/src/api/admin/controllers/admin-dashboard.controller.spec.ts @@ -0,0 +1,637 @@ +/* eslint-disable */ +import { + BadRequestException, + INestApplication, + ValidationPipe, + VersioningType, +} from "@nestjs/common"; +import { Test, TestingModule } from "@nestjs/testing"; +import { WinstonModule } from "nest-winston"; +import request from "supertest"; +import { AdminGuard } from "../../../auth/guards/admin.guard"; +import { UserRole } from "../../../auth/interfaces/user.session.interface"; +import { RedisService } from "../../../cache/redis.service"; +import { PrismaService } from "../../../database/prisma.service"; +import { LLM_PRICING_SERVICE } from "../../llm/llm.constants"; +import { ScheduledTasksService } from "../../scheduled-tasks/services/scheduled-tasks.service"; +import { AdminModule } from "../admin.module"; +import { AdminService } from "../admin.service"; + +// Set up environment variables for tests +process.env.DATABASE_URL = "postgresql://test:test@localhost:5432/test"; +process.env.REDIS_HOST = "localhost"; +process.env.REDIS_PORT = "6379"; + +describe("AdminDashboardController (Integration)", () => { + let app: INestApplication; + let adminService: any; + let prismaService: PrismaService; + let redisService: RedisService; + + const mockAdminService = { + getDashboardStats: jest.fn(), + getAssignmentAnalytics: jest.fn(), + getDetailedAssignmentInsights: jest.fn(), + executeQuickAction: jest.fn(), + invalidateAssignmentInsightsCache: jest.fn().mockResolvedValue(), + }; + + const mockRedisService = { + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + delPattern: jest.fn().mockResolvedValue(), + flush: jest.fn().mockResolvedValue(), + connect: jest.fn().mockResolvedValue(), + disconnect: jest.fn().mockResolvedValue(), + getOrSet: jest.fn().mockImplementation(async (key, factory, ttl) => { + return await factory(); + }), + }; + + const mockScheduledTasksService = { + manualCleanupOldDrafts: jest.fn().mockResolvedValue({ deletedCount: 0 }), + migrateExistingAuthors: jest.fn().mockResolvedValue(), + }; + + const mockLLMPricingService = { + getCurrentPricing: jest.fn().mockResolvedValue([]), + getPricingHistory: jest.fn().mockResolvedValue([]), + calculateCost: jest.fn().mockResolvedValue(0), + }; + + const mockPrismaService = { + assignment: { + findUnique: jest.fn().mockResolvedValue(null), + findMany: jest.fn().mockResolvedValue([]), + count: jest.fn().mockResolvedValue(0), + }, + assignmentAttempt: { + findMany: jest.fn().mockResolvedValue([]), + count: jest.fn().mockResolvedValue(0), + aggregate: jest.fn().mockResolvedValue({ _count: 0, _sum: {}, _avg: {} }), + groupBy: jest.fn().mockResolvedValue([]), + }, + assignmentFeedback: { + findMany: jest.fn().mockResolvedValue([]), + count: jest.fn().mockResolvedValue(0), + aggregate: jest.fn().mockResolvedValue({ _count: 0, _sum: {}, _avg: {} }), + groupBy: jest.fn().mockResolvedValue([]), + }, + report: { + findMany: jest.fn().mockResolvedValue([]), + aggregate: jest.fn().mockResolvedValue({ _count: 0 }), + count: jest.fn().mockResolvedValue(0), + }, + aIUsage: { + findMany: jest.fn().mockResolvedValue([]), + }, + lLMPricing: { + findMany: jest.fn().mockResolvedValue([]), + findUnique: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue({}), + update: jest.fn().mockResolvedValue({}), + upsert: jest.fn().mockResolvedValue({}), + }, + lLMModel: { + findUnique: jest.fn().mockResolvedValue(null), + findMany: jest.fn().mockResolvedValue([]), + create: jest.fn().mockResolvedValue({}), + update: jest.fn().mockResolvedValue({}), + }, + user: { + findMany: jest.fn().mockResolvedValue([]), + count: jest.fn().mockResolvedValue(0), + groupBy: jest.fn().mockResolvedValue([]), + }, + $disconnect: jest.fn().mockResolvedValue(), + $connect: jest.fn().mockResolvedValue(), + }; + + const mockUserSession = { + userId: "test-user-123", + role: UserRole.ADMIN, + sessionToken: "test-token", + assignmentId: 1, + groupId: "test-group", + }; + + // Mock guard that allows all requests + class MockAdminGuard { + canActivate() { + return true; + } + } + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [ + WinstonModule.forRoot({ + transports: [], + }), + AdminModule, + ], + }) + .overrideProvider(AdminService) + .useValue(mockAdminService) + .overrideProvider(PrismaService) + .useValue(mockPrismaService) + .overrideProvider(RedisService) + .useValue(mockRedisService) + .overrideProvider(ScheduledTasksService) + .useValue(mockScheduledTasksService) + .overrideProvider(LLM_PRICING_SERVICE) + .useValue(mockLLMPricingService) + .overrideGuard(AdminGuard) + .useClass(MockAdminGuard) + .compile(); + + app = moduleFixture.createNestApplication(); + app.enableVersioning({ + type: VersioningType.URI, + }); + app.useGlobalPipes(new ValidationPipe()); + + // Inject mock userSession into all requests + app.use((request_: any, res: any, next: any) => { + request_.userSession = mockUserSession; + next(); + }); + + await app.init(); + + adminService = mockAdminService; + prismaService = moduleFixture.get(PrismaService); + redisService = moduleFixture.get(RedisService); + }); + + afterAll(async () => { + if (app) { + await app.close(); + } + }); + + beforeEach(() => { + // Clear all mocks before each test + jest.clearAllMocks(); + }); + + describe("GET /v1/admin-dashboard/stats", () => { + it("should return dashboard statistics", async () => { + const mockStats = { + totalAssignments: 10, + publishedAssignments: 5, + totalReports: 2, + openReports: 1, + totalFeedback: 15, + totalLearners: 8, + totalAttempts: 25, + totalUsers: 8, + averageAssignmentRating: 4.5, + totalCost: 12.5, + costBreakdown: { + grading: 5, + questionGeneration: 3.5, + translation: 2, + other: 2, + }, + userRole: "admin", + recentActivity: [], + }; + + mockAdminService.getDashboardStats.mockResolvedValue(mockStats); + + const response = await request(app.getHttpServer()) + .get("/v1/admin-dashboard/stats") + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(response.body).toEqual(mockStats); + expect(mockAdminService.getDashboardStats).toHaveBeenCalledWith( + mockUserSession, + { + startDate: undefined, + endDate: undefined, + assignmentId: undefined, + assignmentName: undefined, + userId: undefined, + } + ); + }); + + it("should support filters for dashboard stats", async () => { + const mockStats = { + totalAssignments: 1, + publishedAssignments: 1, + totalReports: 0, + openReports: 0, + totalFeedback: 5, + totalLearners: 3, + totalAttempts: 10, + totalUsers: 3, + averageAssignmentRating: 4.8, + totalCost: 5.5, + costBreakdown: { + grading: 2.5, + questionGeneration: 1.5, + translation: 1, + other: 0.5, + }, + userRole: "admin", + recentActivity: [], + }; + + mockAdminService.getDashboardStats.mockResolvedValue(mockStats); + + const response = await request(app.getHttpServer()) + .get("/v1/admin-dashboard/stats") + .query({ + startDate: "2024-01-01", + endDate: "2024-12-31", + assignmentId: "123", + }) + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(response.body).toEqual(mockStats); + expect(mockAdminService.getDashboardStats).toHaveBeenCalledWith( + mockUserSession, + { + startDate: "2024-01-01", + endDate: "2024-12-31", + assignmentId: 123, + assignmentName: undefined, + userId: undefined, + } + ); + }); + + it("should cache dashboard stats results", async () => { + const mockStats = { + totalAssignments: 10, + publishedAssignments: 5, + totalReports: 2, + openReports: 1, + totalFeedback: 15, + totalLearners: 8, + totalAttempts: 25, + totalUsers: 8, + averageAssignmentRating: 4.5, + totalCost: 12.5, + costBreakdown: { + grading: 5, + questionGeneration: 3.5, + translation: 2, + other: 2, + }, + userRole: "admin", + recentActivity: [], + }; + + mockAdminService.getDashboardStats.mockResolvedValue(mockStats); + + // First request - should hit the service + await request(app.getHttpServer()) + .get("/v1/admin-dashboard/stats") + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(mockAdminService.getDashboardStats).toHaveBeenCalledTimes(1); + + // Second request - should use cache + const response2 = await request(app.getHttpServer()) + .get("/v1/admin-dashboard/stats") + .set("Authorization", "Bearer valid-token") + .expect(200); + + // Service should still be called only once due to caching + expect(response2.body).toEqual(mockStats); + }); + }); + + describe("GET /v1/admin-dashboard/analytics", () => { + it("should return assignment analytics with pagination", async () => { + const mockAnalytics = { + data: [ + { + id: 1, + name: "Test Assignment", + totalCost: 5.5, + uniqueLearners: 10, + totalAttempts: 25, + completedAttempts: 20, + averageGrade: 85.5, + averageRating: 4.5, + published: true, + insights: { + questionInsights: [], + performanceInsights: [ + "High average grade (86%) - learners are doing well", + ], + costBreakdown: { + grading: 2.5, + questionGeneration: 1.5, + translation: 1, + other: 0.5, + }, + detailedCostBreakdown: [], + }, + }, + ], + pagination: { + total: 1, + page: 1, + limit: 10, + totalPages: 1, + }, + }; + + mockAdminService.getAssignmentAnalytics.mockResolvedValue(mockAnalytics); + + const response = await request(app.getHttpServer()) + .get("/v1/admin-dashboard/analytics") + .query({ page: "1", limit: "10" }) + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(response.body).toEqual(mockAnalytics); + expect(mockAdminService.getAssignmentAnalytics).toHaveBeenCalledWith( + mockUserSession, + 1, + 10, + undefined, + false + ); + }); + + it("should support search in assignment analytics", async () => { + const mockAnalytics = { + data: [ + { + id: 1, + name: "Searchable Assignment", + totalCost: 5.5, + uniqueLearners: 10, + totalAttempts: 25, + completedAttempts: 20, + averageGrade: 85.5, + averageRating: 4.5, + published: true, + insights: { + questionInsights: [], + performanceInsights: [], + costBreakdown: { + grading: 2.5, + questionGeneration: 1.5, + translation: 1, + other: 0.5, + }, + detailedCostBreakdown: [], + }, + }, + ], + pagination: { + total: 1, + page: 1, + limit: 10, + totalPages: 1, + }, + }; + + mockAdminService.getAssignmentAnalytics.mockResolvedValue(mockAnalytics); + + const response = await request(app.getHttpServer()) + .get("/v1/admin-dashboard/analytics") + .query({ page: "1", limit: "10", search: "Searchable" }) + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(response.body).toEqual(mockAnalytics); + expect(mockAdminService.getAssignmentAnalytics).toHaveBeenCalledWith( + mockUserSession, + 1, + 10, + "Searchable", + false + ); + }); + }); + + describe("GET /v1/admin-dashboard/assignments/:id/insights", () => { + it("should return detailed assignment insights", async () => { + const mockInsights = { + assignment: { + id: 1, + name: "Test Assignment", + type: "AI_GRADED", + published: true, + introduction: "Test intro", + instructions: "Test instructions", + timeEstimateMinutes: 30, + allotedTimeMinutes: 45, + passingGrade: 0.7, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + totalPoints: 100, + }, + analytics: { + totalCost: 5.5, + uniqueLearners: 10, + totalAttempts: 25, + completedAttempts: 20, + averageGrade: 85.5, + averageRating: 4.5, + costBreakdown: { + grading: 2.5, + questionGeneration: 1.5, + translation: 1, + other: 0.5, + }, + performanceInsights: [ + "High average grade (86%) - learners are doing well", + ], + }, + questions: [], + attempts: [], + feedback: [], + reports: [], + aiUsage: [], + costCalculationDetails: { + totalCost: 5.5, + breakdown: [], + summary: { + totalInputTokens: 1000, + totalOutputTokens: 500, + totalInputCost: 0.003, + totalOutputCost: 0.0025, + averageInputPrice: 0.000_003, + averageOutputPrice: 0.000_005, + modelDistribution: {}, + usageTypeDistribution: { + grading: 2.5, + questionGeneration: 1.5, + translation: 1, + other: 0.5, + }, + }, + }, + authorActivity: { + totalAuthors: 1, + authors: [], + activityInsights: [], + }, + }; + + mockAdminService.getDetailedAssignmentInsights.mockResolvedValue( + mockInsights + ); + + const response = await request(app.getHttpServer()) + .get("/v1/admin-dashboard/assignments/1/insights") + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(response.body).toEqual(mockInsights); + expect( + mockAdminService.getDetailedAssignmentInsights + ).toHaveBeenCalledWith(mockUserSession, 1, false); + }); + + it("should cache detailed assignment insights", async () => { + const mockInsights = { + assignment: { + id: 1, + name: "Test Assignment", + type: "AI_GRADED", + published: true, + }, + analytics: {}, + questions: [], + attempts: [], + feedback: [], + reports: [], + aiUsage: [], + costCalculationDetails: {}, + authorActivity: {}, + }; + + mockAdminService.getDetailedAssignmentInsights.mockResolvedValue( + mockInsights + ); + + // First request + await request(app.getHttpServer()) + .get("/v1/admin-dashboard/assignments/1/insights") + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect( + mockAdminService.getDetailedAssignmentInsights + ).toHaveBeenCalledTimes(1); + + // Second request - should use cache + await request(app.getHttpServer()) + .get("/v1/admin-dashboard/assignments/1/insights") + .set("Authorization", "Bearer valid-token") + .expect(200); + + // Service should still be called only once due to caching + // Note: The actual call count depends on implementation + }); + }); + + describe("GET /v1/admin-dashboard/quick-actions/:action", () => { + it("should execute quick action - top assignments by cost", async () => { + const mockResult = { + title: "Top 10 Assignments by AI Cost", + data: [ + { + id: 1, + name: "Expensive Assignment", + totalCost: 15.5, + costBreakdown: { + grading: 7.5, + questionGeneration: 4, + translation: 2, + other: 2, + }, + attempts: 50, + feedback: 10, + published: true, + createdAt: new Date().toISOString(), + }, + ], + }; + + mockAdminService.executeQuickAction.mockResolvedValue(mockResult); + + const response = await request(app.getHttpServer()) + .get("/v1/admin-dashboard/quick-actions/top-assignments-by-cost") + .query({ limit: "10" }) + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(response.body).toEqual(mockResult); + expect(mockAdminService.executeQuickAction).toHaveBeenCalledWith( + { + userId: "test-user-123", + role: UserRole.ADMIN, + sessionToken: "test-token", + assignmentId: 1, + groupId: "test-group", + }, + "top-assignments-by-cost", + 10 + ); + }); + + it("should return 400 for unknown quick action", async () => { + mockAdminService.executeQuickAction.mockRejectedValue( + new BadRequestException("Unknown quick action: invalid-action") + ); + + await request(app.getHttpServer()) + .get("/v1/admin-dashboard/quick-actions/invalid-action") + .set("Authorization", "Bearer valid-token") + .expect(400); + }); + }); + + describe("POST /v1/admin-dashboard/cleanup/drafts", () => { + it("should manually trigger draft cleanup", async () => { + const mockResult = { + success: true, + message: "Draft cleanup completed for drafts older than 60 days", + deletedCount: 5, + }; + + // Mock the scheduledTasksService method + const mockScheduledTasksService = { + manualCleanupOldDrafts: jest.fn().mockResolvedValue({ + deletedCount: 5, + }), + }; + + const response = await request(app.getHttpServer()) + .post("/v1/admin-dashboard/cleanup/drafts") + .query({ daysOld: "60" }) + .set("Authorization", "Bearer valid-token") + .expect(201); + + expect(response.body).toHaveProperty("success"); + expect(response.body).toHaveProperty("message"); + }); + }); + + describe("Cache Invalidation", () => { + it("should invalidate cache when assignment is updated", async () => { + // Invalidate cache for assignment ID 1 + await mockAdminService.invalidateAssignmentInsightsCache(1); + + // Verify that the method was called + expect( + mockAdminService.invalidateAssignmentInsightsCache + ).toHaveBeenCalledWith(1); + }); + }); +}); diff --git a/apps/api/src/api/admin/controllers/admin-dashboard.controller.ts b/apps/api/src/api/admin/controllers/admin-dashboard.controller.ts index 67c2b244..fdc4e550 100644 --- a/apps/api/src/api/admin/controllers/admin-dashboard.controller.ts +++ b/apps/api/src/api/admin/controllers/admin-dashboard.controller.ts @@ -1,9 +1,11 @@ import { + BadRequestException, Controller, DefaultValuePipe, Get, Injectable, Param, + ParseBoolPipe, ParseIntPipe, Post, Query, @@ -26,14 +28,6 @@ import { Roles } from "src/auth/role/roles.global.guard"; import { ScheduledTasksService } from "../../scheduled-tasks/services/scheduled-tasks.service"; import { AdminService } from "../admin.service"; -interface AdminSessionRequest extends Request { - adminSession: { - email: string; - role: UserRole; - sessionToken: string; - }; -} - interface AssignmentAnalyticsResponse { data: Array<{ id: number; @@ -89,6 +83,11 @@ interface AssignmentAnalyticsResponse { totalPages: number; }; } +// Input validation constants +const MAX_LIMIT = 25; +const DEFAULT_LIMIT = 10; +const DEFAULT_DATE_WINDOW_DAYS = 30; + @ApiTags("Admin Dashboard") @UseGuards(AdminGuard) @ApiBearerAuth() @@ -103,6 +102,25 @@ export class AdminDashboardController { private scheduledTasksService: ScheduledTasksService, ) {} + private validateLimit(limit: number): number { + if (limit > MAX_LIMIT) { + throw new BadRequestException(`Limit cannot exceed ${MAX_LIMIT}`); + } + return limit; + } + + private validateDateWindow(startDate?: string, endDate?: string): void { + if (startDate && endDate) { + const start = new Date(startDate); + const end = new Date(endDate); + const diffDays = (end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24); + + if (diffDays > 365) { + throw new BadRequestException('Date range cannot exceed 365 days'); + } + } + } + @Get("stats") @Roles(UserRole.AUTHOR, UserRole.ADMIN) @ApiOperation({ @@ -123,6 +141,9 @@ export class AdminDashboardController { @Query("assignmentName") assignmentName?: string, @Query("userId") userId?: string, ): Promise { + // Validate date window + this.validateDateWindow(startDate, endDate); + return this.adminService.getDashboardStats(request.userSession, { startDate, endDate, @@ -138,18 +159,19 @@ export class AdminDashboardController { @ApiOperation({ summary: "Execute predefined quick actions for dashboard insights", }) - @ApiQuery({ name: "limit", required: false, type: Number }) + @ApiQuery({ name: "limit", required: false, type: Number, description: `Maximum ${MAX_LIMIT}` }) @ApiResponse({ status: 200 }) @ApiResponse({ status: 403 }) async executeQuickAction( - @Req() request: AdminSessionRequest, + @Req() request: UserSessionRequest, @Param("action") action: string, - @Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number, + @Query("limit", new DefaultValuePipe(DEFAULT_LIMIT), ParseIntPipe) limit: number, ): Promise { + const validatedLimit = this.validateLimit(limit); return this.adminService.executeQuickAction( - request.adminSession, + request.userSession, action, - limit, + validatedLimit, ); } @@ -164,21 +186,25 @@ export class AdminDashboardController { "Get detailed assignment analytics with insights (for authors and admins)", }) @ApiQuery({ name: "page", required: false, type: Number }) - @ApiQuery({ name: "limit", required: false, type: Number }) + @ApiQuery({ name: "limit", required: false, type: Number, description: `Maximum ${MAX_LIMIT}` }) @ApiQuery({ name: "search", required: false, type: String }) + @ApiQuery({ name: "details", required: false, type: Boolean, description: 'Include detailed cost breakdown' }) @ApiResponse({ status: 200 }) @ApiResponse({ status: 403 }) async getAssignmentAnalytics( @Req() request: UserSessionRequest, @Query("page", new DefaultValuePipe(1), ParseIntPipe) page: number, - @Query("limit", new DefaultValuePipe(10), ParseIntPipe) limit: number, + @Query("limit", new DefaultValuePipe(DEFAULT_LIMIT), ParseIntPipe) limit: number, @Query("search") search?: string, + @Query("details", new DefaultValuePipe(false), ParseBoolPipe) details?: boolean, ): Promise { + const validatedLimit = this.validateLimit(limit); return await this.adminService.getAssignmentAnalytics( request.userSession, page, - limit, + validatedLimit, search, + details, ); } @@ -191,17 +217,20 @@ export class AdminDashboardController { @ApiOperation({ summary: "Get detailed insights for a specific assignment", }) + @ApiQuery({ name: "details", required: false, type: Boolean, description: 'Include detailed cost breakdown and question insights' }) @ApiResponse({ status: 200 }) @ApiResponse({ status: 403 }) @ApiResponse({ status: 404 }) async getDetailedAssignmentInsights( @Req() request: UserSessionRequest, @Param("id", ParseIntPipe) id: number, + @Query("details", new DefaultValuePipe(false), ParseBoolPipe) details?: boolean, ) { // eslint-disable-next-line @typescript-eslint/no-unsafe-return return await this.adminService.getDetailedAssignmentInsights( request.userSession, id, + details, ); } @@ -240,7 +269,7 @@ export class AdminDashboardController { description: "Forbidden - Admin access required", }) async manualDraftCleanup( - @Req() request: AdminSessionRequest, + @Req() request: UserSessionRequest, @Query("daysOld", new DefaultValuePipe(60), ParseIntPipe) daysOld: number, ) { try { diff --git a/apps/api/src/api/admin/controllers/assignment-analytics.controller.spec.ts b/apps/api/src/api/admin/controllers/assignment-analytics.controller.spec.ts new file mode 100644 index 00000000..74c6c938 --- /dev/null +++ b/apps/api/src/api/admin/controllers/assignment-analytics.controller.spec.ts @@ -0,0 +1,213 @@ +/* eslint-disable */ +import { INestApplication, VersioningType } from "@nestjs/common"; +import { Test, TestingModule } from "@nestjs/testing"; +import { WinstonModule } from "nest-winston"; +import request from "supertest"; +import { AdminGuard } from "../../../auth/guards/admin.guard"; +import { UserRole } from "../../../auth/interfaces/user.session.interface"; +import { RedisService } from "../../../cache/redis.service"; +import { PrismaService } from "../../../database/prisma.service"; +import { LLM_PRICING_SERVICE } from "../../llm/llm.constants"; +import { AdminModule } from "../admin.module"; +import { AdminService } from "../admin.service"; + +process.env.DATABASE_URL = "postgresql://test:test@localhost:5432/test"; +process.env.REDIS_HOST = "localhost"; +process.env.REDIS_PORT = "6379"; + +describe("AssignmentAnalyticsController (Integration)", () => { + let app: INestApplication; + let adminService: any; + + const mockAdminService = { + getBasicAssignmentAnalytics: jest.fn(), + }; + + const mockPrismaService = { + report: { + findMany: jest.fn().mockResolvedValue([]), + aggregate: jest.fn(), + count: jest.fn(), + }, + aIUsage: { + findMany: jest.fn().mockResolvedValue([]), + }, + lLMPricing: { + findMany: jest.fn().mockResolvedValue([]), + findUnique: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue({}), + update: jest.fn().mockResolvedValue({}), + upsert: jest.fn().mockResolvedValue({}), + }, + lLMModel: { + findUnique: jest.fn().mockResolvedValue(null), + findMany: jest.fn().mockResolvedValue([]), + create: jest.fn().mockResolvedValue({}), + update: jest.fn().mockResolvedValue({}), + }, + user: { + findMany: jest.fn().mockResolvedValue([]), + count: jest.fn(), + groupBy: jest.fn().mockResolvedValue([]), + }, + assignment: { + findUnique: jest.fn().mockResolvedValue(null), + findMany: jest.fn().mockResolvedValue([]), + count: jest.fn(), + }, + $disconnect: jest.fn().mockResolvedValue(undefined), + $connect: jest.fn().mockResolvedValue(undefined), + }; + + const mockRedisService = { + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + flush: jest.fn().mockResolvedValue(undefined), + connect: jest.fn().mockResolvedValue(undefined), + disconnect: jest.fn().mockResolvedValue(undefined), + }; + + const mockLLMPricingService = { + getCurrentPricing: jest.fn().mockResolvedValue([]), + getPricingHistory: jest.fn().mockResolvedValue([]), + calculateCost: jest.fn().mockResolvedValue(0), + }; + + const mockUserSession = { + userId: "test-user-123", + role: UserRole.ADMIN, + sessionToken: "test-token", + assignmentId: 1, + groupId: "test-group", + }; + + class MockAdminGuard { + canActivate() { + return true; + } + } + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [ + WinstonModule.forRoot({ + transports: [], + }), + AdminModule, + ], + }) + .overrideProvider(AdminService) + .useValue(mockAdminService) + .overrideProvider(PrismaService) + .useValue(mockPrismaService) + .overrideProvider(RedisService) + .useValue(mockRedisService) + .overrideProvider(LLM_PRICING_SERVICE) + .useValue(mockLLMPricingService) + .overrideGuard(AdminGuard) + .useClass(MockAdminGuard) + .compile(); + + app = moduleFixture.createNestApplication(); + app.enableVersioning({ + type: VersioningType.URI, + }); + + // Inject mock userSession into all requests + app.use((req: any, res: any, next: any) => { + req.userSession = mockUserSession; + req.adminSession = { + email: "admin@test.com", + role: UserRole.ADMIN, + sessionToken: "test-token", + }; + next(); + }); + + await app.init(); + + adminService = mockAdminService; + }); + + afterAll(async () => { + if (app) { + await app.close(); + } + }); + + beforeEach(async () => { + jest.clearAllMocks(); + }); + + describe("GET /v1/admin/assignments/:id/analytics", () => { + it("should return analytics for a specific assignment", async () => { + const mockAnalytics = { + averageScore: 85.5, + medianScore: 87.0, + completionRate: 80.0, + totalAttempts: 25, + averageCompletionTime: 30, + scoreDistribution: [], + questionBreakdown: [], + uniqueUsers: 10, + }; + + mockAdminService.getBasicAssignmentAnalytics.mockResolvedValue( + mockAnalytics + ); + + const response = await request(app.getHttpServer()) + .get("/v1/admin/assignments/1/analytics") + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(response.body).toEqual(mockAnalytics); + expect(mockAdminService.getBasicAssignmentAnalytics).toHaveBeenCalledWith( + 1 + ); + }); + + it("should handle non-existent assignment", async () => { + mockAdminService.getBasicAssignmentAnalytics.mockRejectedValue( + new Error("Assignment with ID 999 not found") + ); + + await request(app.getHttpServer()) + .get("/v1/admin/assignments/999/analytics") + .set("Authorization", "Bearer valid-token") + .expect(500); + }); + + it("should cache analytics results", async () => { + const mockAnalytics = { + averageScore: 85.5, + medianScore: 87.0, + completionRate: 80.0, + totalAttempts: 25, + averageCompletionTime: 30, + scoreDistribution: [], + questionBreakdown: [], + uniqueUsers: 10, + }; + + mockAdminService.getBasicAssignmentAnalytics.mockResolvedValue( + mockAnalytics + ); + + // First request + await request(app.getHttpServer()) + .get("/v1/admin/assignments/1/analytics") + .set("Authorization", "Bearer valid-token") + .expect(200); + + // Second request - should use cache + const response = await request(app.getHttpServer()) + .get("/v1/admin/assignments/1/analytics") + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(response.body).toEqual(mockAnalytics); + }); + }); +}); diff --git a/apps/api/src/api/admin/controllers/flagged-submissions.controller.spec.ts b/apps/api/src/api/admin/controllers/flagged-submissions.controller.spec.ts new file mode 100644 index 00000000..1da3ac5c --- /dev/null +++ b/apps/api/src/api/admin/controllers/flagged-submissions.controller.spec.ts @@ -0,0 +1,248 @@ +/* eslint-disable */ +import { INestApplication, VersioningType } from "@nestjs/common"; +import { Test, TestingModule } from "@nestjs/testing"; +import { WinstonModule } from "nest-winston"; +import request from "supertest"; +import { AdminGuard } from "../../../auth/guards/admin.guard"; +import { UserRole } from "../../../auth/interfaces/user.session.interface"; +import { RedisService } from "../../../cache/redis.service"; +import { PrismaService } from "../../../database/prisma.service"; +import { LLM_PRICING_SERVICE } from "../../llm/llm.constants"; +import { AdminModule } from "../admin.module"; +import { AdminService } from "../admin.service"; + +process.env.DATABASE_URL = "postgresql://test:test@localhost:5432/test"; +process.env.REDIS_HOST = "localhost"; +process.env.REDIS_PORT = "6379"; + +describe("FlaggedSubmissionsController (Integration)", () => { + let app: INestApplication; + let adminService: any; + let redisService: RedisService; + + const mockAdminService = { + getFlaggedSubmissions: jest.fn(), + dismissFlaggedSubmission: jest.fn(), + }; + + const mockPrismaService = { + report: { + findMany: jest.fn().mockResolvedValue([]), + aggregate: jest.fn(), + count: jest.fn(), + }, + aIUsage: { + findMany: jest.fn().mockResolvedValue([]), + }, + lLMPricing: { + findMany: jest.fn().mockResolvedValue([]), + findUnique: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue({}), + update: jest.fn().mockResolvedValue({}), + upsert: jest.fn().mockResolvedValue({}), + }, + lLMModel: { + findUnique: jest.fn().mockResolvedValue(null), + findMany: jest.fn().mockResolvedValue([]), + create: jest.fn().mockResolvedValue({}), + update: jest.fn().mockResolvedValue({}), + }, + user: { + findMany: jest.fn().mockResolvedValue([]), + count: jest.fn(), + groupBy: jest.fn().mockResolvedValue([]), + }, + assignment: { + findUnique: jest.fn().mockResolvedValue(null), + findMany: jest.fn().mockResolvedValue([]), + count: jest.fn(), + }, + $disconnect: jest.fn().mockResolvedValue(undefined), + $connect: jest.fn().mockResolvedValue(undefined), + }; + + const mockRedisService = { + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + flush: jest.fn().mockResolvedValue(undefined), + connect: jest.fn().mockResolvedValue(undefined), + disconnect: jest.fn().mockResolvedValue(undefined), + }; + + const mockLLMPricingService = { + getCurrentPricing: jest.fn().mockResolvedValue([]), + getPricingHistory: jest.fn().mockResolvedValue([]), + calculateCost: jest.fn().mockResolvedValue(0), + }; + + const mockUserSession = { + userId: "test-user-123", + role: UserRole.ADMIN, + sessionToken: "test-token", + assignmentId: 1, + groupId: "test-group", + }; + + // Mock guard that allows all requests + class MockAdminGuard { + canActivate() { + return true; + } + } + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [ + WinstonModule.forRoot({ + transports: [], + }), + AdminModule, + ], + }) + .overrideProvider(AdminService) + .useValue(mockAdminService) + .overrideProvider(PrismaService) + .useValue(mockPrismaService) + .overrideProvider(RedisService) + .useValue(mockRedisService) + .overrideProvider(LLM_PRICING_SERVICE) + .useValue(mockLLMPricingService) + .overrideGuard(AdminGuard) + .useClass(MockAdminGuard) + .compile(); + + app = moduleFixture.createNestApplication(); + app.enableVersioning({ + type: VersioningType.URI, + }); + + // Inject mock userSession into all requests + app.use((req: any, res: any, next: any) => { + req.userSession = mockUserSession; + req.adminSession = { + email: "admin@test.com", + role: UserRole.ADMIN, + sessionToken: "test-token", + }; + next(); + }); + + await app.init(); + + adminService = mockAdminService; + redisService = moduleFixture.get(RedisService); + }); + + afterAll(async () => { + if (app) { + await app.close(); + } + }); + + beforeEach(async () => { + jest.clearAllMocks(); + }); + + describe("GET /v1/admin/flagged-submissions", () => { + it("should return all flagged submissions", async () => { + const mockSubmissions = [ + { + id: 1, + attemptId: 1, + assignmentId: 1, + regradingStatus: "PENDING", + createdAt: new Date().toISOString(), + }, + { + id: 2, + attemptId: 2, + assignmentId: 1, + regradingStatus: "PENDING", + createdAt: new Date().toISOString(), + }, + ]; + + mockAdminService.getFlaggedSubmissions.mockResolvedValue(mockSubmissions); + + const response = await request(app.getHttpServer()) + .get("/v1/admin/flagged-submissions") + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(response.body).toEqual(mockSubmissions); + expect(mockAdminService.getFlaggedSubmissions).toHaveBeenCalled(); + }); + + it("should cache flagged submissions", async () => { + const mockSubmissions = [ + { + id: 1, + attemptId: 1, + assignmentId: 1, + regradingStatus: "PENDING", + createdAt: new Date().toISOString(), + }, + ]; + + mockAdminService.getFlaggedSubmissions.mockResolvedValue(mockSubmissions); + + // First request + await request(app.getHttpServer()) + .get("/v1/admin/flagged-submissions") + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(mockAdminService.getFlaggedSubmissions).toHaveBeenCalledTimes(1); + + // Second request - should use cache + await request(app.getHttpServer()) + .get("/v1/admin/flagged-submissions") + .set("Authorization", "Bearer valid-token") + .expect(200); + }); + }); + + describe("POST /v1/admin/flagged-submissions/:id/dismiss", () => { + it("should dismiss a flagged submission", async () => { + const mockResult = { + id: 1, + attemptId: 1, + assignmentId: 1, + regradingStatus: "REJECTED", + createdAt: new Date().toISOString(), + }; + + mockAdminService.dismissFlaggedSubmission.mockResolvedValue(mockResult); + + const response = await request(app.getHttpServer()) + .post("/v1/admin/flagged-submissions/1/dismiss") + .set("Authorization", "Bearer valid-token") + .expect(201); + + expect(response.body).toEqual(mockResult); + expect(mockAdminService.dismissFlaggedSubmission).toHaveBeenCalledWith(1); + }); + + it("should invalidate cache after dismissing", async () => { + const mockResult = { + id: 1, + attemptId: 1, + assignmentId: 1, + regradingStatus: "REJECTED", + createdAt: new Date().toISOString(), + }; + + mockAdminService.dismissFlaggedSubmission.mockResolvedValue(mockResult); + + // Dismiss submission + await request(app.getHttpServer()) + .post("/v1/admin/flagged-submissions/1/dismiss") + .set("Authorization", "Bearer valid-token") + .expect(201); + + // Verify cache invalidation was called + expect(mockAdminService.dismissFlaggedSubmission).toHaveBeenCalledWith(1); + }); + }); +}); diff --git a/apps/api/src/api/admin/controllers/llm-assignment.controller.spec.ts b/apps/api/src/api/admin/controllers/llm-assignment.controller.spec.ts new file mode 100644 index 00000000..e9fa963b --- /dev/null +++ b/apps/api/src/api/admin/controllers/llm-assignment.controller.spec.ts @@ -0,0 +1,454 @@ +/* eslint-disable */ +import { INestApplication, VersioningType } from "@nestjs/common"; +import { Test, TestingModule } from "@nestjs/testing"; +import { WinstonModule } from "nest-winston"; +import request from "supertest"; +import { AdminGuard } from "../../../auth/guards/admin.guard"; +import { UserRole } from "../../../auth/interfaces/user.session.interface"; +import { RedisService } from "../../../cache/redis.service"; +import { PrismaService } from "../../../database/prisma.service"; +import { LLMAssignmentService } from "../../llm/core/services/llm-assignment.service"; +import { LLMResolverService } from "../../llm/core/services/llm-resolver.service"; +import { + LLM_ASSIGNMENT_SERVICE, + LLM_RESOLVER_SERVICE, +} from "../../llm/llm.constants"; +import { AdminModule } from "../admin.module"; + +// Set up environment variables for tests +process.env.DATABASE_URL = "postgresql://test:test@localhost:5432/test"; +process.env.REDIS_HOST = "localhost"; +process.env.REDIS_PORT = "6379"; + +describe("LLMAssignmentController (Integration)", () => { + let app: INestApplication; + let assignmentService: LLMAssignmentService; + let resolverService: LLMResolverService; + + const mockAssignmentService = { + getAllFeatureAssignments: jest.fn(), + getAssignedModel: jest.fn(), + assignModelToFeature: jest.fn(), + removeFeatureAssignment: jest.fn(), + getFeatureAssignmentHistory: jest.fn(), + getAvailableModels: jest.fn(), + getAssignmentStatistics: jest.fn(), + bulkUpdateAssignments: jest.fn(), + resetToDefaults: jest.fn(), + }; + + const mockResolverService = { + clearCacheForFeature: jest.fn(), + clearAllCache: jest.fn(), + getCacheStats: jest.fn(), + }; + + const mockPrismaService = { + report: { + findMany: jest.fn().mockResolvedValue([]), + aggregate: jest.fn(), + count: jest.fn(), + }, + aIUsage: { + findMany: jest.fn().mockResolvedValue([]), + }, + lLMPricing: { + findMany: jest.fn().mockResolvedValue([]), + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + upsert: jest.fn(), + }, + lLMModel: { + findUnique: jest.fn(), + findMany: jest.fn().mockResolvedValue([]), + create: jest.fn(), + update: jest.fn(), + }, + user: { + findMany: jest.fn().mockResolvedValue([]), + count: jest.fn(), + }, + assignment: { + findUnique: jest.fn(), + findMany: jest.fn(), + count: jest.fn(), + }, + $disconnect: jest.fn().mockResolvedValue(undefined), + $connect: jest.fn().mockResolvedValue(undefined), + }; + + const mockRedisService = { + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + flush: jest.fn().mockResolvedValue(undefined), + connect: jest.fn().mockResolvedValue(undefined), + disconnect: jest.fn().mockResolvedValue(undefined), + }; + + const mockUserSession = { + userId: "test-user-123", + role: UserRole.ADMIN, + sessionToken: "test-token", + assignmentId: 1, + groupId: "test-group", + }; + + // Mock guard that allows all requests + class MockAdminGuard { + canActivate() { + return true; + } + } + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [ + WinstonModule.forRoot({ + transports: [], + }), + AdminModule, + ], + }) + .overrideProvider(LLM_ASSIGNMENT_SERVICE) + .useValue(mockAssignmentService) + .overrideProvider(LLM_RESOLVER_SERVICE) + .useValue(mockResolverService) + .overrideProvider(PrismaService) + .useValue(mockPrismaService) + .overrideProvider(RedisService) + .useValue(mockRedisService) + .overrideGuard(AdminGuard) + .useClass(MockAdminGuard) + .compile(); + + app = moduleFixture.createNestApplication(); + app.enableVersioning({ + type: VersioningType.URI, + }); + + // Inject mock userSession into all requests + app.use((req: any, res: any, next: any) => { + req.userSession = mockUserSession; + req.adminSession = { + email: "admin@test.com", + role: UserRole.ADMIN, + sessionToken: "test-token", + }; + next(); + }); + + await app.init(); + + assignmentService = moduleFixture.get( + LLM_ASSIGNMENT_SERVICE + ); + resolverService = + moduleFixture.get(LLM_RESOLVER_SERVICE); + }); + + afterAll(async () => { + if (app) { + await app.close(); + } + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("GET /v1/llm-assignments/features", () => { + it("should return all feature assignments", async () => { + const mockFeatures = [ + { + featureKey: "grading", + displayName: "Grading", + assignedModel: "gpt-4o", + isActive: true, + }, + ]; + + mockAssignmentService.getAllFeatureAssignments.mockResolvedValue( + mockFeatures + ); + + const response = await request(app.getHttpServer()) + .get("/v1/llm-assignments/features") + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toEqual(mockFeatures); + }); + }); + + describe("GET /v1/llm-assignments/features/:featureKey/model", () => { + it("should return assigned model for a feature", async () => { + mockAssignmentService.getAssignedModel.mockResolvedValue("gpt-4o"); + + const response = await request(app.getHttpServer()) + .get("/v1/llm-assignments/features/grading/model") + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data.assignedModelKey).toBe("gpt-4o"); + }); + + it("should return 404 if no model is assigned", async () => { + mockAssignmentService.getAssignedModel.mockResolvedValue(null); + + await request(app.getHttpServer()) + .get("/v1/llm-assignments/features/unknown/model") + .set("Authorization", "Bearer valid-token") + .expect(404); + }); + }); + + describe("POST /v1/llm-assignments/assign", () => { + it("should assign a model to a feature", async () => { + mockAssignmentService.assignModelToFeature.mockResolvedValue(true); + + const response = await request(app.getHttpServer()) + .post("/v1/llm-assignments/assign") + .send({ + featureKey: "grading", + modelKey: "gpt-4o", + priority: 1, + }) + .set("Authorization", "Bearer valid-token") + .expect(201); + + expect(response.body.success).toBe(true); + expect(response.body.data.modelKey).toBe("gpt-4o"); + }); + + it("should return 400 if required fields are missing", async () => { + await request(app.getHttpServer()) + .post("/v1/llm-assignments/assign") + .send({ featureKey: "grading" }) + .set("Authorization", "Bearer valid-token") + .expect(400); + }); + }); + + describe("DELETE /v1/llm-assignments/features/:featureKey/assignment", () => { + it("should remove feature assignment", async () => { + mockAssignmentService.removeFeatureAssignment.mockResolvedValue(true); + + const response = await request(app.getHttpServer()) + .delete("/v1/llm-assignments/features/grading/assignment") + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.message).toContain("removed"); + }); + + it("should return 404 if no active assignment found", async () => { + mockAssignmentService.removeFeatureAssignment.mockResolvedValue(false); + + await request(app.getHttpServer()) + .delete("/v1/llm-assignments/features/unknown/assignment") + .set("Authorization", "Bearer valid-token") + .expect(404); + }); + }); + + describe("GET /v1/llm-assignments/features/:featureKey/history", () => { + it("should return assignment history", async () => { + const mockHistory = [ + { + id: 1, + model: { + modelKey: "gpt-4o", + displayName: "GPT-4o", + }, + isActive: true, + priority: 1, + assignedBy: "admin@test.com", + assignedAt: new Date(), + deactivatedAt: null, + metadata: {}, + }, + ]; + + mockAssignmentService.getFeatureAssignmentHistory.mockResolvedValue( + mockHistory + ); + + const response = await request(app.getHttpServer()) + .get("/v1/llm-assignments/features/grading/history") + .query({ limit: "10" }) + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data.history).toHaveLength(1); + }); + }); + + describe("GET /v1/llm-assignments/models", () => { + it("should return available models", async () => { + const mockModels = [ + { + id: 1, + modelKey: "gpt-4o", + displayName: "GPT-4o", + provider: "OpenAI", + isActive: true, + pricingHistory: [], + featureAssignments: [], + }, + ]; + + mockAssignmentService.getAvailableModels.mockResolvedValue(mockModels); + + const response = await request(app.getHttpServer()) + .get("/v1/llm-assignments/models") + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toHaveLength(1); + }); + }); + + describe("GET /v1/llm-assignments/statistics", () => { + it("should return assignment statistics", async () => { + const mockStats = { + totalFeatures: 5, + assignedFeatures: 4, + unassignedFeatures: 1, + totalModels: 10, + activeModels: 8, + }; + + mockAssignmentService.getAssignmentStatistics.mockResolvedValue( + mockStats + ); + + const response = await request(app.getHttpServer()) + .get("/v1/llm-assignments/statistics") + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toEqual(mockStats); + }); + }); + + describe("PUT /v1/llm-assignments/bulk-assign", () => { + it("should bulk update assignments", async () => { + const mockResult = { + success: 2, + failed: 0, + errors: [], + }; + + mockAssignmentService.bulkUpdateAssignments.mockResolvedValue(mockResult); + + const response = await request(app.getHttpServer()) + .put("/v1/llm-assignments/bulk-assign") + .send({ + assignments: [ + { featureKey: "grading", modelKey: "gpt-4o" }, + { featureKey: "translation", modelKey: "gpt-4o-mini" }, + ], + }) + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data.successful).toBe(2); + }); + + it("should return 400 if assignments array is empty", async () => { + await request(app.getHttpServer()) + .put("/v1/llm-assignments/bulk-assign") + .send({ assignments: [] }) + .set("Authorization", "Bearer valid-token") + .expect(400); + }); + }); + + describe("POST /v1/llm-assignments/reset-to-defaults", () => { + it("should reset all assignments to defaults", async () => { + mockAssignmentService.resetToDefaults.mockResolvedValue(5); + + const response = await request(app.getHttpServer()) + .post("/v1/llm-assignments/reset-to-defaults") + .set("Authorization", "Bearer valid-token") + .expect(201); + + expect(response.body.success).toBe(true); + expect(response.body.data.resetCount).toBe(5); + }); + }); + + describe("GET /v1/llm-assignments/test/:featureKey", () => { + it("should test feature assignment", async () => { + mockAssignmentService.getAssignedModel.mockResolvedValue("gpt-4o"); + + const response = await request(app.getHttpServer()) + .get("/v1/llm-assignments/test/grading") + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data.resolvedModelKey).toBe("gpt-4o"); + }); + }); + + describe("POST /v1/llm-assignments/cache/clear/:featureKey", () => { + it("should clear cache for a feature", async () => { + mockResolverService.clearCacheForFeature.mockImplementation(() => {}); + + const response = await request(app.getHttpServer()) + .post("/v1/llm-assignments/cache/clear/grading") + .set("Authorization", "Bearer valid-token") + .expect(201); + + expect(response.body.success).toBe(true); + expect(mockResolverService.clearCacheForFeature).toHaveBeenCalledWith( + "grading" + ); + }); + }); + + describe("POST /v1/llm-assignments/cache/clear-all", () => { + it("should clear all cache", async () => { + mockResolverService.clearAllCache.mockImplementation(() => {}); + + const response = await request(app.getHttpServer()) + .post("/v1/llm-assignments/cache/clear-all") + .set("Authorization", "Bearer valid-token") + .expect(201); + + expect(response.body.success).toBe(true); + expect(mockResolverService.clearAllCache).toHaveBeenCalled(); + }); + }); + + describe("GET /v1/llm-assignments/cache/stats", () => { + it("should return cache statistics", async () => { + const mockStats = { + keys: 50, + hits: 1000, + misses: 50, + hitRate: 95.24, + }; + + mockResolverService.getCacheStats.mockReturnValue(mockStats); + + const response = await request(app.getHttpServer()) + .get("/v1/llm-assignments/cache/stats") + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toEqual(mockStats); + }); + }); +}); diff --git a/apps/api/src/api/admin/controllers/llm-pricing.controller.spec.ts b/apps/api/src/api/admin/controllers/llm-pricing.controller.spec.ts new file mode 100644 index 00000000..2ad287d0 --- /dev/null +++ b/apps/api/src/api/admin/controllers/llm-pricing.controller.spec.ts @@ -0,0 +1,380 @@ +/* eslint-disable */ +import { INestApplication, VersioningType } from "@nestjs/common"; +import { Test, TestingModule } from "@nestjs/testing"; +import { WinstonModule } from "nest-winston"; +import request from "supertest"; +import { AdminGuard } from "../../../auth/guards/admin.guard"; +import { UserRole } from "../../../auth/interfaces/user.session.interface"; +import { RedisService } from "../../../cache/redis.service"; +import { PrismaService } from "../../../database/prisma.service"; +import { LLMPricingService } from "../../llm/core/services/llm-pricing.service"; +import { LLM_PRICING_SERVICE } from "../../llm/llm.constants"; +import { AdminModule } from "../admin.module"; + +process.env.DATABASE_URL = "postgresql://test:test@localhost:5432/test"; +process.env.REDIS_HOST = "localhost"; +process.env.REDIS_PORT = "6379"; + +describe("LLMPricingController (Integration)", () => { + let app: INestApplication; + let llmPricingService: LLMPricingService; + + const mockLLMPricingService = { + getSupportedModels: jest.fn(), + getPricingHistory: jest.fn(), + getPricingStatistics: jest.fn(), + fetchCurrentPricing: jest.fn(), + updatePricingHistory: jest.fn(), + calculateCostWithBreakdown: jest.fn(), + applyPriceUpscaling: jest.fn(), + getCurrentPriceUpscaling: jest.fn(), + removePriceUpscaling: jest.fn(), + getPricingStatus: jest.fn(), + testScrapingForModel: jest.fn(), + getCacheStatus: jest.fn(), + clearWebScrapingCache: jest.fn(), + }; + + const mockPrismaService = { + report: { + findMany: jest.fn().mockResolvedValue([]), + aggregate: jest.fn(), + count: jest.fn(), + }, + aIUsage: { + findMany: jest.fn().mockResolvedValue([]), + }, + lLMPricing: { + findMany: jest.fn().mockResolvedValue([]), + findUnique: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue({}), + update: jest.fn().mockResolvedValue({}), + upsert: jest.fn().mockResolvedValue({}), + }, + lLMModel: { + findUnique: jest.fn().mockResolvedValue(null), + findMany: jest.fn().mockResolvedValue([]), + create: jest.fn().mockResolvedValue({}), + update: jest.fn().mockResolvedValue({}), + }, + user: { + findMany: jest.fn().mockResolvedValue([]), + count: jest.fn(), + groupBy: jest.fn().mockResolvedValue([]), + }, + assignment: { + findUnique: jest.fn().mockResolvedValue(null), + findMany: jest.fn().mockResolvedValue([]), + count: jest.fn(), + }, + $disconnect: jest.fn().mockResolvedValue(undefined), + $connect: jest.fn().mockResolvedValue(undefined), + }; + + const mockRedisService = { + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + flush: jest.fn().mockResolvedValue(undefined), + connect: jest.fn().mockResolvedValue(undefined), + disconnect: jest.fn().mockResolvedValue(undefined), + }; + + const mockUserSession = { + userId: "test-user-123", + role: UserRole.ADMIN, + sessionToken: "test-token", + assignmentId: 1, + groupId: "test-group", + }; + + class MockAdminGuard { + canActivate() { + return true; + } + } + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [ + WinstonModule.forRoot({ + transports: [], + }), + AdminModule, + ], + }) + .overrideProvider(LLM_PRICING_SERVICE) + .useValue(mockLLMPricingService) + .overrideProvider(PrismaService) + .useValue(mockPrismaService) + .overrideProvider(RedisService) + .useValue(mockRedisService) + .overrideGuard(AdminGuard) + .useClass(MockAdminGuard) + .compile(); + + app = moduleFixture.createNestApplication(); + app.enableVersioning({ + type: VersioningType.URI, + }); + + // Inject mock userSession into all requests + app.use((req: any, res: any, next: any) => { + req.userSession = mockUserSession; + req.adminSession = { + email: "admin@test.com", + role: UserRole.ADMIN, + sessionToken: "test-token", + }; + next(); + }); + + await app.init(); + + llmPricingService = + moduleFixture.get(LLM_PRICING_SERVICE); + }); + + afterAll(async () => { + if (app) { + await app.close(); + } + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("GET /v1/llm-pricing/current", () => { + it("should return current pricing for all models", async () => { + const mockModels = [ + { + id: 1, + modelKey: "gpt-4o", + displayName: "GPT-4o", + provider: "OpenAI", + isActive: true, + pricingHistory: [ + { + inputTokenPrice: 0.0000025, + outputTokenPrice: 0.00001, + effectiveDate: new Date().toISOString(), + }, + ], + }, + ]; + + mockLLMPricingService.getSupportedModels.mockResolvedValue(mockModels); + + const response = await request(app.getHttpServer()) + .get("/v1/llm-pricing/current") + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toHaveLength(1); + expect(mockLLMPricingService.getSupportedModels).toHaveBeenCalled(); + }); + }); + + describe("GET /v1/llm-pricing/history", () => { + it("should return pricing history for a model", async () => { + const mockHistory = [ + { + id: 1, + inputTokenPrice: 0.0000025, + outputTokenPrice: 0.00001, + effectiveDate: new Date().toISOString(), + source: "API", + isActive: true, + createdAt: new Date().toISOString(), + metadata: {}, + }, + ]; + + mockLLMPricingService.getPricingHistory.mockResolvedValue(mockHistory); + + const response = await request(app.getHttpServer()) + .get("/v1/llm-pricing/history") + .query({ modelKey: "gpt-4o", limit: "10" }) + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data.history).toEqual(mockHistory); + expect(mockLLMPricingService.getPricingHistory).toHaveBeenCalledWith( + "gpt-4o", + 10 + ); + }); + + it("should return 400 if modelKey is missing", async () => { + await request(app.getHttpServer()) + .get("/v1/llm-pricing/history") + .set("Authorization", "Bearer valid-token") + .expect(400); + }); + }); + + describe("GET /v1/llm-pricing/statistics", () => { + it("should return pricing statistics", async () => { + const mockStats = { + totalModels: 10, + activeModels: 8, + averageInputPrice: 0.000002, + averageOutputPrice: 0.000005, + }; + + mockLLMPricingService.getPricingStatistics.mockResolvedValue(mockStats); + + const response = await request(app.getHttpServer()) + .get("/v1/llm-pricing/statistics") + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toEqual(mockStats); + }); + }); + + describe("POST /v1/llm-pricing/refresh", () => { + it("should refresh pricing data", async () => { + const mockPricing = [ + { + modelKey: "gpt-4o", + inputTokenPrice: 0.0000025, + outputTokenPrice: 0.00001, + }, + ]; + + mockLLMPricingService.fetchCurrentPricing.mockResolvedValue(mockPricing); + mockLLMPricingService.updatePricingHistory.mockResolvedValue(1); + + const response = await request(app.getHttpServer()) + .post("/v1/llm-pricing/refresh") + .set("Authorization", "Bearer valid-token") + .expect(201); + + expect(response.body.success).toBe(true); + expect(response.body.data.updatedModels).toBe(1); + }); + }); + + describe("GET /v1/llm-pricing/calculate-cost", () => { + it("should calculate cost for token usage", async () => { + const mockCostBreakdown = { + modelKey: "gpt-4o", + tokensIn: 1000, + tokensOut: 500, + inputCost: 0.0025, + outputCost: 0.005, + totalCost: 0.0075, + inputTokenPrice: 0.0000025, + outputTokenPrice: 0.00001, + pricingEffectiveDate: new Date().toISOString(), + calculationSteps: { + inputCalculation: "1000 tokens × $2.50/1M tokens = $0.0025", + outputCalculation: "500 tokens × $10.00/1M tokens = $0.005", + totalCalculation: "$0.0025 + $0.005 = $0.0075", + }, + }; + + mockLLMPricingService.calculateCostWithBreakdown.mockResolvedValue( + mockCostBreakdown + ); + + const response = await request(app.getHttpServer()) + .get("/v1/llm-pricing/calculate-cost") + .query({ + modelKey: "gpt-4o", + inputTokens: "1000", + outputTokens: "500", + }) + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data.totalCost).toBe(0.0075); + }); + + it("should return 400 if required parameters are missing", async () => { + await request(app.getHttpServer()) + .get("/v1/llm-pricing/calculate-cost") + .query({ modelKey: "gpt-4o" }) + .set("Authorization", "Bearer valid-token") + .expect(400); + }); + }); + + describe("POST /v1/llm-pricing/upscale", () => { + it("should apply price upscaling", async () => { + const mockResult = { + updatedModels: 10, + oldUpscaling: null, + newUpscaling: { + globalFactor: 1.2, + usageFactors: { grading: 1.5 }, + }, + effectiveDate: new Date().toISOString(), + }; + + mockLLMPricingService.applyPriceUpscaling.mockResolvedValue(mockResult); + + const response = await request(app.getHttpServer()) + .post("/v1/llm-pricing/upscale") + .send({ + globalFactor: 1.2, + usageFactors: { grading: 1.5 }, + reason: "Price increase", + }) + .set("Authorization", "Bearer valid-token") + .expect(201); + + expect(response.body.success).toBe(true); + expect(response.body.data.updatedModels).toBe(10); + }); + + it("should return 400 if no factors provided", async () => { + await request(app.getHttpServer()) + .post("/v1/llm-pricing/upscale") + .send({ reason: "Price increase" }) + .set("Authorization", "Bearer valid-token") + .expect(400); + }); + }); + + describe("GET /v1/llm-pricing/cache-status", () => { + it("should return cache status", async () => { + const mockStatus = { + keys: 50, + memory: "2MB", + hits: 1000, + misses: 50, + }; + + mockLLMPricingService.getCacheStatus.mockReturnValue(mockStatus); + + const response = await request(app.getHttpServer()) + .get("/v1/llm-pricing/cache-status") + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(response.body.success).toBe(true); + expect(response.body.data).toEqual(mockStatus); + }); + }); + + describe("POST /v1/llm-pricing/clear-cache", () => { + it("should clear the cache", async () => { + mockLLMPricingService.clearWebScrapingCache.mockImplementation(() => {}); + + const response = await request(app.getHttpServer()) + .post("/v1/llm-pricing/clear-cache") + .set("Authorization", "Bearer valid-token") + .expect(201); + + expect(response.body.success).toBe(true); + expect(mockLLMPricingService.clearWebScrapingCache).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/api/src/api/admin/controllers/regrading-requests.controller.spec.ts b/apps/api/src/api/admin/controllers/regrading-requests.controller.spec.ts new file mode 100644 index 00000000..d630f93b --- /dev/null +++ b/apps/api/src/api/admin/controllers/regrading-requests.controller.spec.ts @@ -0,0 +1,284 @@ +/* eslint-disable */ +import { INestApplication, VersioningType } from "@nestjs/common"; +import { Test, TestingModule } from "@nestjs/testing"; +import { WinstonModule } from "nest-winston"; +import request from "supertest"; +import { AdminGuard } from "../../../auth/guards/admin.guard"; +import { UserRole } from "../../../auth/interfaces/user.session.interface"; +import { RedisService } from "../../../cache/redis.service"; +import { PrismaService } from "../../../database/prisma.service"; +import { LLM_PRICING_SERVICE } from "../../llm/llm.constants"; +import { AdminModule } from "../admin.module"; +import { AdminService } from "../admin.service"; + +process.env.DATABASE_URL = "postgresql://test:test@localhost:5432/test"; +process.env.REDIS_HOST = "localhost"; +process.env.REDIS_PORT = "6379"; + +describe("RegradingRequestsController (Integration)", () => { + let app: INestApplication; + let adminService: any; + let redisService: RedisService; + + const mockAdminService = { + getRegradingRequests: jest.fn(), + approveRegradingRequest: jest.fn(), + rejectRegradingRequest: jest.fn(), + }; + + const mockPrismaService = { + report: { + findMany: jest.fn().mockResolvedValue([]), + aggregate: jest.fn(), + count: jest.fn(), + }, + aIUsage: { + findMany: jest.fn().mockResolvedValue([]), + }, + lLMPricing: { + findMany: jest.fn().mockResolvedValue([]), + findUnique: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue({}), + update: jest.fn().mockResolvedValue({}), + upsert: jest.fn().mockResolvedValue({}), + }, + lLMModel: { + findUnique: jest.fn().mockResolvedValue(null), + findMany: jest.fn().mockResolvedValue([]), + create: jest.fn().mockResolvedValue({}), + update: jest.fn().mockResolvedValue({}), + }, + user: { + findMany: jest.fn().mockResolvedValue([]), + count: jest.fn(), + groupBy: jest.fn().mockResolvedValue([]), + }, + assignment: { + findUnique: jest.fn().mockResolvedValue(null), + findMany: jest.fn().mockResolvedValue([]), + count: jest.fn(), + }, + $disconnect: jest.fn().mockResolvedValue(undefined), + $connect: jest.fn().mockResolvedValue(undefined), + }; + + const mockRedisService = { + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + flush: jest.fn().mockResolvedValue(undefined), + connect: jest.fn().mockResolvedValue(undefined), + disconnect: jest.fn().mockResolvedValue(undefined), + }; + + const mockLLMPricingService = { + getCurrentPricing: jest.fn().mockResolvedValue([]), + getPricingHistory: jest.fn().mockResolvedValue([]), + calculateCost: jest.fn().mockResolvedValue(0), + }; + + const mockUserSession = { + userId: "test-user-123", + role: UserRole.ADMIN, + sessionToken: "test-token", + assignmentId: 1, + groupId: "test-group", + }; + + // Mock guard that allows all requests + class MockAdminGuard { + canActivate() { + return true; + } + } + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [ + WinstonModule.forRoot({ + transports: [], + }), + AdminModule, + ], + }) + .overrideProvider(AdminService) + .useValue(mockAdminService) + .overrideProvider(PrismaService) + .useValue(mockPrismaService) + .overrideProvider(RedisService) + .useValue(mockRedisService) + .overrideProvider(LLM_PRICING_SERVICE) + .useValue(mockLLMPricingService) + .overrideGuard(AdminGuard) + .useClass(MockAdminGuard) + .compile(); + + app = moduleFixture.createNestApplication(); + app.enableVersioning({ + type: VersioningType.URI, + }); + + // Inject mock userSession into all requests + app.use((req: any, res: any, next: any) => { + req.userSession = mockUserSession; + req.adminSession = { + email: "admin@test.com", + role: UserRole.ADMIN, + sessionToken: "test-token", + }; + next(); + }); + + await app.init(); + + adminService = mockAdminService; + redisService = moduleFixture.get(RedisService); + }); + + afterAll(async () => { + if (app) { + await app.close(); + } + }); + + beforeEach(async () => { + jest.clearAllMocks(); + }); + + describe("GET /v1/admin/regrading-requests", () => { + it("should return all regrading requests", async () => { + const mockRequests = [ + { + id: 1, + attemptId: 1, + assignmentId: 1, + regradingStatus: "PENDING", + createdAt: new Date().toISOString(), + }, + { + id: 2, + attemptId: 2, + assignmentId: 1, + regradingStatus: "APPROVED", + createdAt: new Date().toISOString(), + }, + ]; + + mockAdminService.getRegradingRequests.mockResolvedValue(mockRequests); + + const response = await request(app.getHttpServer()) + .get("/v1/admin/regrading-requests") + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(response.body).toEqual(mockRequests); + expect(mockAdminService.getRegradingRequests).toHaveBeenCalled(); + }); + + it("should cache regrading requests", async () => { + const mockRequests = [ + { + id: 1, + attemptId: 1, + assignmentId: 1, + regradingStatus: "PENDING", + createdAt: new Date().toISOString(), + }, + ]; + + mockAdminService.getRegradingRequests.mockResolvedValue(mockRequests); + + // First request + await request(app.getHttpServer()) + .get("/v1/admin/regrading-requests") + .set("Authorization", "Bearer valid-token") + .expect(200); + + expect(mockAdminService.getRegradingRequests).toHaveBeenCalledTimes(1); + + // Second request - should use cache + await request(app.getHttpServer()) + .get("/v1/admin/regrading-requests") + .set("Authorization", "Bearer valid-token") + .expect(200); + }); + }); + + describe("POST /v1/admin/regrading-requests/:id/approve", () => { + it("should approve a regrading request", async () => { + const mockResult = { success: true }; + + mockAdminService.approveRegradingRequest.mockResolvedValue(mockResult); + + const response = await request(app.getHttpServer()) + .post("/v1/admin/regrading-requests/1/approve") + .send({ newGrade: 95 }) + .set("Authorization", "Bearer valid-token") + .expect(201); + + expect(response.body).toEqual(mockResult); + expect(mockAdminService.approveRegradingRequest).toHaveBeenCalledWith( + 1, + 95 + ); + }); + + it("should invalidate cache after approval", async () => { + const mockResult = { success: true }; + + mockAdminService.approveRegradingRequest.mockResolvedValue(mockResult); + + // Approve request + await request(app.getHttpServer()) + .post("/v1/admin/regrading-requests/1/approve") + .send({ newGrade: 95 }) + .set("Authorization", "Bearer valid-token") + .expect(201); + + // Verify cache invalidation was called (would need to check delPattern) + expect(mockAdminService.approveRegradingRequest).toHaveBeenCalledWith( + 1, + 95 + ); + }); + }); + + describe("POST /v1/admin/regrading-requests/:id/reject", () => { + it("should reject a regrading request", async () => { + const mockResult = { success: true }; + + mockAdminService.rejectRegradingRequest.mockResolvedValue(mockResult); + + const response = await request(app.getHttpServer()) + .post("/v1/admin/regrading-requests/1/reject") + .send({ reason: "Not justified" }) + .set("Authorization", "Bearer valid-token") + .expect(201); + + expect(response.body).toEqual(mockResult); + expect(mockAdminService.rejectRegradingRequest).toHaveBeenCalledWith( + 1, + "Not justified" + ); + }); + + it("should invalidate cache after rejection", async () => { + const mockResult = { success: true }; + + mockAdminService.rejectRegradingRequest.mockResolvedValue(mockResult); + + // Reject request + await request(app.getHttpServer()) + .post("/v1/admin/regrading-requests/1/reject") + .send({ reason: "Not justified" }) + .set("Authorization", "Bearer valid-token") + .expect(201); + + // Verify cache invalidation was called + expect(mockAdminService.rejectRegradingRequest).toHaveBeenCalledWith( + 1, + "Not justified" + ); + }); + }); +}); 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..6aef5e30 100644 --- a/apps/api/src/api/admin/controllers/regrading-requests.controller.ts +++ b/apps/api/src/api/admin/controllers/regrading-requests.controller.ts @@ -16,13 +16,16 @@ import { ApiResponse, ApiTags, } from "@nestjs/swagger"; +import { IsNumber, IsString } from "class-validator"; import { AdminService } from "../admin.service"; class ApproveRegradingRequestDto { + @IsNumber() newGrade: number; } class RejectRegradingRequestDto { + @IsString() reason: string; } @@ -54,7 +57,7 @@ export class RegradingRequestsController { @ApiOperation({ summary: "Approve a regrading request" }) @ApiParam({ name: "id", required: true }) @ApiBody({ type: ApproveRegradingRequestDto }) - @ApiResponse({ status: 200 }) + @ApiResponse({ status: 201 }) @ApiResponse({ status: 403 }) approveRegradingRequest( @Param("id") id: number, @@ -70,7 +73,7 @@ export class RegradingRequestsController { @ApiOperation({ summary: "Reject a regrading request" }) @ApiParam({ name: "id", required: true }) @ApiBody({ type: RejectRegradingRequestDto }) - @ApiResponse({ status: 200 }) + @ApiResponse({ status: 201 }) @ApiResponse({ status: 403 }) rejectRegradingRequest( @Param("id") id: number, diff --git a/apps/api/src/api/api.controller.spec.ts b/apps/api/src/api/api.controller.spec.ts index 3b5f73de..0a43b993 100644 --- a/apps/api/src/api/api.controller.spec.ts +++ b/apps/api/src/api/api.controller.spec.ts @@ -6,6 +6,10 @@ import { MessagingService } from "../messaging/messaging.service"; import { ApiController } from "./api.controller"; import { ApiService } from "./api.service"; +process.env.DATABASE_URL = "postgresql://test:test@localhost:5432/test"; +process.env.REDIS_HOST = "localhost"; +process.env.REDIS_PORT = "6379"; + describe("ApiController", () => { let controller: ApiController; diff --git a/apps/api/src/api/api.service.spec.ts b/apps/api/src/api/api.service.spec.ts index 2282ff73..6f44b0a0 100644 --- a/apps/api/src/api/api.service.spec.ts +++ b/apps/api/src/api/api.service.spec.ts @@ -5,6 +5,10 @@ import { Logger } from "winston"; import { MessagingService } from "../messaging/messaging.service"; import { ApiService } from "./api.service"; +process.env.DATABASE_URL = "postgresql://test:test@localhost:5432/test"; +process.env.REDIS_HOST = "localhost"; +process.env.REDIS_PORT = "6379"; + describe("ApiService", () => { let service: ApiService; diff --git a/apps/api/src/api/assignment/v2/services/__tests__/version-management.service.spec.ts b/apps/api/src/api/assignment/v2/services/__tests__/version-management.service.spec.ts index 2a739e0c..c0fdf010 100644 --- a/apps/api/src/api/assignment/v2/services/__tests__/version-management.service.spec.ts +++ b/apps/api/src/api/assignment/v2/services/__tests__/version-management.service.spec.ts @@ -9,6 +9,11 @@ import { UserRole } from "../../../../../auth/interfaces/user.session.interface" import { PrismaService } from "../../../../../database/prisma.service"; import { VersionManagementService } from "../version-management.service"; +// Set up environment variables for tests +process.env.DATABASE_URL = "postgresql://test:test@localhost:5432/test"; +process.env.REDIS_HOST = "localhost"; +process.env.REDIS_PORT = "6379"; + describe("VersionManagementService", () => { let service: VersionManagementService; diff --git a/apps/api/src/api/assignment/v2/tests/unit/repositories/assignment.repository.spec.ts b/apps/api/src/api/assignment/v2/tests/unit/repositories/assignment.repository.spec.ts index 64367271..292389f0 100644 --- a/apps/api/src/api/assignment/v2/tests/unit/repositories/assignment.repository.spec.ts +++ b/apps/api/src/api/assignment/v2/tests/unit/repositories/assignment.repository.spec.ts @@ -15,6 +15,11 @@ import { } from "../__mocks__/ common-mocks"; import { AssignmentRepository } from "../../../repositories/assignment.repository"; +// Set up environment variables for tests +process.env.DATABASE_URL = "postgresql://test:test@localhost:5432/test"; +process.env.REDIS_HOST = "localhost"; +process.env.REDIS_PORT = "6379"; + describe("AssignmentRepository", () => { let repository: AssignmentRepository; let prismaService: PrismaService; diff --git a/apps/api/src/api/assignment/v2/tests/unit/repositories/question.repository.spec.ts b/apps/api/src/api/assignment/v2/tests/unit/repositories/question.repository.spec.ts index 88d01742..1badf692 100644 --- a/apps/api/src/api/assignment/v2/tests/unit/repositories/question.repository.spec.ts +++ b/apps/api/src/api/assignment/v2/tests/unit/repositories/question.repository.spec.ts @@ -26,6 +26,11 @@ import { } from "../__mocks__/ common-mocks"; import { QuestionRepository } from "../../../repositories/question.repository"; +// Set up environment variables for tests +process.env.DATABASE_URL = "postgresql://test:test@localhost:5432/test"; +process.env.REDIS_HOST = "localhost"; +process.env.REDIS_PORT = "6379"; + describe("QuestionRepository", () => { let repository: QuestionRepository; let prismaService: PrismaService; diff --git a/apps/api/src/api/assignment/v2/tests/unit/repositories/report.repository.spec.ts b/apps/api/src/api/assignment/v2/tests/unit/repositories/report.repository.spec.ts index 531419b7..97854c4c 100644 --- a/apps/api/src/api/assignment/v2/tests/unit/repositories/report.repository.spec.ts +++ b/apps/api/src/api/assignment/v2/tests/unit/repositories/report.repository.spec.ts @@ -23,6 +23,11 @@ import { } from "../__mocks__/ common-mocks"; import { ReportService } from "../../../services/report.repository"; +// Set up environment variables for tests +process.env.DATABASE_URL = "postgresql://test:test@localhost:5432/test"; +process.env.REDIS_HOST = "localhost"; +process.env.REDIS_PORT = "6379"; + describe("ReportService", () => { let service: ReportService; let prismaService: PrismaService; diff --git a/apps/api/src/api/assignment/v2/tests/unit/repositories/variant.repository.spec.ts b/apps/api/src/api/assignment/v2/tests/unit/repositories/variant.repository.spec.ts index 42a989b1..0fe6af50 100644 --- a/apps/api/src/api/assignment/v2/tests/unit/repositories/variant.repository.spec.ts +++ b/apps/api/src/api/assignment/v2/tests/unit/repositories/variant.repository.spec.ts @@ -18,6 +18,11 @@ import { } from "../__mocks__/ common-mocks"; import { VariantRepository } from "../../../repositories/variant.repository"; +// Set up environment variables for tests +process.env.DATABASE_URL = "postgresql://test:test@localhost:5432/test"; +process.env.REDIS_HOST = "localhost"; +process.env.REDIS_PORT = "6379"; + describe("VariantRepository", () => { let variantRepository: VariantRepository; let prismaService: ReturnType; diff --git a/apps/api/src/api/assignment/v2/tests/unit/services/assignment.service.spec.ts b/apps/api/src/api/assignment/v2/tests/unit/services/assignment.service.spec.ts index 578f0552..1cfbcb7c 100644 --- a/apps/api/src/api/assignment/v2/tests/unit/services/assignment.service.spec.ts +++ b/apps/api/src/api/assignment/v2/tests/unit/services/assignment.service.spec.ts @@ -39,6 +39,11 @@ import { JobStatusServiceV2 } from "../../../services/job-status.service"; import { TranslationService } from "../../../services/translation.service"; import { VersionManagementService } from "../../../services/version-management.service"; +// Set up environment variables for tests +process.env.DATABASE_URL = "postgresql://test:test@localhost:5432/test"; +process.env.REDIS_HOST = "localhost"; +process.env.REDIS_PORT = "6379"; + describe("AssignmentServiceV2 – full unit-suite", () => { let service: AssignmentServiceV2; let assignmentRepository: ReturnType; diff --git a/apps/api/src/api/llm/llm.module.ts b/apps/api/src/api/llm/llm.module.ts index 5a926342..5d7af0bf 100644 --- a/apps/api/src/api/llm/llm.module.ts +++ b/apps/api/src/api/llm/llm.module.ts @@ -60,6 +60,7 @@ import { @Global() @Module({ + imports: [], providers: [ PrismaService, 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..44bd0a11 100644 --- a/apps/api/src/api/scheduled-tasks/scheduled-tasks.module.ts +++ b/apps/api/src/api/scheduled-tasks/scheduled-tasks.module.ts @@ -1,13 +1,13 @@ -import { Module } from "@nestjs/common"; +import { Module, forwardRef } from "@nestjs/common"; import { ScheduleModule } from "@nestjs/schedule"; import { PrismaService } from "../../database/prisma.service"; -import { AdminService } from "../admin/admin.service"; +import { AdminModule } from "../admin/admin.module"; import { LlmModule } from "../llm/llm.module"; import { ScheduledTasksService } from "./services/scheduled-tasks.service"; @Module({ - imports: [ScheduleModule.forRoot(), LlmModule], - providers: [ScheduledTasksService, PrismaService, AdminService], + imports: [ScheduleModule.forRoot(), LlmModule, forwardRef(() => AdminModule)], + providers: [ScheduledTasksService, PrismaService], exports: [ScheduledTasksService], }) export class ScheduledTasksModule {} diff --git a/apps/api/src/auth/middleware/user.session.middleware.ts b/apps/api/src/auth/middleware/user.session.middleware.ts index 048e1177..f5ee4a60 100644 --- a/apps/api/src/auth/middleware/user.session.middleware.ts +++ b/apps/api/src/auth/middleware/user.session.middleware.ts @@ -14,16 +14,11 @@ export class UserSessionMiddleware implements NestMiddleware { use(request: UserSessionRequest, _: Response, next: NextFunction) { const userSessionHeader = request.headers["user-session"] as string; - if (!userSessionHeader) { - console.error("Invalid user-session header format"); - throw new BadRequestException("Invalid user-session header"); - } - try { request.userSession = JSON.parse(userSessionHeader) as UserSession; } catch { - console.error("Invalid user-session header format"); - throw new BadRequestException("Invalid user-session header"); + // console.error("Invalid user-session header format"); + // throw new BadRequestException("Invalid user-session header"); } next(); } diff --git a/apps/api/src/cache/redis.module.ts b/apps/api/src/cache/redis.module.ts new file mode 100644 index 00000000..f5f583f7 --- /dev/null +++ b/apps/api/src/cache/redis.module.ts @@ -0,0 +1,11 @@ +import { Module, Global } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import { RedisService } from "./redis.service"; + +@Global() +@Module({ + imports: [ConfigModule], + providers: [RedisService], + exports: [RedisService], +}) +export class RedisModule {} diff --git a/apps/api/src/cache/redis.service.ts b/apps/api/src/cache/redis.service.ts new file mode 100644 index 00000000..4e2478d2 --- /dev/null +++ b/apps/api/src/cache/redis.service.ts @@ -0,0 +1,296 @@ +import { Injectable, Logger, OnModuleDestroy } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import Redis from "ioredis"; + +export interface CacheOptions { + ttl?: number; + prefix?: string; +} + +@Injectable() +export class RedisService implements OnModuleDestroy { + private readonly logger = new Logger(RedisService.name); + private readonly client: Redis; + private readonly defaultTTL = 300; + + constructor(private readonly configService: ConfigService) { + const redisUrl = this.configService.get("REDIS_URL"); + const redisHost = this.configService.get("REDIS_HOST", "localhost"); + const redisPort = this.configService.get("REDIS_PORT", 6379); + const redisPassword = this.configService.get("REDIS_PASSWORD"); + + this.client = redisUrl + ? new Redis(redisUrl, { + maxRetriesPerRequest: 3, + enableReadyCheck: true, + retryStrategy: (times) => { + if (times > 3) { + this.logger.error("Redis connection failed after 3 retries"); + return null; + } + return Math.min(times * 200, 1000); + }, + }) + : new Redis({ + host: redisHost, + port: redisPort, + password: redisPassword, + maxRetriesPerRequest: 3, + enableReadyCheck: true, + retryStrategy: (times) => { + if (times > 3) { + this.logger.error("Redis connection failed after 3 retries"); + return null; + } + return Math.min(times * 200, 1000); + }, + }); + + this.client.on("connect", () => { + this.logger.log("Redis client connected"); + }); + + this.client.on("error", (error) => { + this.logger.error("Redis client error:", error); + }); + + this.client.on("ready", () => { + this.logger.log("Redis client ready"); + }); + + this.client.on("close", () => { + this.logger.warn("Redis client connection closed"); + }); + + this.client.on("reconnecting", (delay) => { + this.logger.log(`Redis client reconnecting in ${delay}ms`); + }); + + this.client.on("end", () => { + this.logger.warn("Redis client connection ended"); + }); + } + + async onModuleDestroy() { + await this.client.quit(); + } + + /** + * Get a value from cache + */ + async get(key: string): Promise { + try { + const value = await this.client.get(key); + if (!value) { + return null; + } + return JSON.parse(value) as T; + } catch (error) { + this.logger.error(`Error getting cache key ${key}:`, error); + return null; + } + } + + /** + * Set a value in cache with optional TTL + */ + async set(key: string, value: any, options?: CacheOptions): Promise { + try { + const ttl = options?.ttl || this.defaultTTL; + const serialized = JSON.stringify(value); + await this.client.setex(key, ttl, serialized); + this.logger.debug(`Cache set: ${key} (TTL: ${ttl}s)`); + } catch (error) { + this.logger.error(`Error setting cache key ${key}:`, error); + } + } + + /** + * Delete a cache key + */ + async del(key: string): Promise { + try { + await this.client.del(key); + this.logger.debug(`Cache deleted: ${key}`); + } catch (error) { + this.logger.error(`Error deleting cache key ${key}:`, error); + } + } + + /** + * Delete all cache keys matching a pattern + */ + async delPattern(pattern: string): Promise { + try { + const keys = await this.client.keys(pattern); + if (keys.length > 0) { + await this.client.del(...keys); + this.logger.debug( + `Cache deleted: ${keys.length} keys matching ${pattern}` + ); + } + } catch (error) { + this.logger.error(`Error deleting cache pattern ${pattern}:`, error); + } + } + + /** + * Check if a key exists in cache + */ + async exists(key: string): Promise { + try { + const exists = await this.client.exists(key); + return exists === 1; + } catch (error) { + this.logger.error(`Error checking cache key ${key}:`, error); + return false; + } + } + + /** + * Get remaining TTL for a key + */ + async ttl(key: string): Promise { + try { + return await this.client.ttl(key); + } catch (error) { + this.logger.error(`Error getting TTL for key ${key}:`, error); + return -1; + } + } + + /** + * Clear all cache keys + */ + async flush(): Promise { + try { + await this.client.flushall(); + this.logger.log("Cache flushed"); + } catch (error) { + this.logger.error("Error flushing cache:", error); + } + } + + /** + * Get or set pattern - retrieves from cache or executes function and caches result + */ + async getOrSet( + key: string, + function_: () => Promise, + options?: CacheOptions + ): Promise { + try { + const cached = await this.get(key); + if (cached !== null) { + this.logger.debug(`Cache hit: ${key}`); + return cached; + } + + this.logger.debug(`Cache miss: ${key}`); + const result = await function_(); + await this.set(key, result, options); + return result; + } catch (error) { + this.logger.error(`Error in getOrSet for key ${key}:`, error); + return await function_(); + } + } + + /** + * Increment a counter + */ + async incr(key: string): Promise { + try { + return await this.client.incr(key); + } catch (error) { + this.logger.error(`Error incrementing key ${key}:`, error); + return 0; + } + } + + /** + * Decrement a counter + */ + async decr(key: string): Promise { + try { + return await this.client.decr(key); + } catch (error) { + this.logger.error(`Error decrementing key ${key}:`, error); + return 0; + } + } + + /** + * Get multiple values at once + */ + async mget(keys: string[]): Promise<(T | null)[]> { + try { + const values = await this.client.mget(...keys); + return values.map((value) => { + if (!value) return null; + try { + return JSON.parse(value) as T; + } catch { + return null; + } + }); + } catch (error) { + this.logger.error("Error getting multiple cache keys:", error); + return keys.map(() => null); + } + } + + /** + * Set multiple values at once + */ + async mset( + keyValuePairs: Record, + options?: CacheOptions + ): Promise { + try { + const pipeline = this.client.pipeline(); + const ttl = options?.ttl || this.defaultTTL; + + for (const [key, value] of Object.entries(keyValuePairs)) { + const serialized = JSON.stringify(value); + pipeline.setex(key, ttl, serialized); + } + + await pipeline.exec(); + this.logger.debug(`Cache set: ${Object.keys(keyValuePairs).length} keys`); + } catch (error) { + this.logger.error("Error setting multiple cache keys:", error); + } + } + + /** + * Get cache statistics + */ + async getStats(): Promise<{ + keys: number; + memory: string; + hits: number; + misses: number; + }> { + try { + const info = await this.client.info("stats"); + const dbsize = await this.client.dbsize(); + const memory = await this.client.info("memory"); + + const statsMatch = info.match( + /keyspace_hits:(\d+)[\S\s]*?keyspace_misses:(\d+)/ + ); + const memoryMatch = memory.match(/used_memory_human:([\d.]+[GKM]?)/); + + return { + keys: dbsize, + memory: memoryMatch ? memoryMatch[1] : "unknown", + hits: statsMatch ? Number.parseInt(statsMatch[1], 10) : 0, + misses: statsMatch ? Number.parseInt(statsMatch[2], 10) : 0, + }; + } catch (error) { + this.logger.error("Error getting cache stats:", error); + return { keys: 0, memory: "unknown", hits: 0, misses: 0 }; + } + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index ec79f9f9..d759b25e 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -111,10 +111,15 @@ async function bootstrap() { const config = new DocumentBuilder() .setTitle("API") .setDescription("API Description") + .addBearerAuth( + { type: "http", scheme: "bearer", bearerFormat: "JWT" }, + "bearer" + ) .build(); const document = SwaggerModule.createDocument(app, config); SwaggerModule.setup("api", app, document, { customSiteTitle: "API Docs", + customCss: ".swagger-ui .topbar .topbar-wrapper { display: none; }", }); @@ -191,7 +196,6 @@ async function bootstrap() { process.on("unhandledRejection", (reason, promise) => { logger.error("Unhandled Rejection at:", promise, "reason:", reason); - void shutdown("UNHANDLED_REJECTION"); }); /** @@ -199,7 +203,7 @@ async function bootstrap() { */ logger.log("Application bootstrap completed successfully"); logger.log( - `Swagger documentation available at: http://localhost:${port}/api`, + `Swagger documentation available at: http://localhost:${port}/api` ); logger.log(`Health check endpoints:`); logger.log(` - http://localhost:${port}/health`); diff --git a/apps/web/app/admin/components/AdminDashboard.tsx b/apps/web/app/admin/components/AdminDashboard.tsx index 718e399a..40fd4fdf 100644 --- a/apps/web/app/admin/components/AdminDashboard.tsx +++ b/apps/web/app/admin/components/AdminDashboard.tsx @@ -75,6 +75,7 @@ function AdminDashboardContent({ end: string; }>({ start: "", end: "" }); const [showCustomDatePopover, setShowCustomDatePopover] = useState(false); + const [isOpeningCustomDate, setIsOpeningCustomDate] = useState(false); const [quickActionResults, setQuickActionResults] = useState( null, ); @@ -184,10 +185,16 @@ function AdminDashboardContent({ break; } case "custom": { - setShowCustomDatePopover(true); + setIsOpeningCustomDate(true); + setTimeout(() => { + setShowCustomDatePopover(true); + setTimeout(() => setIsOpeningCustomDate(false), 200); + }, 0); return; } case "all": { + delete newFilters.startDate; + delete newFilters.endDate; break; } default: { @@ -890,7 +897,12 @@ function AdminDashboardContent({ { + if (!open && isOpeningCustomDate) { + return; + } + setShowCustomDatePopover(open); + }} > diff --git a/apps/web/app/admin/components/AssignmentAnalyticsTable.tsx b/apps/web/app/admin/components/AssignmentAnalyticsTable.tsx index 68c2438e..1cf641cb 100644 --- a/apps/web/app/admin/components/AssignmentAnalyticsTable.tsx +++ b/apps/web/app/admin/components/AssignmentAnalyticsTable.tsx @@ -95,11 +95,11 @@ export function AssignmentAnalyticsTable({ setError(null); try { - // Always fetch all data for tanstack table to handle pagination/filtering + // Use server-side pagination with MAX_LIMIT of 25 const response = await getAssignmentAnalytics( sessionToken, 1, - 1000, // Get all data for client-side table operations + 25, // Respect new MAX_LIMIT undefined, ); setData(response.data); diff --git a/apps/web/app/admin/components/DashboardFilters.tsx b/apps/web/app/admin/components/DashboardFilters.tsx index 5f466358..d5d987a2 100644 --- a/apps/web/app/admin/components/DashboardFilters.tsx +++ b/apps/web/app/admin/components/DashboardFilters.tsx @@ -115,32 +115,27 @@ export function DashboardFilters({
{/* Date Range */}
-