diff --git a/src/analytics/analytics.service.ts b/src/analytics/analytics.service.ts index d675c900..7f5d7d8d 100644 --- a/src/analytics/analytics.service.ts +++ b/src/analytics/analytics.service.ts @@ -1,3 +1,5 @@ +import { Logger } from '@nestjs/common'; +import { Counter, Histogram } from 'prom-client'; import { Injectable, Logger, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; @@ -10,8 +12,75 @@ import { EventValidationService } from './services/event-validation.service'; @Injectable() export class AnalyticsService implements OnModuleInit { private readonly logger = new Logger(AnalyticsService.name); - private featureEventsCounter: any | null = null; + private readonly featureEvents: Counter<'category' | 'action' | 'label'> | null; + private readonly assessmentDuration: Histogram<'status'> | null; + + constructor(private readonly metrics: MetricsCollectionService) { + const registry = this.metrics.getRegistry(); + + this.featureEvents = this.registerMetric(() => + (registry.getSingleMetric('feature_events_total') as Counter<'category' | 'action' | 'label'>) ?? + new Counter({ + name: 'feature_events_total', + help: 'Feature analytics events', + labelNames: ['category', 'action', 'label'] as const, + registers: [registry], + }), + ); + + this.assessmentDuration = this.registerMetric(() => + (registry.getSingleMetric('assessment_duration_seconds') as Histogram<'status'>) ?? + new Histogram({ + name: 'assessment_duration_seconds', + help: 'Time from attempt start to submission or timeout, in seconds', + labelNames: ['status'] as const, + buckets: [30, 60, 120, 300, 600, 1200, 1800], + registers: [registry], + }), + ); + } + + // ── Generic event recording ──────────────────────────────────────────────── + + recordEvent(category: string, action: string, label = '', value = 1): void { + try { + this.featureEvents?.inc({ category, action, label }, value); + } catch (err) { + this.logger.error( + `Failed to record analytics event: ${category}.${action}`, + err as Error, + ); + } + } + + // ── Assessment-domain events ─────────────────────────────────────────────── + + recordAssessmentStarted(assessmentId: string): void { + this.recordEvent('assessment', 'started', assessmentId); + } + + recordAssessmentSubmitted(assessmentId: string, startedAt: Date): void { + this.recordEvent('assessment', 'submitted', assessmentId); + this.observeDuration(startedAt, 'submitted'); + } + + recordAssessmentTimedOut(assessmentId: string, startedAt: Date): void { + this.recordEvent('assessment', 'timed_out', assessmentId); + this.observeDuration(startedAt, 'timed_out'); + } + + recordAssessmentScore(score: number, maxScore: number): void { + const pct = maxScore > 0 ? Math.round((score / maxScore) * 100) : 0; + this.recordEvent('assessment', 'score_recorded', '', pct); + } + + // ── Private helpers ──────────────────────────────────────────────────────── + + private observeDuration(startedAt: Date, status: string): void { + try { + const seconds = (Date.now() - startedAt.getTime()) / 1000; + this.assessmentDuration?.observe({ status }, seconds); constructor( @InjectRepository(AnalyticsEvent) private eventRepository: Repository, @@ -37,12 +106,18 @@ export class AnalyticsService implements OnModuleInit { registers: [registry], }); } catch (err) { - this.logger.warn('Could not initialize feature events counter', err as Error); - this.featureEventsCounter = null; + this.logger.error('Failed to observe assessment duration', err as Error); } } /** + * Wraps metric construction in a try/catch so a misconfigured registry + * (e.g. duplicate registration in tests) degrades to a null metric rather + * than crashing the service on startup. + */ + private registerMetric(factory: () => T): T | null { + try { + return factory(); * Track an event with full validation and batching */ async trackEvent(dto: ITrackEventDto): Promise { @@ -101,9 +176,11 @@ export class AnalyticsService implements OnModuleInit { this.logger.debug(`Analytics event (log only): ${category}.${action} value=${value}`); } } catch (err) { - this.logger.error('Failed to record analytics event', err as Error); + this.logger.warn('Could not register metric; proceeding without it', err as Error); + return null; } } +} /** * Query events with filters diff --git a/src/assessment/assessments.service.spec.ts b/src/assessment/assessments.service.spec.ts index 1d1aa684..0d352c78 100644 --- a/src/assessment/assessments.service.spec.ts +++ b/src/assessment/assessments.service.spec.ts @@ -7,10 +7,38 @@ import { AssessmentAttempt } from './entities/assessment-attempt.entity'; import { Answer } from './entities/answer.entity'; import { FeedbackGenerationService } from './feedback/feedback-generation.service'; import { ScoreCalculationService } from './scoring/score-calculation.service'; +import { AnalyticsService } from '../analytics/analytics.service'; import { AssessmentStatus } from './enums/assessment-status.enum'; import { QuestionType } from './enums/question-type.enum'; -const mockAssessmentRepo = { +// ─── Factories ──────────────────────────────────────────────────────────────── + +const makeAssessment = (overrides: Partial = {}) => ({ + id: 'assess-1', + title: 'JS Basics', + durationMinutes: 30, + questions: [ + { id: 'q-1', type: QuestionType.MULTIPLE_CHOICE, correctAnswer: 'A', points: 10 }, + { id: 'q-2', type: QuestionType.MULTIPLE_CHOICE, correctAnswer: 'B', points: 5 }, + ], + ...overrides, +}); + +const makeAttempt = (overrides: Record = {}) => ({ + id: 'attempt-1', + studentId: 'student-1', + startedAt: new Date(), + status: AssessmentStatus.IN_PROGRESS, + assessment: makeAssessment(), + ...overrides, +}); + +// Convenience alias for tests that don't need to inspect the shape +const BASE_ASSESSMENT = makeAssessment(); + +// ─── Mock factories (fresh per test via beforeEach) ─────────────────────────── + +const makeMockAssessmentRepo = () => ({ findOne: jest.fn(), find: jest.fn(), findByIds: jest.fn(), @@ -21,236 +49,350 @@ const mockAssessmentRepo = { manager: { transaction: jest.fn(), }, -}; +}); -const mockAttemptRepo = { +const makeMockAttemptRepo = () => ({ findOne: jest.fn(), save: jest.fn(), -}; +}); -const mockAnswerRepo = { +const makeMockAnswerRepo = () => ({ save: jest.fn(), -}; +}); -const mockScoringService = { +const makeMockScoringService = () => ({ calculate: jest.fn(), -}; +}); -const mockFeedbackService = { +const makeMockFeedbackService = () => ({ generate: jest.fn(), -}; +}); -const baseAssessment = { - id: 'assess-1', - title: 'JS Basics', - durationMinutes: 30, - questions: [ - { - id: 'q-1', - type: QuestionType.MULTIPLE_CHOICE, - correctAnswer: 'A', - points: 10, - }, - ], -}; +const makeMockAnalyticsService = () => ({ + recordAssessmentStarted: jest.fn(), + recordAssessmentSubmitted: jest.fn(), + recordAssessmentTimedOut: jest.fn(), + recordAssessmentScore: jest.fn(), +}); + +// ─── Suite ──────────────────────────────────────────────────────────────────── describe('AssessmentsService', () => { let service: AssessmentsService; + let assessmentRepo: ReturnType; + let attemptRepo: ReturnType; + let answerRepo: ReturnType; + let scoringService: ReturnType; + let feedbackService: ReturnType; + let analyticsService: ReturnType; beforeEach(async () => { + assessmentRepo = makeMockAssessmentRepo(); + attemptRepo = makeMockAttemptRepo(); + answerRepo = makeMockAnswerRepo(); + scoringService = makeMockScoringService(); + feedbackService = makeMockFeedbackService(); + analyticsService = makeMockAnalyticsService(); + const module: TestingModule = await Test.createTestingModule({ providers: [ AssessmentsService, - { provide: getRepositoryToken(Assessment), useValue: mockAssessmentRepo }, - { provide: getRepositoryToken(AssessmentAttempt), useValue: mockAttemptRepo }, - { provide: getRepositoryToken(Answer), useValue: mockAnswerRepo }, - { provide: ScoreCalculationService, useValue: mockScoringService }, - { provide: FeedbackGenerationService, useValue: mockFeedbackService }, + { provide: getRepositoryToken(Assessment), useValue: assessmentRepo }, + { provide: getRepositoryToken(AssessmentAttempt), useValue: attemptRepo }, + { provide: getRepositoryToken(Answer), useValue: answerRepo }, + { provide: ScoreCalculationService, useValue: scoringService }, + { provide: FeedbackGenerationService, useValue: feedbackService }, + { provide: AnalyticsService, useValue: analyticsService }, ], }).compile(); service = module.get(AssessmentsService); }); - afterEach(() => jest.clearAllMocks()); - it('should be defined', () => { expect(service).toBeDefined(); }); + // ── findAll ────────────────────────────────────────────────────────────── + describe('findAll', () => { - it('should return all assessments with questions', async () => { - mockAssessmentRepo.find.mockResolvedValue([baseAssessment]); + it('returns all assessments with questions relation', async () => { + assessmentRepo.find.mockResolvedValue([BASE_ASSESSMENT]); + const result = await service.findAll(); - expect(result).toEqual([baseAssessment]); - expect(mockAssessmentRepo.find).toHaveBeenCalledWith({ relations: ['questions'] }); + + expect(result).toEqual([BASE_ASSESSMENT]); + expect(assessmentRepo.find).toHaveBeenCalledWith({ relations: ['questions'] }); + }); + + it('returns empty array when no assessments exist', async () => { + assessmentRepo.find.mockResolvedValue([]); + expect(await service.findAll()).toEqual([]); }); }); + // ── findOne ────────────────────────────────────────────────────────────── + describe('findOne', () => { - it('should return assessment by id', async () => { - mockAssessmentRepo.findOne.mockResolvedValue(baseAssessment); + it('returns assessment by id with questions relation', async () => { + assessmentRepo.findOne.mockResolvedValue(BASE_ASSESSMENT); + const result = await service.findOne('assess-1'); - expect(result).toEqual(baseAssessment); - expect(mockAssessmentRepo.findOne).toHaveBeenCalledWith({ + + expect(result).toEqual(BASE_ASSESSMENT); + expect(assessmentRepo.findOne).toHaveBeenCalledWith({ where: { id: 'assess-1' }, relations: ['questions'], }); }); + + it('returns null when assessment does not exist', async () => { + assessmentRepo.findOne.mockResolvedValue(null); + expect(await service.findOne('unknown')).toBeNull(); + }); }); + // ── findByIds ──────────────────────────────────────────────────────────── + describe('findByIds', () => { - it('should return empty array for empty ids', async () => { + it('short-circuits and returns [] without hitting the repo for empty input', async () => { const result = await service.findByIds([]); expect(result).toEqual([]); - expect(mockAssessmentRepo.findByIds).not.toHaveBeenCalled(); + expect(assessmentRepo.findByIds).not.toHaveBeenCalled(); }); - it('should return assessments for given ids', async () => { - mockAssessmentRepo.findByIds.mockResolvedValue([baseAssessment]); + it('returns assessments for given ids', async () => { + assessmentRepo.findByIds.mockResolvedValue([BASE_ASSESSMENT]); const result = await service.findByIds(['assess-1']); - expect(result).toEqual([baseAssessment]); + expect(result).toEqual([BASE_ASSESSMENT]); }); }); + // ── create ─────────────────────────────────────────────────────────────── + describe('create', () => { - it('should create and save an assessment', async () => { + it('creates and saves an assessment', async () => { const data = { title: 'New Quiz', durationMinutes: 15 }; - mockAssessmentRepo.create.mockReturnValue(data); - mockAssessmentRepo.save.mockResolvedValue({ id: 'new-1', ...data }); + assessmentRepo.create.mockReturnValue(data); + assessmentRepo.save.mockResolvedValue({ id: 'new-1', ...data }); const result = await service.create(data); - expect(mockAssessmentRepo.create).toHaveBeenCalledWith(data); - expect(result).toMatchObject({ id: 'new-1' }); + expect(assessmentRepo.create).toHaveBeenCalledWith(data); + expect(result).toMatchObject({ id: 'new-1', title: 'New Quiz' }); }); - it('should handle array result from save', async () => { + it('unwraps array result from save (TypeORM bulk-save edge case)', async () => { const data = { title: 'Quiz' }; - mockAssessmentRepo.create.mockReturnValue(data); - mockAssessmentRepo.save.mockResolvedValue([{ id: 'arr-1', ...data }]); + assessmentRepo.create.mockReturnValue(data); + assessmentRepo.save.mockResolvedValue([{ id: 'arr-1', ...data }]); const result = await service.create(data); expect(result).toMatchObject({ id: 'arr-1' }); }); }); + // ── update ─────────────────────────────────────────────────────────────── + describe('update', () => { - it('should update and return the assessment', async () => { - const updated = { ...baseAssessment, title: 'Updated' }; - mockAssessmentRepo.update.mockResolvedValue({ affected: 1 }); - mockAssessmentRepo.findOne.mockResolvedValue(updated); + it('updates the record and returns the refreshed assessment', async () => { + const updated = makeAssessment({ title: 'Updated' }); + assessmentRepo.update.mockResolvedValue({ affected: 1 }); + assessmentRepo.findOne.mockResolvedValue(updated); const result = await service.update('assess-1', { title: 'Updated' }); + + expect(assessmentRepo.update).toHaveBeenCalledWith('assess-1', { title: 'Updated' }); expect(result.title).toBe('Updated'); }); }); + // ── remove ─────────────────────────────────────────────────────────────── + describe('remove', () => { - it('should soft-delete assessment and its questions in a transaction', async () => { - mockAssessmentRepo.findOne.mockResolvedValue(baseAssessment); - mockAssessmentRepo.manager.transaction.mockImplementation(async (cb) => { - const manager = { - getRepository: jest.fn().mockReturnValue({ - createQueryBuilder: jest.fn().mockReturnValue({ - softDelete: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - execute: jest.fn().mockResolvedValue({}), - }), - softDelete: jest.fn().mockResolvedValue({}), - }), - }; - await cb(manager); + it('soft-deletes assessment and questions inside a transaction', async () => { + assessmentRepo.findOne.mockResolvedValue(BASE_ASSESSMENT); + + const qbMock = { + softDelete: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue({}), + }; + const softDeleteMock = jest.fn().mockResolvedValue({}); + const getRepositoryMock = jest.fn().mockReturnValue({ + createQueryBuilder: jest.fn().mockReturnValue(qbMock), + softDelete: softDeleteMock, + }); + + assessmentRepo.manager.transaction.mockImplementation(async (cb: any) => { + await cb({ getRepository: getRepositoryMock }); }); await service.remove('assess-1'); - expect(mockAssessmentRepo.manager.transaction).toHaveBeenCalled(); + + expect(assessmentRepo.manager.transaction).toHaveBeenCalledTimes(1); + expect(qbMock.execute).toHaveBeenCalled(); + expect(softDeleteMock).toHaveBeenCalledWith('assess-1'); }); - it('should do nothing when assessment not found', async () => { - mockAssessmentRepo.findOne.mockResolvedValue(null); + it('skips the transaction when assessment does not exist', async () => { + assessmentRepo.findOne.mockResolvedValue(null); await service.remove('nonexistent'); - expect(mockAssessmentRepo.manager.transaction).not.toHaveBeenCalled(); + expect(assessmentRepo.manager.transaction).not.toHaveBeenCalled(); }); }); + // ── startAssessment ────────────────────────────────────────────────────── + describe('startAssessment', () => { - it('should create an in-progress attempt', async () => { - mockAssessmentRepo.findOne.mockResolvedValue(baseAssessment); - mockAttemptRepo.save.mockResolvedValue({ id: 'attempt-1', status: AssessmentStatus.IN_PROGRESS }); + it('saves an IN_PROGRESS attempt and records the analytics event', async () => { + assessmentRepo.findOne.mockResolvedValue(BASE_ASSESSMENT); + attemptRepo.save.mockResolvedValue({ + id: 'attempt-1', + status: AssessmentStatus.IN_PROGRESS, + }); const result = await service.startAssessment('student-1', 'assess-1'); - expect(mockAttemptRepo.save).toHaveBeenCalledWith( + expect(attemptRepo.save).toHaveBeenCalledWith( expect.objectContaining({ studentId: 'student-1', - assessment: baseAssessment, + assessment: BASE_ASSESSMENT, status: AssessmentStatus.IN_PROGRESS, }), ); - expect(result).toMatchObject({ status: AssessmentStatus.IN_PROGRESS }); + expect(result.status).toBe(AssessmentStatus.IN_PROGRESS); + expect(analyticsService.recordAssessmentStarted).toHaveBeenCalledWith('assess-1'); }); }); + // ── submitAssessment ───────────────────────────────────────────────────── + describe('submitAssessment', () => { - it('should throw NotFoundException when attempt not found', async () => { - mockAttemptRepo.findOne.mockResolvedValue(null); - await expect(service.submitAssessment('bad-attempt', [])).rejects.toThrow(NotFoundException); + it('throws NotFoundException when attempt is not found', async () => { + attemptRepo.findOne.mockResolvedValue(null); + await expect(service.submitAssessment('missing', [])).rejects.toThrow(NotFoundException); }); - it('should mark attempt as TIMED_OUT when past deadline', async () => { - const pastAttempt = { - id: 'attempt-1', - startedAt: new Date(Date.now() - 60 * 60 * 1000), // 1 hour ago - assessment: { ...baseAssessment, durationMinutes: 1 }, - status: AssessmentStatus.IN_PROGRESS, - }; - mockAttemptRepo.findOne.mockResolvedValue(pastAttempt); - mockAttemptRepo.save.mockResolvedValue({ ...pastAttempt, status: AssessmentStatus.TIMED_OUT }); + it('throws NotFoundException when attempt has no questions', async () => { + attemptRepo.findOne.mockResolvedValue( + makeAttempt({ assessment: { ...makeAssessment(), questions: undefined } }), + ); + await expect(service.submitAssessment('attempt-1', [])).rejects.toThrow(NotFoundException); + }); + + it('marks attempt TIMED_OUT and fires timeout analytics when past deadline', async () => { + const timedOutAttempt = makeAttempt({ + startedAt: new Date(Date.now() - 2 * 60 * 60 * 1000), // 2 hours ago + assessment: makeAssessment({ durationMinutes: 1 }), + }); + attemptRepo.findOne.mockResolvedValue(timedOutAttempt); + attemptRepo.save.mockResolvedValue({ ...timedOutAttempt, status: AssessmentStatus.TIMED_OUT }); - const result = await service.submitAssessment('attempt-1', []); + await service.submitAssessment('attempt-1', []); - expect(mockAttemptRepo.save).toHaveBeenCalledWith( + expect(attemptRepo.save).toHaveBeenCalledWith( expect.objectContaining({ status: AssessmentStatus.TIMED_OUT }), ); + expect(analyticsService.recordAssessmentTimedOut).toHaveBeenCalledWith( + timedOutAttempt.assessment.id, + timedOutAttempt.startedAt, + ); + expect(analyticsService.recordAssessmentSubmitted).not.toHaveBeenCalled(); }); - it('should grade attempt and return feedback', async () => { - const activeAttempt = { - id: 'attempt-1', - startedAt: new Date(), - assessment: { ...baseAssessment, durationMinutes: 60 }, - status: AssessmentStatus.IN_PROGRESS, - }; - mockAttemptRepo.findOne.mockResolvedValue(activeAttempt); - mockScoringService.calculate.mockReturnValue(10); - mockFeedbackService.generate.mockReturnValue('Excellent performance 🎉'); - mockAnswerRepo.save.mockResolvedValue({}); - mockAttemptRepo.save.mockResolvedValue({ ...activeAttempt, score: 10, status: AssessmentStatus.GRADED }); + it('grades all questions, saves answers, and returns feedback', async () => { + const activeAttempt = makeAttempt({ + assessment: makeAssessment({ durationMinutes: 60 }), + }); + attemptRepo.findOne.mockResolvedValue(activeAttempt); + scoringService.calculate.mockReturnValue(10); + feedbackService.generate.mockReturnValue('Excellent'); + answerRepo.save.mockResolvedValue({}); + attemptRepo.save.mockResolvedValue({ + ...activeAttempt, + score: 20, + status: AssessmentStatus.GRADED, + }); - const result = await service.submitAssessment('attempt-1', [ + const result = (await service.submitAssessment('attempt-1', [ { questionId: 'q-1', response: 'A' }, - ]) as { attempt: any; feedback: string }; + { questionId: 'q-2', response: 'B' }, + ])) as { attempt: any; feedback: string }; + + // One saved answer per question + expect(answerRepo.save).toHaveBeenCalledTimes(2); + expect(answerRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ question: activeAttempt.assessment.questions[0], response: 'A', awardedPoints: 10 }), + ); + + expect(attemptRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ status: AssessmentStatus.GRADED, score: 20 }), + ); + expect(result.feedback).toBe('Excellent'); + }); + + it('records submission and score analytics on successful grade', async () => { + const activeAttempt = makeAttempt({ + assessment: makeAssessment({ durationMinutes: 60 }), + }); + attemptRepo.findOne.mockResolvedValue(activeAttempt); + // q-1 = 10pts correct, q-2 = 0pts wrong → score 10 out of 15 + scoringService.calculate + .mockReturnValueOnce(10) + .mockReturnValueOnce(0); + feedbackService.generate.mockReturnValue('Good job'); + answerRepo.save.mockResolvedValue({}); + attemptRepo.save.mockResolvedValue({ ...activeAttempt, score: 10, status: AssessmentStatus.GRADED }); + + await service.submitAssessment('attempt-1', []); + + expect(analyticsService.recordAssessmentSubmitted).toHaveBeenCalledWith( + activeAttempt.assessment.id, + activeAttempt.startedAt, + ); + expect(analyticsService.recordAssessmentScore).toHaveBeenCalledWith(10, 15); + }); + + it('handles all questions unanswered (score of 0)', async () => { + const activeAttempt = makeAttempt({ + assessment: makeAssessment({ durationMinutes: 60 }), + }); + attemptRepo.findOne.mockResolvedValue(activeAttempt); + scoringService.calculate.mockReturnValue(0); + feedbackService.generate.mockReturnValue('Keep practising'); + answerRepo.save.mockResolvedValue({}); + attemptRepo.save.mockResolvedValue({ ...activeAttempt, score: 0, status: AssessmentStatus.GRADED }); - expect(result.feedback).toBe('Excellent performance 🎉'); - expect(mockAttemptRepo.save).toHaveBeenCalledWith( - expect.objectContaining({ status: AssessmentStatus.GRADED, score: 10 }), + const result = (await service.submitAssessment('attempt-1', [])) as { attempt: any; feedback: string }; + + expect(attemptRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ score: 0, status: AssessmentStatus.GRADED }), ); + expect(feedbackService.generate).toHaveBeenCalledWith(0, 15); + expect(result.feedback).toBe('Keep practising'); }); }); + // ── getResults ─────────────────────────────────────────────────────────── + describe('getResults', () => { - it('should return attempt with answers', async () => { - const attempt = { id: 'attempt-1', answers: [] }; - mockAttemptRepo.findOne.mockResolvedValue(attempt); + it('returns attempt with answers and nested question relations', async () => { + const attempt = { id: 'attempt-1', answers: [{ id: 'ans-1', question: { id: 'q-1' } }] }; + attemptRepo.findOne.mockResolvedValue(attempt); const result = await service.getResults('attempt-1'); - expect(mockAttemptRepo.findOne).toHaveBeenCalledWith({ + expect(attemptRepo.findOne).toHaveBeenCalledWith({ where: { id: 'attempt-1' }, relations: ['answers', 'answers.question'], }); expect(result).toEqual(attempt); }); + + it('returns null when attempt does not exist', async () => { + attemptRepo.findOne.mockResolvedValue(null); + expect(await service.getResults('unknown')).toBeNull(); + }); }); -}); +}); \ No newline at end of file diff --git a/src/assessment/assessments.service.ts b/src/assessment/assessments.service.ts index 86ef3a0f..b24ff55f 100644 --- a/src/assessment/assessments.service.ts +++ b/src/assessment/assessments.service.ts @@ -9,6 +9,7 @@ import { FeedbackGenerationService } from './feedback/feedback-generation.servic import { Answer } from './entities/answer.entity'; import { ScoreCalculationService } from './scoring/score-calculation.service'; import { Question } from './entities/question.entity'; +import { AnalyticsService } from '../analytics/analytics.service'; /** * Provides assessment operations. @@ -24,13 +25,11 @@ export class AssessmentsService { private readonly answerRepo: Repository, private readonly scoringService: ScoreCalculationService, private readonly feedbackService: FeedbackGenerationService, + private readonly analytics: AnalyticsService, ) {} /** * Starts assessment. - * @param studentId The student identifier. - * @param assessmentId The assessment identifier. - * @returns The operation result. */ async startAssessment(studentId: string, assessmentId: string) { const assessment = await this.assessmentRepo.findOne({ @@ -38,6 +37,7 @@ export class AssessmentsService { relations: ['questions'], }); + const attempt = await this.attemptRepo.save({ if (!assessment) { throw new ResourceNotFoundException('Assessment', assessmentId); } @@ -48,44 +48,36 @@ export class AssessmentsService { status: AssessmentStatus.IN_PROGRESS, startedAt: new Date(), }); + + this.analytics.recordAssessmentStarted(assessmentId); + + return attempt; } /** - * Retrieves all matching results. - * @returns The matching results. + * Retrieves all assessments. */ async findAll(): Promise { - return await this.assessmentRepo.find({ - relations: ['questions'], - }); + return this.assessmentRepo.find({ relations: ['questions'] }); } /** - * Retrieves the requested record. - * @param id The identifier. - * @returns The resulting assessment. + * Retrieves an assessment by id. */ async findOne(id: string): Promise { - return await this.assessmentRepo.findOne({ - where: { id }, - relations: ['questions'], - }); + return this.assessmentRepo.findOne({ where: { id }, relations: ['questions'] }); } /** - * Retrieves records by their identifiers. - * @param ids The identifiers. - * @returns The matching results. + * Retrieves assessments by ids. */ async findByIds(ids: string[]): Promise { if (ids.length === 0) return []; - return await this.assessmentRepo.findByIds(ids); + return this.assessmentRepo.findByIds(ids); } /** - * Creates a new record. - * @param data The data to process. - * @returns The resulting assessment. + * Creates a new assessment. */ async create(data: any): Promise { const assessment = this.assessmentRepo.create(data); @@ -94,10 +86,7 @@ export class AssessmentsService { } /** - * Updates the requested record. - * @param id The identifier. - * @param data The data to process. - * @returns The resulting assessment. + * Updates an assessment. */ async update(id: string, data: any): Promise { await this.assessmentRepo.update(id, data); @@ -105,14 +94,11 @@ export class AssessmentsService { } /** - * Removes the requested record. - * @param id The identifier. + * Soft-deletes an assessment and its questions. */ async remove(id: string): Promise { const assessment = await this.findOne(id); - if (!assessment) { - return; - } + if (!assessment) return; await this.assessmentRepo.manager.transaction(async (manager) => { await manager @@ -126,10 +112,7 @@ export class AssessmentsService { } /** - * Submits assessment. - * @param attemptId The attempt identifier. - * @param answers The answers. - * @returns The operation result. + * Submits an assessment attempt, grades answers, and generates feedback. */ async submitAssessment(attemptId: string, answers: any[]) { const attempt = await this.attemptRepo.findOne({ @@ -146,28 +129,28 @@ export class AssessmentsService { if (Date.now() > endTime) { attempt.status = AssessmentStatus.TIMED_OUT; + this.analytics.recordAssessmentTimedOut(attempt.assessment.id, attempt.startedAt); return this.attemptRepo.save(attempt); } let totalScore = 0; let maxScore = 0; + for (const question of attempt.assessment.questions) { const response = answers.find((a) => a.questionId === question.id)?.response; const score = this.scoringService.calculate(question, response); maxScore += question.points; totalScore += score; - await this.answerRepo.save({ - attempt, - question, - response, - awardedPoints: score, - }); + await this.answerRepo.save({ attempt, question, response, awardedPoints: score }); } attempt.score = totalScore; attempt.status = AssessmentStatus.GRADED; attempt.submittedAt = new Date(); + this.analytics.recordAssessmentSubmitted(attempt.assessment.id, attempt.startedAt); + this.analytics.recordAssessmentScore(totalScore, maxScore); + const feedback = this.feedbackService.generate(totalScore, maxScore); return { @@ -177,9 +160,7 @@ export class AssessmentsService { } /** - * Retrieves results. - * @param attemptId The attempt identifier. - * @returns The operation result. + * Retrieves attempt results with answers. */ getResults(attemptId: string) { return this.attemptRepo.findOne({ @@ -187,4 +168,4 @@ export class AssessmentsService { relations: ['answers', 'answers.question'], }); } -} +} \ No newline at end of file