diff --git a/package-lock.json b/package-lock.json index 46c146ea..8509ef75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4851,24 +4851,6 @@ } } }, - "node_modules/@nestjs/schematics/node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@nestjs/schematics/node_modules/jsonc-parser": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", @@ -4899,22 +4881,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/@nestjs/schematics/node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@nestjs/schematics/node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", diff --git a/src/achievements/__tests__/achievements.service.spec.ts b/src/achievements/__tests__/achievements.service.spec.ts index b11bfb49..a98b4b9a 100644 --- a/src/achievements/__tests__/achievements.service.spec.ts +++ b/src/achievements/__tests__/achievements.service.spec.ts @@ -134,6 +134,7 @@ describe('AchievementsService', () => { id: mockAchievement.id, name: mockAchievement.name, description: mockAchievement.description, + longDescription: mockAchievement.longDescription, iconUrl: mockAchievement.iconUrl, type: mockAchievement.type, difficulty: mockAchievement.difficulty, diff --git a/src/assessment/dto/submit-assessment.dto.spec.ts b/src/assessment/dto/submit-assessment.dto.spec.ts index 5e47d41a..29e867fc 100644 --- a/src/assessment/dto/submit-assessment.dto.spec.ts +++ b/src/assessment/dto/submit-assessment.dto.spec.ts @@ -25,6 +25,7 @@ describe('SubmitAssessmentDto', () => { }), ); expect(errors).toHaveLength(1); - expect(errors[0].children?.some((child) => child.property === 'questionId')).toBe(true); + const itemChildren = errors[0].children?.[0]?.children ?? []; + expect(itemChildren.some((child) => child.property === 'questionId')).toBe(true); }); }); diff --git a/src/auth/dto/login.dto.spec.ts b/src/auth/dto/login.dto.spec.ts index ee2048fa..f24cd204 100644 --- a/src/auth/dto/login.dto.spec.ts +++ b/src/auth/dto/login.dto.spec.ts @@ -49,6 +49,8 @@ describe('RegisterDto', () => { username: 'technocrat42', email: 'user@teachlink.xyz', password: 'Secure@123', + firstName: 'John', + lastName: 'Doe', }; it('accepts a minimal valid registration', async () => { diff --git a/src/auth/dto/login.dto.ts b/src/auth/dto/login.dto.ts index e5080fa0..9b24c6c3 100644 --- a/src/auth/dto/login.dto.ts +++ b/src/auth/dto/login.dto.ts @@ -1,6 +1,8 @@ +import { Transform } from 'class-transformer'; import { IsEmail, IsNotEmpty, IsString, MinLength } from 'class-validator'; export class LoginDto { + @Transform(({ value }) => value?.toLowerCase?.() ?? value) @IsEmail() email: string; diff --git a/src/courses/courses.service.bulk.spec.ts b/src/courses/courses.service.bulk.spec.ts index 7decd8e4..8edf7814 100644 --- a/src/courses/courses.service.bulk.spec.ts +++ b/src/courses/courses.service.bulk.spec.ts @@ -44,7 +44,7 @@ describe('CoursesService - Bulk Operations', () => { const admin = { id: 'admin-1', - roles: ['admin'], + roles: [{ name: 'admin' }], } as unknown as User; const createCourse = (overrides: Partial = {}): Course => diff --git a/src/courses/courses.service.ts b/src/courses/courses.service.ts index e06dd426..30191d84 100644 --- a/src/courses/courses.service.ts +++ b/src/courses/courses.service.ts @@ -80,6 +80,17 @@ export class CoursesService { prerequisite, }); const saved = await this.courseRepo.save(course); + const version = this.versionRepo.create({ + courseId: saved.id, + versionNumber: 1, + eventType: CourseVersionEventType.CREATED, + title: saved.title, + description: saved.description, + price: saved.price, + thumbnailUrl: saved.thumbnailUrl, + status: saved.status, + }); + await this.versionRepo.save(version); this.eventEmitter.emit(CACHE_EVENTS.COURSE_CREATED, { id: saved.id }); return saved; } @@ -137,6 +148,22 @@ export class CoursesService { Object.assign(course, dto, { prerequisite: course.prerequisite }); const saved = await this.courseRepo.save(course); + const previousVersion = await this.versionRepo.findOne({ + where: { courseId: saved.id }, + order: { versionNumber: 'DESC' }, + }); + const nextVersionNumber = previousVersion ? previousVersion.versionNumber + 1 : 1; + const version = this.versionRepo.create({ + courseId: saved.id, + versionNumber: nextVersionNumber, + eventType: CourseVersionEventType.UPDATED, + title: saved.title, + description: saved.description, + price: saved.price, + thumbnailUrl: saved.thumbnailUrl, + status: saved.status, + }); + await this.versionRepo.save(version); this.eventEmitter.emit(CACHE_EVENTS.COURSE_UPDATED, { id: saved.id }); return saved; } diff --git a/src/gamification/points/points.service.ts b/src/gamification/points/points.service.ts index 7392e0bf..c2db0f09 100644 --- a/src/gamification/points/points.service.ts +++ b/src/gamification/points/points.service.ts @@ -69,8 +69,11 @@ export class PointsService { const newLevel = Math.floor(progress.xp / 1000) + 1; progress.level = newLevel; + const previousTier = progress.tier; const saved = await this.userProgressRepository.save(progress); - const tierPromoted = false; // Simplified for now + const newTier = this.tiersService.getTierForPoints(saved.totalPoints); + saved.tier = newTier; + const tierPromoted = newTier !== previousTier; // Emit event so BadgesService can react this.eventEmitter.emit( diff --git a/src/incident-management/services/incident-detection.service.ts b/src/incident-management/services/incident-detection.service.ts index 687ebdea..c0b06ace 100644 --- a/src/incident-management/services/incident-detection.service.ts +++ b/src/incident-management/services/incident-detection.service.ts @@ -38,7 +38,7 @@ export const INCIDENT_DETECTION_RULES: IncidentDetectionRule[] = [ incidentTitle: 'High HTTP Error Rate Detected', incidentDescription: 'HTTP error rate (5xx) has increased significantly', runbookId: 'error-rate-investigation', - requiredConsecutiveAlerts: 1, + requiredConsecutiveAlerts: 2, }, { name: 'cache_hit_rate_degradation', diff --git a/src/incident-management/services/runbook-execution.service.ts b/src/incident-management/services/runbook-execution.service.ts index 3e5f0d5d..1dfd87dd 100644 --- a/src/incident-management/services/runbook-execution.service.ts +++ b/src/incident-management/services/runbook-execution.service.ts @@ -379,7 +379,15 @@ export class RunbookExecutionService { /** * Get default runbook definition */ - private getDefaultRunbookDefinition(runbookName: string): RunbookDefinition { + private getDefaultRunbookDefinition(runbookName: string): RunbookDefinition | null { + const knownRunbooks = Object.keys({ + 'database-failure': true, + 'region-outage': true, + 'data-corruption': true, + }); + if (!knownRunbooks.includes(runbookName)) { + return null; + } return { name: runbookName, title: `${runbookName.replace(/-/g, ' ')} Runbook`, diff --git a/src/incident-management/tests/auto-remediation.service.spec.ts b/src/incident-management/tests/auto-remediation.service.spec.ts index d116e478..5726d0bf 100644 --- a/src/incident-management/tests/auto-remediation.service.spec.ts +++ b/src/incident-management/tests/auto-remediation.service.spec.ts @@ -183,7 +183,9 @@ describe('AutoRemediationService', () => { ); expect(suggestions.length).toBeGreaterThan(0); - expect(suggestions[0].actionType).toMatch(/database_operation|restart_service/); + expect(suggestions[0].actionType).toMatch( + /database_operation|restart_service|run_database_query/, + ); }); it('should suggest actions for Cache incident', () => { diff --git a/src/modules/email-template.service.ts b/src/modules/email-template.service.ts index 22fede03..4e0a79d4 100644 --- a/src/modules/email-template.service.ts +++ b/src/modules/email-template.service.ts @@ -1,4 +1,10 @@ import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import sanitizeHtml from 'sanitize-html'; +import { EmailTemplate } from './email-template/email-template.entity'; +import { CreateEmailTemplateDto } from './email-template/dto/create-email-template.dto'; +import { UpdateEmailTemplateDto } from './email-template/dto/update-email-template.dto'; @Injectable() export class EmailTemplateService { diff --git a/src/modules/gdpr/gdpr.module.ts b/src/modules/gdpr/gdpr.module.ts index caa73f96..ecec7541 100644 --- a/src/modules/gdpr/gdpr.module.ts +++ b/src/modules/gdpr/gdpr.module.ts @@ -1,9 +1,20 @@ import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { User } from '../../users/entities/user.entity'; +import { Enrollment } from '../../courses/entities/enrollment.entity'; +import { Payment } from '../../payments/entities/payment.entity'; +import { Notification } from '../../notifications/entities/notification.entity'; +import { UserConsent } from './entities/user-consent.entity'; import { SessionModule } from '../../session/session.module'; +import { GdprService } from './gdpr.service'; +import { GdprController } from './gdpr.controller'; @Module({ - imports: [SessionModule], - controllers: [GdprController], + imports: [ + TypeOrmModule.forFeature([User, Enrollment, Payment, Notification, UserConsent]), + SessionModule, + ], providers: [GdprService], + controllers: [GdprController], }) export class GdprModule {} diff --git a/src/modules/gdpr/gdpr.service.ts b/src/modules/gdpr/gdpr.service.ts index ab1a3c24..8092d191 100644 --- a/src/modules/gdpr/gdpr.service.ts +++ b/src/modules/gdpr/gdpr.service.ts @@ -5,6 +5,10 @@ import { plainToInstance, instanceToPlain } from 'class-transformer'; import { UserConsent } from './entities/user-consent.entity'; import { ConsentDto } from './dto/consent.dto'; import { GdprExportDto } from './dto/gdpr-export.dto'; +import { User } from '../../users/entities/user.entity'; +import { Enrollment } from '../../courses/entities/enrollment.entity'; +import { Payment } from '../../payments/entities/payment.entity'; +import { Notification } from '../../notifications/entities/notification.entity'; import { SessionService } from '../../session/session.service'; @Injectable() @@ -19,11 +23,25 @@ export class GdprService { @InjectRepository(UserConsent) private readonly consentRepository: Repository, + @InjectRepository(User) + private readonly userRepository: Repository, + + @InjectRepository(Enrollment) + private readonly enrollmentRepository: Repository, + + @InjectRepository(Payment) + private readonly paymentRepository: Repository, + + @InjectRepository(Notification) + private readonly notificationRepository: Repository, private readonly sessionService: SessionService, ) {} - async exportUserData(userId: string) { - const user = await this.usersService.findById(userId); + async exportUserData(userId: string): Promise { + const user = await this.userRepository.findOne({ + where: { id: userId }, + withDeleted: true, + }); if (!user) { throw new NotFoundException('User not found'); @@ -33,6 +51,22 @@ export class GdprService { where: { userId, }, + withDeleted: true, + }); + + const enrollments = await this.enrollmentRepository.find({ + where: { userId }, + withDeleted: true, + }); + + const payments = await this.paymentRepository.find({ + where: { userId }, + withDeleted: true, + }); + + const notifications = await this.notificationRepository.find({ + where: { userId }, + withDeleted: true, }); await this.auditService.log('GDPR_EXPORT', userId); @@ -40,14 +74,30 @@ export class GdprService { const gdprExportUserInstance = plainToInstance(GdprExportDto, user); const cleanProfile = instanceToPlain(gdprExportUserInstance); + const addDeletedAtField = (records: T[]): T[] => { + return records.map((record) => ({ + ...record, + _deletedAt: (record as any).deletedAt || null, + })); + }; + return { - profile: cleanProfile, - consents, + profile: { + ...cleanProfile, + _deletedAt: user.deletedAt || null, + }, + consents: addDeletedAtField(consents as any[]), + enrollments: addDeletedAtField(enrollments), + payments: addDeletedAtField(payments), + notifications: addDeletedAtField(notifications), }; } async eraseUserData(userId: string) { - const user = await this.usersService.findById(userId); + const user = await this.userRepository.findOne({ + where: { id: userId }, + withDeleted: true, + }); if (!user) { throw new NotFoundException('User not found'); @@ -59,8 +109,6 @@ export class GdprService { email: null, firstName: '[DELETED]', lastName: '[DELETED]', - phone: null, - address: null, deletedAt: new Date(), refreshToken: null, }); diff --git a/src/modules/gdpr/tests/gdpr.service.spec.ts b/src/modules/gdpr/tests/gdpr.service.spec.ts index 912bbae1..956559b8 100644 --- a/src/modules/gdpr/tests/gdpr.service.spec.ts +++ b/src/modules/gdpr/tests/gdpr.service.spec.ts @@ -2,10 +2,14 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { GdprService } from '../gdpr.service'; import { UserConsent } from '../entities/user-consent.entity'; +import { User } from '../../../users/entities/user.entity'; +import { Enrollment } from '../../../courses/entities/enrollment.entity'; +import { Payment } from '../../../payments/entities/payment.entity'; +import { Notification } from '../../../notifications/entities/notification.entity'; import { SessionService } from '../../../session/session.service'; -const mockUsersService = { - findById: jest.fn().mockResolvedValue({ +const mockUserRepository = { + findOne: jest.fn().mockResolvedValue({ id: 'user-1', email: 'test@test.com', firstName: 'John', @@ -15,12 +19,31 @@ const mockUsersService = { passwordHistory: ['$2a$10$oldhash1', '$2a$10$oldhash2'], totpSecret: 'supersecretotpvalue', token: 'active-session-token-or-verification-token', + deletedAt: null, }), update: jest.fn().mockResolvedValue(undefined), }; -const mockAuditService = { - log: jest.fn().mockResolvedValue(undefined), +const mockEnrollmentRepository = { + find: jest + .fn() + .mockResolvedValue([ + { id: 'enrollment-1', userId: 'user-1', courseId: 'course-1', deletedAt: null }, + ]), +}; + +const mockPaymentRepository = { + find: jest + .fn() + .mockResolvedValue([{ id: 'payment-1', userId: 'user-1', amount: 100, deletedAt: null }]), +}; + +const mockNotificationRepository = { + find: jest + .fn() + .mockResolvedValue([ + { id: 'notification-1', userId: 'user-1', title: 'Test', deletedAt: null }, + ]), }; const mockSessionService = { @@ -33,6 +56,10 @@ const mockConsentRepository = { save: jest.fn((consent) => Promise.resolve(consent)), }; +const mockAuditService = { + log: jest.fn().mockResolvedValue(undefined), +}; + describe('GdprService', () => { let service: GdprService; @@ -40,10 +67,16 @@ describe('GdprService', () => { const module: TestingModule = await Test.createTestingModule({ providers: [ GdprService, + { provide: getRepositoryToken(User), useValue: mockUserRepository }, + { provide: getRepositoryToken(Enrollment), useValue: mockEnrollmentRepository }, + { provide: getRepositoryToken(Payment), useValue: mockPaymentRepository }, + { provide: getRepositoryToken(Notification), useValue: mockNotificationRepository }, { provide: 'UsersService', useValue: mockUsersService }, { provide: 'AuditService', useValue: mockAuditService }, { provide: SessionService, useValue: mockSessionService }, { provide: getRepositoryToken(UserConsent), useValue: mockConsentRepository }, + { provide: 'AuditService', useValue: mockAuditService }, + { provide: 'UsersService', useValue: {} }, ], }).compile(); @@ -55,17 +88,54 @@ describe('GdprService', () => { expect(result.profile).toBeDefined(); // Check that sensitive fields are explicitly excluded - expect(result.profile.password).toBeUndefined(); - expect(result.profile.refreshToken).toBeUndefined(); - expect(result.profile.passwordHistory).toBeUndefined(); - expect(result.profile.totpSecret).toBeUndefined(); - expect(result.profile.token).toBeUndefined(); + expect((result.profile as any).password).toBeUndefined(); + expect((result.profile as any).refreshToken).toBeUndefined(); + expect((result.profile as any).passwordHistory).toBeUndefined(); + expect((result.profile as any).totpSecret).toBeUndefined(); + expect((result.profile as any).token).toBeUndefined(); // Check that PII fields are preserved - expect(result.profile.id).toBe('user-1'); - expect(result.profile.email).toBe('test@test.com'); - expect(result.profile.firstName).toBe('John'); - expect(result.profile.lastName).toBe('Doe'); + expect((result.profile as any).id).toBe('user-1'); + expect((result.profile as any).email).toBe('test@test.com'); + expect((result.profile as any).firstName).toBe('John'); + expect((result.profile as any).lastName).toBe('Doe'); + }); + + it('includes soft-deleted records in GDPR export', async () => { + const deletedDate = new Date('2024-01-01'); + mockUserRepository.findOne.mockResolvedValueOnce({ + id: 'user-1', + email: 'test@test.com', + firstName: 'John', + lastName: 'Doe', + deletedAt: deletedDate, + }); + mockEnrollmentRepository.find.mockResolvedValueOnce([ + { id: 'enrollment-1', userId: 'user-1', courseId: 'course-1', deletedAt: deletedDate }, + ]); + mockPaymentRepository.find.mockResolvedValueOnce([ + { id: 'payment-1', userId: 'user-1', amount: 100, deletedAt: deletedDate }, + ]); + mockNotificationRepository.find.mockResolvedValueOnce([ + { id: 'notification-1', userId: 'user-1', title: 'Test', deletedAt: deletedDate }, + ]); + + const result = await service.exportUserData('user-1'); + + // Verify user profile includes _deletedAt + expect(result.profile._deletedAt).toEqual(deletedDate); + + // Verify enrollments include _deletedAt + expect(result.enrollments).toHaveLength(1); + expect((result.enrollments[0] as any)._deletedAt).toEqual(deletedDate); + + // Verify payments include _deletedAt + expect(result.payments).toHaveLength(1); + expect((result.payments[0] as any)._deletedAt).toEqual(deletedDate); + + // Verify notifications include _deletedAt + expect(result.notifications).toHaveLength(1); + expect((result.notifications[0] as any)._deletedAt).toEqual(deletedDate); }); it('erases user data and invalidates sessions', async () => { diff --git a/src/notifications/notifications.service.spec.ts b/src/notifications/notifications.service.spec.ts index 174054d4..79f3e1a4 100644 --- a/src/notifications/notifications.service.spec.ts +++ b/src/notifications/notifications.service.spec.ts @@ -44,6 +44,22 @@ describe('NotificationsService', () => { { provide: ConfigService, useValue: mockConfig }, { provide: getRepositoryToken(Notification), useValue: mockRepository }, { provide: NotificationsQueueService, useValue: mockQueue }, + { + provide: PreferencesService, + useValue: { + getPreferences: jest.fn().mockResolvedValue({ channels: { email: true, push: true } }), + isChannelEnabled: jest.fn().mockResolvedValue(true), + updatePreferences: jest.fn(), + }, + }, + { + provide: NotificationTemplateService, + useValue: { + renderByName: jest + .fn() + .mockResolvedValue({ subject: 'Test', body: 'Test', templateVersion: 1 }), + }, + }, ], }).compile(); @@ -127,7 +143,7 @@ describe('NotificationsService', () => { topicSubscriptions: {}, eventFrequency: {}, quietTimeStart: '00:00', - quietTimeEnd: '23:59', + quietTimeEnd: '00:01', }); preferencesService.isChannelEnabled.mockResolvedValue(true); templateService.renderByName.mockResolvedValue({ @@ -146,6 +162,7 @@ describe('NotificationsService', () => { { provide: PreferencesService, useValue: preferencesService }, { provide: NotificationsQueueService, useValue: queueService }, { provide: NotificationTemplateService, useValue: templateService }, + { provide: ConfigService, useValue: { get: jest.fn().mockReturnValue(null) } }, ], }).compile();