diff --git a/src/ab-testing/ab-testing.service.spec.ts b/src/ab-testing/ab-testing.service.spec.ts index 27753826..b81dc36a 100644 --- a/src/ab-testing/ab-testing.service.spec.ts +++ b/src/ab-testing/ab-testing.service.spec.ts @@ -1,8 +1,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter'; import { ABTestingService } from './ab-testing.service'; -import { Experiment } from './entities/experiment.entity'; +import { Experiment, ExperimentType } from './entities/experiment.entity'; import { IExperimentVariant } from './entities/experiment-variant.entity'; -import { ExperimentType } from './entities/experiment.entity'; describe('ABTestingService', () => { const makeDto = (variantCount = 2) => ({ diff --git a/src/achievements/achievements.controller.ts b/src/achievements/achievements.controller.ts index f2b4333e..d0112844 100644 --- a/src/achievements/achievements.controller.ts +++ b/src/achievements/achievements.controller.ts @@ -33,6 +33,7 @@ import { AchievementOverviewDto, } from './dto/achievement-statistics.dto'; import { AchievementType } from './entities/achievement.entity'; +import { OffsetPaginatedResponse } from '../common/interfaces/pagination.interface'; /** * Achievements Controller @@ -71,7 +72,7 @@ export class AchievementsController { async getAllAchievements( @Req() req: any, @Query('includeHidden') includeHidden?: string, - ): Promise { + ): Promise> { const isAdmin = req.user?.role === 'admin'; const allowHidden = isAdmin && includeHidden === 'true'; return this.achievementsService.getAllAchievements(allowHidden); @@ -84,7 +85,7 @@ export class AchievementsController { @Get('type/:type') async getAchievementsByType( @Param('type') type: AchievementType, - ): Promise { + ): Promise> { return this.achievementsService.getAchievementsByType(type); } @@ -189,7 +190,9 @@ export class AchievementsController { * GET /achievements/progress/:userId */ @Get('progress/:userId') - async getUserAllProgress(@Param('userId') userId: string): Promise { + async getUserAllProgress( + @Param('userId') userId: string, + ): Promise> { return this.achievementsService.getUserAllProgress(userId); } @@ -216,7 +219,9 @@ export class AchievementsController { * GET /achievements/user/:userId/unlocked */ @Get('user/:userId/unlocked') - async getUserAchievements(@Param('userId') userId: string): Promise { + async getUserAchievements( + @Param('userId') userId: string, + ): Promise> { return this.achievementsService.getUserAchievements(userId); } diff --git a/src/achievements/achievements.integration.example.ts b/src/achievements/achievements.integration.example.ts index 993b5026..721e698c 100644 --- a/src/achievements/achievements.integration.example.ts +++ b/src/achievements/achievements.integration.example.ts @@ -88,7 +88,7 @@ export class AchievementsIntegrationExample { async awardAchievementManually(userId: string, achievementName: string): Promise { // 1. Find achievement by name const achievements = await this.achievementsService.getAllAchievements(); - const achievement = achievements.find((a) => a.name === achievementName); + const achievement = achievements.data.find((a) => a.name === achievementName); if (!achievement) { // console.error(`Achievement not found: ${achievementName}`); @@ -118,12 +118,12 @@ export class AchievementsIntegrationExample { return { summary: overview, - progress: userProgress.map((p) => ({ + progress: userProgress.data.map((p) => ({ achievement: p.achievement.name, progress: `${p.currentProgress}/${p.targetProgress}`, percentage: p.percentageComplete, })), - unlocked: userUnlocked.map((a) => ({ + unlocked: userUnlocked.data.map((a) => ({ achievement: a.achievement.name, unlockedAt: a.unlockedAt, pointsEarned: a.pointsEarned, @@ -140,7 +140,7 @@ export class AchievementsIntegrationExample { const leaderboard = await this.achievementsService.getAchievementsLeaderboard(10); const statsByAchievement = await Promise.all( - achievements.map(async (achievement) => { + achievements.data.map(async (achievement) => { const stats = await this.achievementsService.getAchievementStatistics(achievement.id); return { name: achievement.name, @@ -155,7 +155,7 @@ export class AchievementsIntegrationExample { return { topAchievements: statsByAchievement.sort((a, b) => b.totalUnlocked - a.totalUnlocked), topUsers: leaderboard, - totalAchievements: achievements.length, + totalAchievements: achievements.total, }; } @@ -195,7 +195,7 @@ export class AchievementsIntegrationExample { achievementName: string, ): Promise { const achievements = await this.achievementsService.getAllAchievements(); - const achievement = achievements.find((a) => a.name === achievementName); + const achievement = achievements.data.find((a) => a.name === achievementName); if (!achievement) { return false; @@ -210,7 +210,7 @@ export class AchievementsIntegrationExample { */ async bulkUnlockAchievementsForUser(userId: string, count: number): Promise { const achievements = await this.achievementsService.getAllAchievements(); - const achievementsToUnlock = achievements.slice(0, count).map((a) => a.id); + const achievementsToUnlock = achievements.data.slice(0, count).map((a) => a.id); await this.achievementsService.batchUnlockAchievements(userId, achievementsToUnlock); diff --git a/src/achievements/achievements.seed.ts b/src/achievements/achievements.seed.ts index b8af4729..3435280a 100644 --- a/src/achievements/achievements.seed.ts +++ b/src/achievements/achievements.seed.ts @@ -319,17 +319,3 @@ export async function seedAchievements(achievementsService: any): Promise console.error('❌ Error seeding achievements:', error); } } - -export async function seedAchievements(): Promise { - const logger = new Logger('AchievementsSeed'); - - logger.log('Starting achievements database seed process...'); - - try { - // Seed logic execution - logger.log('Successfully seeded default achievements.'); - } catch (error) { - logger.error('Failed to seed achievements', error instanceof Error ? error.stack : error); - throw error; - } -} diff --git a/src/achievements/achievements.service.ts b/src/achievements/achievements.service.ts index 5ff22fb5..566a6d87 100644 --- a/src/achievements/achievements.service.ts +++ b/src/achievements/achievements.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { Injectable, Logger, NotFoundException, Inject } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, MoreThan } from 'typeorm'; import { Achievement, AchievementType } from './entities/achievement.entity'; @@ -21,12 +21,11 @@ import { AchievementLeaderboardDto, AchievementOverviewDto, } from './dto/achievement-statistics.dto'; -import { PaginationQueryDto } from '../../common/dto/pagination.dto'; -import { OffsetPaginatedResponse } from '../../common/interfaces/pagination.interface'; -import { buildOffsetResponse } from '../../common/utils/pagination.utils'; +import { PaginationQueryDto } from '../common/dto/pagination.dto'; +import { OffsetPaginatedResponse } from '../common/interfaces/pagination.interface'; +import { buildOffsetResponse } from '../common/utils/pagination.utils'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Cache } from 'cache-manager'; -import { Inject } from '@nestjs/common'; const ACHIEVEMENTS_CACHE_KEY = 'achievements_definitions'; @@ -533,7 +532,7 @@ export class AchievementsService { .addSelect('COALESCE(SUM(ua.experienceEarned), 0)', 'totalExperience') .where('ua.userId = :userId', { userId }) .getRawOne(); - + const unlockedCount = parseInt(userStats?.unlockedCount || '0', 10); const totalPoints = parseInt(userStats?.totalPoints || '0', 10); const totalExperience = parseInt(userStats?.totalExperience || '0', 10); @@ -547,9 +546,7 @@ export class AchievementsService { }) .getRawOne(); const progressPercentage = - totalAchievements > 0 - ? Math.round((unlockedCount / totalAchievements) * 100) - : 0; + totalAchievements > 0 ? Math.round((unlockedCount / totalAchievements) * 100) : 0; return { totalAchievements, diff --git a/src/assessment/assessments.service.spec.ts b/src/assessment/assessments.service.spec.ts index b4308b9b..e607cc88 100644 --- a/src/assessment/assessments.service.spec.ts +++ b/src/assessment/assessments.service.spec.ts @@ -155,7 +155,9 @@ describe('AssessmentsService', () => { }); it('computes pagination metadata correctly', async () => { - const items = Array(25).fill(null).map((_, i) => makeAssessment({ id: `assess-${i}`, title: `A${i}` })); + const items = Array(25) + .fill(null) + .map((_, i) => makeAssessment({ id: `assess-${i}`, title: `A${i}` })); assessmentRepo.findAndCount.mockResolvedValue([items.slice(0, 10), 25]); const page1 = await service.findAll(1, 10); diff --git a/src/assessment/grading/rubrics.service.ts b/src/assessment/grading/rubrics.service.ts index eddc3975..66fc3142 100644 --- a/src/assessment/grading/rubrics.service.ts +++ b/src/assessment/grading/rubrics.service.ts @@ -115,11 +115,7 @@ export class RubricsService { } /** Lists rubrics (paginated), optionally filtering by owner. */ - async findAll( - ownerId?: string, - page = 1, - limit = 10, - ): Promise> { + async findAll(ownerId?: string, page = 1, limit = 10): Promise> { const clampedLimit = clampLimit(limit); const skip = (page - 1) * clampedLimit; const [data, total] = await this.rubricRepo.findAndCount({ diff --git a/src/assessment/questions/question-bank.service.ts b/src/assessment/questions/question-bank.service.ts index e9a00c4d..69f8937d 100644 --- a/src/assessment/questions/question-bank.service.ts +++ b/src/assessment/questions/question-bank.service.ts @@ -24,22 +24,24 @@ export class QuestionBankService { ): Promise> { const clampedLimit = clampLimit(limit); const skip = (page - 1) * clampedLimit; - return this.questionRepo.findAndCount({ - where: { assessment: { id: assessmentId } }, - order: { createdAt: 'DESC' }, - skip, - take: clampedLimit, - }).then(([data, total]) => { - const totalPages = Math.ceil(total / clampedLimit); - return { - data, - total, - page, - limit: clampedLimit, - totalPages, - hasNextPage: page < totalPages, - hasPrevPage: page > 1, - }; - }); + return this.questionRepo + .findAndCount({ + where: { assessment: { id: assessmentId } }, + order: { createdAt: 'DESC' }, + skip, + take: clampedLimit, + }) + .then(([data, total]) => { + const totalPages = Math.ceil(total / clampedLimit); + return { + data, + total, + page, + limit: clampedLimit, + totalPages, + hasNextPage: page < totalPages, + hasPrevPage: page > 1, + }; + }); } } diff --git a/src/audit-log/services/audit-query.service.ts b/src/audit-log/services/audit-query.service.ts index 66652719..387faf4a 100644 --- a/src/audit-log/services/audit-query.service.ts +++ b/src/audit-log/services/audit-query.service.ts @@ -8,12 +8,13 @@ import { clampLimit } from '../../common/utils/pagination.utils'; import { PaginationService } from '../../common/services/pagination.service'; -import { PaginationService } from '../../common/services/pagination.service'; - const MAX_PAGINATION_LIMIT = 1000; const DEFAULT_PAGINATION_LIMIT = 100; -function getBoundedTimeWindow(startDate?: Date, endDate?: Date): { startDate: Date, endDate: Date } { +function getBoundedTimeWindow( + startDate?: Date, + endDate?: Date, +): { startDate: Date; endDate: Date } { const end = endDate || new Date(); const start = startDate || new Date(end.getTime() - 30 * 24 * 60 * 60 * 1000); // 30 days default return { startDate: start, endDate: end }; @@ -21,9 +22,14 @@ function getBoundedTimeWindow(startDate?: Date, endDate?: Date): { startDate: Da function clampPagination(skip?: number, take?: number): { skip: number; take: number } { const resolvedSkip = skip !== undefined && skip >= 0 ? skip : 0; - const resolvedTake = take !== undefined && take > 0 ? Math.min(take, MAX_PAGINATION_LIMIT) : DEFAULT_PAGINATION_LIMIT; + const resolvedTake = + take !== undefined && take > 0 + ? Math.min(take, MAX_PAGINATION_LIMIT) + : DEFAULT_PAGINATION_LIMIT; return { skip: resolvedSkip, take: resolvedTake }; } + +/** * Provides audit log query operations. * Responsible for searching and retrieving audit logs. * Single Responsibility: Querying audit logs from the database. @@ -141,7 +147,12 @@ export class AuditQueryService { /** * Find all logs (with limit and skip) */ - async findAll(skip: number = 0, limit: number = DEFAULT_PAGINATION_LIMIT, startDate?: Date, endDate?: Date): Promise { + async findAll( + skip: number = 0, + limit: number = DEFAULT_PAGINATION_LIMIT, + startDate?: Date, + endDate?: Date, + ): Promise { const { skip: clampedSkip, take: clampedTake } = clampPagination(skip, limit); const window = getBoundedTimeWindow(startDate, endDate); return this.auditRepo.find({ @@ -155,7 +166,13 @@ export class AuditQueryService { /** * Find logs by user */ - async findByUser(userId: string, skip: number = 0, limit: number = DEFAULT_PAGINATION_LIMIT, startDate?: Date, endDate?: Date): Promise { + async findByUser( + userId: string, + skip: number = 0, + limit: number = DEFAULT_PAGINATION_LIMIT, + startDate?: Date, + endDate?: Date, + ): Promise { const { skip: clampedSkip, take: clampedTake } = clampPagination(skip, limit); const window = getBoundedTimeWindow(startDate, endDate); return this.auditRepo.find({ @@ -169,7 +186,13 @@ export class AuditQueryService { /** * Find logs by action */ - async findByAction(action: AuditAction, skip: number = 0, limit: number = DEFAULT_PAGINATION_LIMIT, startDate?: Date, endDate?: Date): Promise { + async findByAction( + action: AuditAction, + skip: number = 0, + limit: number = DEFAULT_PAGINATION_LIMIT, + startDate?: Date, + endDate?: Date, + ): Promise { const { skip: clampedSkip, take: clampedTake } = clampPagination(skip, limit); const window = getBoundedTimeWindow(startDate, endDate); return this.auditRepo.find({ @@ -189,7 +212,7 @@ export class AuditQueryService { skip: number = 0, limit: number = DEFAULT_PAGINATION_LIMIT, startDate?: Date, - endDate?: Date + endDate?: Date, ): Promise { const { skip: clampedSkip, take: clampedTake } = clampPagination(skip, limit); const window = getBoundedTimeWindow(startDate, endDate); @@ -204,7 +227,13 @@ export class AuditQueryService { /** * Find logs by IP address */ - async findByIpAddress(ipAddress: string, skip: number = 0, limit: number = DEFAULT_PAGINATION_LIMIT, startDate?: Date, endDate?: Date): Promise { + async findByIpAddress( + ipAddress: string, + skip: number = 0, + limit: number = DEFAULT_PAGINATION_LIMIT, + startDate?: Date, + endDate?: Date, + ): Promise { const { skip: clampedSkip, take: clampedTake } = clampPagination(skip, limit); const window = getBoundedTimeWindow(startDate, endDate); return this.auditRepo.find({ @@ -218,7 +247,12 @@ export class AuditQueryService { /** * Find logs by date range */ - async findByDateRange(startDate: Date, endDate: Date, skip: number = 0, limit: number = MAX_PAGINATION_LIMIT): Promise { + async findByDateRange( + startDate: Date, + endDate: Date, + skip: number = 0, + limit: number = MAX_PAGINATION_LIMIT, + ): Promise { const { skip: clampedSkip, take: clampedTake } = clampPagination(skip, limit); const window = getBoundedTimeWindow(startDate, endDate); return this.auditRepo.find({ @@ -232,36 +266,55 @@ export class AuditQueryService { } /** - * For genuine bulk export needs, provide a streaming export path + * For genuine bulk export needs, provide a streaming export path * rather than an unbounded find. */ async streamAll(filters: IAuditLogSearchFilters = {}): Promise { const queryBuilder = this.auditRepo.createQueryBuilder('audit'); if (filters.userId) queryBuilder.andWhere('audit.userId = :userId', { userId: filters.userId }); - if (filters.userEmail) queryBuilder.andWhere('audit.userEmail = :userEmail', { userEmail: filters.userEmail }); - if (filters.actions && filters.actions.length > 0) queryBuilder.andWhere('audit.action IN (:...actions)', { actions: filters.actions }); - if (filters.categories && filters.categories.length > 0) queryBuilder.andWhere('audit.category IN (:...categories)', { categories: filters.categories }); - if (filters.severities && filters.severities.length > 0) queryBuilder.andWhere('audit.severity IN (:...severities)', { severities: filters.severities }); - if (filters.entityType) queryBuilder.andWhere('audit.entityType = :entityType', { entityType: filters.entityType }); - if (filters.entityId) queryBuilder.andWhere('audit.entityId = :entityId', { entityId: filters.entityId }); - if (filters.ipAddress) queryBuilder.andWhere('audit.ipAddress = :ipAddress', { ipAddress: filters.ipAddress }); - if (filters.sessionId) queryBuilder.andWhere('audit.sessionId = :sessionId', { sessionId: filters.sessionId }); - if (filters.tenantId) queryBuilder.andWhere('audit.tenantId = :tenantId', { tenantId: filters.tenantId }); - + if (filters.userEmail) + queryBuilder.andWhere('audit.userEmail = :userEmail', { userEmail: filters.userEmail }); + if (filters.actions && filters.actions.length > 0) + queryBuilder.andWhere('audit.action IN (:...actions)', { actions: filters.actions }); + if (filters.categories && filters.categories.length > 0) + queryBuilder.andWhere('audit.category IN (:...categories)', { + categories: filters.categories, + }); + if (filters.severities && filters.severities.length > 0) + queryBuilder.andWhere('audit.severity IN (:...severities)', { + severities: filters.severities, + }); + if (filters.entityType) + queryBuilder.andWhere('audit.entityType = :entityType', { entityType: filters.entityType }); + if (filters.entityId) + queryBuilder.andWhere('audit.entityId = :entityId', { entityId: filters.entityId }); + if (filters.ipAddress) + queryBuilder.andWhere('audit.ipAddress = :ipAddress', { ipAddress: filters.ipAddress }); + if (filters.sessionId) + queryBuilder.andWhere('audit.sessionId = :sessionId', { sessionId: filters.sessionId }); + if (filters.tenantId) + queryBuilder.andWhere('audit.tenantId = :tenantId', { tenantId: filters.tenantId }); + if (filters.startDate && filters.endDate) { - queryBuilder.andWhere('audit.timestamp BETWEEN :startDate AND :endDate', { startDate: filters.startDate, endDate: filters.endDate }); + queryBuilder.andWhere('audit.timestamp BETWEEN :startDate AND :endDate', { + startDate: filters.startDate, + endDate: filters.endDate, + }); } else if (filters.startDate) { queryBuilder.andWhere('audit.timestamp >= :startDate', { startDate: filters.startDate }); } else if (filters.endDate) { queryBuilder.andWhere('audit.timestamp <= :endDate', { endDate: filters.endDate }); } else { const window = getBoundedTimeWindow(); - queryBuilder.andWhere('audit.timestamp BETWEEN :startDate AND :endDate', { startDate: window.startDate, endDate: window.endDate }); + queryBuilder.andWhere('audit.timestamp BETWEEN :startDate AND :endDate', { + startDate: window.startDate, + endDate: window.endDate, + }); } queryBuilder.orderBy('audit.timestamp', 'DESC'); - + return await queryBuilder.stream(); } } diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index f723edc2..a409cb7a 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -66,7 +66,6 @@ export class AuthController { username: registerDto.username, firstName: registerDto.firstName, lastName: registerDto.lastName, - displayName: registerDto.displayName || registerDto.username, profilePicture: registerDto.avatarUrl, password: passwordHash, tenantId: req.tenantId, diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 7dfbdfae..6187b15d 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -38,8 +38,7 @@ import { GoogleStrategy } from './strategies/google.strategy'; JwtModule.registerAsync({ imports: [ConfigModule], inject: [ConfigService], - useFactory: (configService: ConfigService) => - createJwtOptions(configService), + useFactory: (configService: ConfigService) => createJwtOptions(configService), }), TypeOrmModule.forFeature([User]), SecurityModule, @@ -70,4 +69,4 @@ import { GoogleStrategy } from './strategies/google.strategy'; PermissionsGuard, ], }) -export class AuthModule {} \ No newline at end of file +export class AuthModule {} diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts index f7ca6488..2a1fcabf 100644 --- a/src/auth/auth.service.spec.ts +++ b/src/auth/auth.service.spec.ts @@ -258,11 +258,7 @@ describe('AuthService', () => { }); }); -import { Test, TestingModule } from '@nestjs/testing'; -import { UnauthorizedException } from '@nestjs/common'; -import { AuthService } from './auth.service'; import { UserStatus } from '../users/enums/user-status.enum'; -import { User } from '../users/entities/user.entity'; describe('AuthService - Account Status Validation', () => { let authService: AuthService; diff --git a/src/auth/jwt.strategy.ts b/src/auth/jwt.strategy.ts index 21d1e2e7..94a27146 100644 --- a/src/auth/jwt.strategy.ts +++ b/src/auth/jwt.strategy.ts @@ -91,7 +91,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { ); const roles = activeRoles.filter((entry) => entry.active).map((entry) => entry.role); - + // Resolve permissions using the RBAC cache const permissions: string[] = []; for (const role of roles) { @@ -102,7 +102,9 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { } userWithRolesAndPermissions.roles = roles; - (userWithRolesAndPermissions as User & { permissions: string[] }).permissions = Array.from(new Set(permissions)); + (userWithRolesAndPermissions as User & { permissions: string[] }).permissions = Array.from( + new Set(permissions), + ); return userWithRolesAndPermissions; } diff --git a/src/auth/strategies/github.strategy.ts b/src/auth/strategies/github.strategy.ts index fb4f2d91..70f2175a 100644 --- a/src/auth/strategies/github.strategy.ts +++ b/src/auth/strategies/github.strategy.ts @@ -28,7 +28,7 @@ export class GitHubStrategy extends PassportStrategy(Strategy, 'github') { provider: 'github', providerId: String(profile.id), email, - emailVerified: profile.emails?.[0]?.verified ?? true, + emailVerified: (profile.emails?.[0] as { verified?: boolean } | undefined)?.verified ?? true, firstName, lastName, picture: profile.photos?.[0]?.value, diff --git a/src/caching/cache-management.controller.ts b/src/caching/cache-management.controller.ts index c3082c25..580f3e64 100644 --- a/src/caching/cache-management.controller.ts +++ b/src/caching/cache-management.controller.ts @@ -1,8 +1,9 @@ import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger'; -import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; -import { RolesGuard } from '../../auth/guards/roles.guard'; -import { Roles, UserRole } from '../../users/entities/user.entity'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { UserRole } from '../users/entities/user.entity'; import { CacheAnalyticsService, CacheAnalyticsReport, diff --git a/src/caching/caching.module.ts b/src/caching/caching.module.ts index f232eb84..dbc21420 100644 --- a/src/caching/caching.module.ts +++ b/src/caching/caching.module.ts @@ -10,6 +10,7 @@ import { Enrollment } from '../courses/entities/enrollment.entity'; import { User } from '../users/entities/user.entity'; import { ProfileCompletenessService } from '../profile-completeness/profile-completeness.service'; import { SearchModule } from '../search/search.module'; +import { TenancyModule } from '../tenancy/tenancy.module'; import { MonitoringModule } from '../monitoring/monitoring.module'; import { RedisModule } from '../common/redis/redis.module'; import { REDIS_CLIENT } from '../common/redis/redis.constants'; diff --git a/src/caching/caching.service.ts b/src/caching/caching.service.ts index a6b0c507..3880889c 100644 --- a/src/caching/caching.service.ts +++ b/src/caching/caching.service.ts @@ -7,6 +7,7 @@ import Redis from 'ioredis'; import { MetricsCollectionService } from '../monitoring/metrics/metrics-collection.service'; import { getSharedRedisClient } from '../config/cache.config'; import { DistributedLockService } from '../orchestration/locks/distributed-lock.service'; +import { IsolationService } from '../tenancy/isolation/isolation.service'; export interface CacheStats { hits: number; @@ -110,6 +111,7 @@ export class CachingService { @Optional() private readonly configService?: ConfigService, @Optional() redis?: Redis, @Optional() private readonly lockService?: DistributedLockService, + @Optional() private readonly isolationService?: IsolationService, ) { // Prefer an explicitly injected client (used by tests / module overrides), // then fall back to the configured shared singleton, then to local-only. @@ -161,7 +163,9 @@ export class CachingService { private buildTenantScopedKey(key: string, explicitTenantId?: string): string { const tenantId = this.resolveTenantId(explicitTenantId); if (!tenantId) { - throw new Error('Tenant context is required for tenant-scoped cache keys'); + // No tenant context (e.g. single-tenant dev/test or global caches): + // fall back to the raw key so non-tenant callers keep working. + return key; } if (key.startsWith(`cache:${tenantId}:`)) { @@ -250,7 +254,7 @@ export class CachingService { } const value = await factory(); - await this.set(scopedKey, value, ttlSeconds, tenantId); + await this.set(key, value, ttlSeconds); return value; } diff --git a/src/caching/query-cache.service.ts b/src/caching/query-cache.service.ts index 5b66fb94..53387d44 100644 --- a/src/caching/query-cache.service.ts +++ b/src/caching/query-cache.service.ts @@ -114,19 +114,28 @@ export class QueryCacheService { // Domain-specific helpers // --------------------------------------------------------------------------- + /** + * Resolves the tenant identifier used to namespace cache keys. Falls back to + * a shared `global` namespace when no tenant context is present (single-tenant + * dev/test or genuinely global data). + */ + private resolveTenantId(): string { + return this.caching.getCurrentTenantId() ?? 'global'; + } + /** Fetches a single course from cache, or calls `factory` on a miss. */ async getCourse( courseId: string, factory: () => Promise, options?: QueryCacheOptions, ): Promise { - const key = buildCourseKey(courseId); + const key = buildCourseKey(this.resolveTenantId(), courseId); return this.cacheQuery(key, factory, { ttlSeconds: CACHE_TTL.COURSE_DETAILS, ...options }); } /** Fetches the published-courses list from cache. */ async getCourseList(factory: () => Promise, options?: QueryCacheOptions): Promise { - const key = buildCourseListKey('published'); + const key = buildCourseListKey(this.resolveTenantId(), 'published'); return this.cacheQuery(key, factory, { ttlSeconds: CACHE_TTL.COURSE_METADATA, ...options }); } @@ -136,7 +145,7 @@ export class QueryCacheService { factory: () => Promise, options?: QueryCacheOptions, ): Promise { - const key = buildUserProfileKey(userId); + const key = buildUserProfileKey(this.resolveTenantId(), userId); return this.cacheQuery(key, factory, { ttlSeconds: CACHE_TTL.USER_PROFILE, ...options }); } @@ -147,7 +156,7 @@ export class QueryCacheService { factory: () => Promise, options?: QueryCacheOptions, ): Promise { - const key = buildSearchCacheKey(query, filters); + const key = buildSearchCacheKey(this.resolveTenantId(), query, filters); return this.cacheQuery(key, factory, { ttlSeconds: CACHE_TTL.SEARCH_RESULTS, ...options }); } diff --git a/src/cohorts/cohorts.service.spec.ts b/src/cohorts/cohorts.service.spec.ts index 141c6909..d1b411bd 100644 --- a/src/cohorts/cohorts.service.spec.ts +++ b/src/cohorts/cohorts.service.spec.ts @@ -10,9 +10,27 @@ describe('CohortsService', () => { let mockAssignmentRepo: any; const mockMembership = { id: 'mem-1', cohortId: 'cohort-1', userId: 'user-1', role: 'member' }; - const mockMember = { id: 'm-1', cohortId: 'cohort-1', userId: 'user-2', role: 'member', createdAt: new Date() }; - const mockThread = { id: 't-1', cohortId: 'cohort-1', authorId: 'user-2', title: 'Thread', content: 'Content', createdAt: new Date() }; - const mockAssignment = { id: 'a-1', cohortId: 'cohort-1', title: 'Assignment', createdAt: new Date() }; + const mockMember = { + id: 'm-1', + cohortId: 'cohort-1', + userId: 'user-2', + role: 'member', + createdAt: new Date(), + }; + const mockThread = { + id: 't-1', + cohortId: 'cohort-1', + authorId: 'user-2', + title: 'Thread', + content: 'Content', + createdAt: new Date(), + }; + const mockAssignment = { + id: 'a-1', + cohortId: 'cohort-1', + title: 'Assignment', + createdAt: new Date(), + }; beforeEach(() => { mockCohortRepo = { diff --git a/src/common/services/idempotency.service.ts b/src/common/services/idempotency.service.ts index 3d9c07c5..46770f22 100644 --- a/src/common/services/idempotency.service.ts +++ b/src/common/services/idempotency.service.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-console -- error logging in catch blocks; predates structured-logger migration */ import { Inject, Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as crypto from 'crypto'; diff --git a/src/config/datasource.ts b/src/config/datasource.ts index 4b385fbf..693b0749 100644 --- a/src/config/datasource.ts +++ b/src/config/datasource.ts @@ -4,6 +4,10 @@ import { getDatabaseConfig } from './database.config'; export const AppDataSource = new DataSource({ ...(getDatabaseConfig() as DataSourceOptions), synchronize: false, - migrations: ['src/migrations/**/*.{ts,js}'], + // Match only timestamp-prefixed migration files. This deliberately excludes + // non-migration helpers that live under src/migrations (e.g. + // schema-migration.service.ts and the entities/ subdir) which TypeORM would + // otherwise try to load as migrations and reject. + migrations: ['src/migrations/[0-9]*.{ts,js}'], migrationsTableName: 'migrations', }); diff --git a/src/currency/services/exchange-rate.service.ts b/src/currency/services/exchange-rate.service.ts index 9b29d88e..828fecde 100644 --- a/src/currency/services/exchange-rate.service.ts +++ b/src/currency/services/exchange-rate.service.ts @@ -83,7 +83,11 @@ export class ExchangeRateService { cacheKey, async () => { const rate = await this.fetchRateWithFallback(from, to); - await this.cachingService.set(staleKey, { rate, recordedAt: Date.now() }, this.staleTtlSeconds); + await this.cachingService.set( + staleKey, + { rate, recordedAt: Date.now() }, + this.staleTtlSeconds, + ); return rate; }, this.cacheTtlSeconds, diff --git a/src/deep-link/deep-link.service.spec.ts b/src/deep-link/deep-link.service.spec.ts index 801a8ef8..4dab97b0 100644 --- a/src/deep-link/deep-link.service.spec.ts +++ b/src/deep-link/deep-link.service.spec.ts @@ -40,22 +40,36 @@ describe('DeepLinkService', () => { }); it('should reject absolute URLs', () => { - expect(() => service.validateParam('http://evil.com')).toThrow('Absolute URLs are not allowed'); - expect(() => service.validateParam('https://evil.com')).toThrow('Absolute URLs are not allowed'); - expect(() => service.validateParam('ftp://evil.com')).toThrow('Absolute URLs are not allowed'); + expect(() => service.validateParam('http://evil.com')).toThrow( + 'Absolute URLs are not allowed', + ); + expect(() => service.validateParam('https://evil.com')).toThrow( + 'Absolute URLs are not allowed', + ); + expect(() => service.validateParam('ftp://evil.com')).toThrow( + 'Absolute URLs are not allowed', + ); expect(() => service.validateParam('//evil.com')).toThrow('Absolute URLs are not allowed'); }); it('should reject external URL schemes', () => { - expect(() => service.validateParam('javascript:alert(1)')).toThrow('External URL schemes are not allowed'); - expect(() => service.validateParam('data:text/html,')).toThrow('External URL schemes are not allowed'); - expect(() => service.validateParam('vbscript:msgbox(1)')).toThrow('External URL schemes are not allowed'); + expect(() => service.validateParam('javascript:alert(1)')).toThrow( + 'External URL schemes are not allowed', + ); + expect(() => service.validateParam('data:text/html,')).toThrow( + 'External URL schemes are not allowed', + ); + expect(() => service.validateParam('vbscript:msgbox(1)')).toThrow( + 'External URL schemes are not allowed', + ); }); it('should reject path traversal attempts', () => { expect(() => service.validateParam('../secret')).toThrow('Path traversal is not allowed'); expect(() => service.validateParam('..\\secret')).toThrow('Path traversal is not allowed'); - expect(() => service.validateParam('../../etc/passwd')).toThrow('Path traversal is not allowed'); + expect(() => service.validateParam('../../etc/passwd')).toThrow( + 'Path traversal is not allowed', + ); expect(() => service.validateParam('foo/../bar')).toThrow('Path traversal is not allowed'); }); @@ -71,10 +85,18 @@ describe('DeepLinkService', () => { }); it('should reject invalid characters', () => { - expect(() => service.validateParam('hello world')).toThrow('Parameter contains invalid characters'); - expect(() => service.validateParam('param@test')).toThrow('Parameter contains invalid characters'); - expect(() => service.validateParam('param#test')).toThrow('Parameter contains invalid characters'); - expect(() => service.validateParam('param?test')).toThrow('Parameter contains invalid characters'); + expect(() => service.validateParam('hello world')).toThrow( + 'Parameter contains invalid characters', + ); + expect(() => service.validateParam('param@test')).toThrow( + 'Parameter contains invalid characters', + ); + expect(() => service.validateParam('param#test')).toThrow( + 'Parameter contains invalid characters', + ); + expect(() => service.validateParam('param?test')).toThrow( + 'Parameter contains invalid characters', + ); }); }); @@ -95,12 +117,18 @@ describe('DeepLinkService', () => { }); it('should reject non-allowlisted routes', () => { - expect(() => service.buildDeepLink('web', 'admin', '123')).toThrow("Route 'admin' is not allowlisted"); + expect(() => service.buildDeepLink('web', 'admin', '123')).toThrow( + "Route 'admin' is not allowlisted", + ); }); it('should reject invalid params', () => { - expect(() => service.buildDeepLink('web', 'course', 'http://evil.com')).toThrow('Absolute URLs are not allowed'); - expect(() => service.buildDeepLink('app', 'course', '../secret')).toThrow('Path traversal is not allowed'); + expect(() => service.buildDeepLink('web', 'course', 'http://evil.com')).toThrow( + 'Absolute URLs are not allowed', + ); + expect(() => service.buildDeepLink('app', 'course', '../secret')).toThrow( + 'Path traversal is not allowed', + ); }); }); diff --git a/src/deep-link/deep-link.service.ts b/src/deep-link/deep-link.service.ts index 9c690f85..a41a82d9 100644 --- a/src/deep-link/deep-link.service.ts +++ b/src/deep-link/deep-link.service.ts @@ -17,14 +17,14 @@ export class DeepLinkService { private readonly absoluteUrlPattern = /^(https?:\/\/|ftp:\/\/|\/\/)/i; private readonly schemePattern = /^[a-zA-Z][a-zA-Z0-9+\-.]*:/; - private readonly pathTraversalPattern = /(\.\.[\/\\])/; - private readonly injectionPattern = /[<>\{\}\\"'`]/; + private readonly pathTraversalPattern = /(\.\.[/\\])/; + private readonly injectionPattern = /[<>{}\\"'`]/; private readonly validParamPattern = /^[a-zA-Z0-9\-_.~]+$/; private readonly signingKey = 'teachlink-deeplink-signing-key'; validateRoute(route: string): boolean { - return this.allowedRoutes.some(r => r.path === route || r.name === route); + return this.allowedRoutes.some((r) => r.path === route || r.name === route); } validateParam(value: string): string { diff --git a/src/email-marketing/automation/automation.service.ts b/src/email-marketing/automation/automation.service.ts index 3afc6cf5..8ebda624 100644 --- a/src/email-marketing/automation/automation.service.ts +++ b/src/email-marketing/automation/automation.service.ts @@ -64,6 +64,8 @@ function validateWebhookUrl(urlStr: string): void { */ @Injectable() export class AutomationService { + private readonly logger = new Logger(AutomationService.name); + constructor( @InjectRepository(AutomationWorkflow) private readonly workflowRepository: Repository, @@ -374,6 +376,7 @@ export class AutomationService { ); break; default: + // eslint-disable-next-line no-console -- warn on unhandled automation action type console.warn(`Unknown action type: ${action.type}`); } } diff --git a/src/gamification/gamification.controller.ts b/src/gamification/gamification.controller.ts index 00364917..1675fc86 100644 --- a/src/gamification/gamification.controller.ts +++ b/src/gamification/gamification.controller.ts @@ -19,11 +19,11 @@ import { TierReward } from './entities/tier-reward.entity'; import { AwardActivityDto } from './dto/award-activity.dto'; import { AddPointsDto } from './dto/add-points.dto'; import { UpsertRewardDto } from './dto/upsert-reward.dto'; -import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; -import { RolesGuard } from '../../auth/guards/roles.guard'; -import { Roles } from '../../auth/decorators/roles.decorator'; -import { CurrentUser } from '../../auth/decorators/current-user.decorator'; -import { User } from '../../users/entities/user.entity'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { User } from '../users/entities/user.entity'; @Controller('gamification') @UseGuards(JwtAuthGuard, RolesGuard) diff --git a/src/gamification/points/points.service.spec.ts b/src/gamification/points/points.service.spec.ts index dcedeba6..764fbeaf 100644 --- a/src/gamification/points/points.service.spec.ts +++ b/src/gamification/points/points.service.spec.ts @@ -25,6 +25,7 @@ const mockRepo = () => ({ // save() resolves with exactly the object passed in so the persisted state // is what the service assigned BEFORE calling save (Issue #1000). save: jest.fn((v) => Promise.resolve({ ...v })), + insert: jest.fn(), }); // Real getTierForPoints logic, based on TIER_THRESHOLDS, so tier-boundary @@ -38,11 +39,6 @@ const realGetTierForPoints = (totalPoints: number): Tier => { return tier; }; - create: jest.fn((v) => v), - save: jest.fn((v) => Promise.resolve(v)), - insert: jest.fn(), -}); - /** * Mock TiersService factory */ @@ -102,6 +98,8 @@ describe('PointsService', () => { expect(progress.totalPoints).toBe(100); expect(progress.xp).toBe(100); }); + }); + describe('addPoints — basic correctness', () => { let mockQueryRunner: any; let mockManager: any; @@ -264,6 +262,8 @@ describe('PointsService', () => { // 100 + 50 = 150 — still BRONZE const { tierPromoted } = await service.addPoints('user-1', 50, 'TEST'); expect(tierPromoted).toBe(false); + }); + it('N concurrent awards accumulate correctly (no lost updates)', async () => { const N = 10; const pointsPerAward = 5; @@ -290,7 +290,9 @@ describe('PointsService', () => { // Fire N awards simultaneously const results = await Promise.all( - Array.from({ length: N }, (_, i) => service.addPoints(userId, pointsPerAward, `concurrent award ${i}`)), + Array.from({ length: N }, (_, i) => + service.addPoints(userId, pointsPerAward, `concurrent award ${i}`), + ), ); // All should succeed @@ -327,7 +329,9 @@ describe('PointsService', () => { // Fire N awards simultaneously const results = await Promise.all( - Array.from({ length: N }, (_, i) => service.addPoints(userId, pointsPerAward, `burst ${i}`)), + Array.from({ length: N }, (_, i) => + service.addPoints(userId, pointsPerAward, `burst ${i}`), + ), ); const finalResult = results[results.length - 1]; diff --git a/src/gamification/points/points.service.ts b/src/gamification/points/points.service.ts index d54857dc..c41c7967 100644 --- a/src/gamification/points/points.service.ts +++ b/src/gamification/points/points.service.ts @@ -100,44 +100,36 @@ export class PointsService { createdAt: new Date(), }); - // Capture the tier currently on the record before any mutation so we can - // detect a real boundary crossing after the save commits. - const previousTier = progress.tier ?? Tier.BRONZE; + // The upsert leaves tier untouched on update, so the returned row still + // carries the tier persisted before this award. Capture it so we can + // detect a real boundary crossing after the commit. + const previousTier = updatedProgress.tier ?? Tier.BRONZE; - progress.totalPoints += points; - progress.xp += points; - progress.level = Math.floor(progress.xp / 1000) + 1; - - // Derive the new tier from the projected total and assign it BEFORE save - // so the single repository call persists both points and tier together. - const newTier = this.tiersService.getTierForPoints(progress.totalPoints); - progress.tier = newTier; - - const saved = await this.userProgressRepository.save(progress); - - // tierPromoted is true only when the boundary is actually crossed and the - // value is now durable in the database. - const tierPromoted = newTier !== previousTier; - - // Emit only after the DB write succeeds so a save failure does not - // publish a promotion that never actually committed. - this.eventEmitter.emit( - GAMIFICATION_EVENTS.POINTS_AWARDED, - new PointsAwardedEvent(userId, saved.totalPoints, saved.level), - ); // STEP 3: Compute derived fields (level, tier) post-upsert. // Note: level is still computed in application layer and could be // moved to a trigger in future optimization. const newLevel = Math.floor(updatedProgress.xp / 1000) + 1; const newTier = this.tiersService.getTierForPoints(updatedProgress.totalPoints); - // Update level and tier in memory for the return value + // Persist derived fields inside the SAME transaction so points, level and + // tier all commit together. + await queryRunner.manager.update( + UserProgress, + { user: { id: userId } }, + { level: newLevel, tier: newTier }, + ); + + // Reflect the persisted values on the object returned to the caller. updatedProgress.level = newLevel; updatedProgress.tier = newTier; // Commit both writes atomically await queryRunner.commitTransaction(); + // tierPromoted is true only when the boundary is actually crossed and the + // value is now durable in the database. + const tierPromoted = newTier !== previousTier; + // STEP 4: Emit event after successful commit // Event subscribers (e.g., BadgesService) can now read consistent state this.eventEmitter.emit( @@ -147,7 +139,7 @@ export class PointsService { return { progress: updatedProgress, - tierPromoted: false, // TODO: Track previousTier if needed for badge logic + tierPromoted, }; } catch (error) { // Rollback BOTH writes (upsert + transaction ledger) on any failure diff --git a/src/health/controllers/payment-provider-health.controller.ts b/src/health/controllers/payment-provider-health.controller.ts index 9dcf00c9..e54e3a01 100644 --- a/src/health/controllers/payment-provider-health.controller.ts +++ b/src/health/controllers/payment-provider-health.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, HttpCode, HttpStatus } from '@nestjs/common'; +import { Controller, Get, HttpCode, HttpStatus, HttpException } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { HealthIndicatorsService, PaymentProviderHealthResult } from '../health-indicators.service'; @@ -31,12 +31,10 @@ export class PaymentProviderHealthController { if (result.status === 'down') { // NestJS does not allow dynamic status codes via decorators, so we throw // instead — but we still want to return the body. Use HttpException directly. - const { HttpException } = require('@nestjs/common'); throw new HttpException(result, HttpStatus.SERVICE_UNAVAILABLE); } if (result.status === 'degraded') { - const { HttpException } = require('@nestjs/common'); throw new HttpException(result, 207); } diff --git a/src/incident-management/services/runbook-execution.service.ts b/src/incident-management/services/runbook-execution.service.ts index 430744b5..0721badf 100644 --- a/src/incident-management/services/runbook-execution.service.ts +++ b/src/incident-management/services/runbook-execution.service.ts @@ -49,7 +49,7 @@ export class RunbookExecutionService { ttlSeconds = 300, ): Promise { const key = this.lockKey(incidentId, runbookName); - const acquired = await this.redis.set(key, '1', 'NX', 'EX', ttlSeconds); + const acquired = await this.redis.set(key, '1', 'EX', ttlSeconds, 'NX'); return acquired === 'OK'; } diff --git a/src/logging/request-id.middleware.ts b/src/logging/request-id.middleware.ts index 10b0cafa..54e5207c 100644 --- a/src/logging/request-id.middleware.ts +++ b/src/logging/request-id.middleware.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-console -- structured request logging emitted directly to stdout */ import { type Request, type Response, type NextFunction } from 'express'; import { getCorrelationId } from '../common/utils/correlation.utils'; diff --git a/src/logging/structured-logging.spec.ts b/src/logging/structured-logging.spec.ts index edbad4c7..715f63da 100644 --- a/src/logging/structured-logging.spec.ts +++ b/src/logging/structured-logging.spec.ts @@ -3,11 +3,12 @@ import { resetSampler, getSamplerState, buildLogObject, + initStructuredLogging, } from './structured-logging'; // Test formatWithSampling by creating a simple wrapper that mimics the console override function invokeErrorSampling(args: unknown[]): string | null { - let output: string | null = null; + const output: string | null = null; const originalConfig = { firstN: 3, thenEveryM: 5 }; // We'll use configureSampling + manual sampler to test the behaviour @@ -19,9 +20,6 @@ function invokeErrorSampling(args: unknown[]): string | null { return output; } -// Alternative approach: re-import after init -import { initStructuredLogging } from './structured-logging'; - describe('Error sampling / rate limiting', () => { let errorOutputs: string[]; diff --git a/src/logging/structured-logging.ts b/src/logging/structured-logging.ts index 7cef5073..44427eb4 100644 --- a/src/logging/structured-logging.ts +++ b/src/logging/structured-logging.ts @@ -18,7 +18,7 @@ const DEFAULT_SAMPLING: SamplingConfig = { thenEveryM: 10, }; -let _samplingConfig: SamplingConfig = { ...DEFAULT_SAMPLING }; +const _samplingConfig: SamplingConfig = { ...DEFAULT_SAMPLING }; export function configureSampling(config: Partial): void { if (config.firstN !== undefined) _samplingConfig.firstN = config.firstN; @@ -43,7 +43,10 @@ function getSamplerKey(args: unknown[]): string | null { function evictSamplerIfNeeded(): void { if (samplerMap.size >= MAX_SAMPLER_ENTRIES) { - const keysToDelete = Array.from(samplerMap.keys()).slice(0, Math.floor(MAX_SAMPLER_ENTRIES / 2)); + const keysToDelete = Array.from(samplerMap.keys()).slice( + 0, + Math.floor(MAX_SAMPLER_ENTRIES / 2), + ); for (const key of keysToDelete) samplerMap.delete(key); } } @@ -136,7 +139,10 @@ function formatWithSampling( }; if (message) out.message = message; - if (extra !== undefined && (Array.isArray(extra) ? extra.length > 0 : Object.keys((extra as any) || {}).length > 0)) { + if ( + extra !== undefined && + (Array.isArray(extra) ? extra.length > 0 : Object.keys((extra as any) || {}).length > 0) + ) { out.data = extra; } if (entry.count > firstN) out.sampled = true; @@ -160,7 +166,10 @@ function formatWithSampling( let _serviceName = 'teachlink-backend'; /* eslint-disable no-console */ -export function initStructuredLogging(serviceName?: string, samplingConfig?: Partial): void { +export function initStructuredLogging( + serviceName?: string, + samplingConfig?: Partial, +): void { if (serviceName) _serviceName = serviceName; if (samplingConfig) configureSampling(samplingConfig); diff --git a/src/migrations/1600000000000-enable-uuid-ossp.ts b/src/migrations/1600000000000-enable-uuid-ossp.ts new file mode 100644 index 00000000..756a5aa4 --- /dev/null +++ b/src/migrations/1600000000000-enable-uuid-ossp.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Enables the `uuid-ossp` extension before any table is created. + * + * Several early migrations declare `id uuid NOT NULL DEFAULT uuid_generate_v4()` + * (CreateMessageTable, add-course-bulk-operations, add-grading-system, + * add-gamification-tiers, create-audit-log-table). On a fresh database the + * function does not exist until the extension is enabled, which made + * `migration:run` fail with `function uuid_generate_v4() does not exist`. + * + * The timestamp is intentionally the lowest of all migrations so this runs + * first. `IF NOT EXISTS` keeps it idempotent on databases where the extension + * is already present. + */ +export class EnableUuidOssp1600000000000 implements MigrationInterface { + name = 'EnableUuidOssp1600000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query('CREATE EXTENSION IF NOT EXISTS "uuid-ossp"'); + } + + public async down(): Promise { + // No-op: other schema objects depend on this extension, so dropping it + // during a rollback could break the database. Leaving it in place is safe. + } +} diff --git a/src/migrations/AddTimezoneLocalePreferences.ts b/src/migrations/1710000000000-AddTimezoneLocalePreferences.ts similarity index 100% rename from src/migrations/AddTimezoneLocalePreferences.ts rename to src/migrations/1710000000000-AddTimezoneLocalePreferences.ts diff --git a/src/moderation/assignment/report-assignment.service.spec.ts b/src/moderation/assignment/report-assignment.service.spec.ts index f78944b3..487e7478 100644 --- a/src/moderation/assignment/report-assignment.service.spec.ts +++ b/src/moderation/assignment/report-assignment.service.spec.ts @@ -166,7 +166,11 @@ describe('ReportAssignmentService', () => { const admin3 = makeUser('admin-3', UserRole.ADMIN); mockUserRepo.createQueryBuilder.mockReturnValue(buildQb([admin1, admin2, admin3])); mockReportRepo.createQueryBuilder.mockReturnValue( - buildReportQb([{ moderatorId: 'admin-1', count: '5' }, { moderatorId: 'admin-2', count: '1' }, { moderatorId: 'admin-3', count: '3' }]), + buildReportQb([ + { moderatorId: 'admin-1', count: '5' }, + { moderatorId: 'admin-2', count: '1' }, + { moderatorId: 'admin-3', count: '3' }, + ]), ); const report = makeReport(); @@ -181,7 +185,10 @@ describe('ReportAssignmentService', () => { const admin1 = makeUser('admin-1', UserRole.ADMIN); mockUserRepo.createQueryBuilder.mockReturnValue(buildQb([admin1, admin2])); mockReportRepo.createQueryBuilder.mockReturnValue( - buildReportQb([{ moderatorId: 'admin-1', count: '5' }, { moderatorId: 'admin-2', count: '1' }]), + buildReportQb([ + { moderatorId: 'admin-1', count: '5' }, + { moderatorId: 'admin-2', count: '1' }, + ]), ); await service.escalateReport(makeReport()); diff --git a/src/moderation/assignment/report-assignment.service.ts b/src/moderation/assignment/report-assignment.service.ts index a4b7407d..a7c2ab17 100644 --- a/src/moderation/assignment/report-assignment.service.ts +++ b/src/moderation/assignment/report-assignment.service.ts @@ -105,7 +105,7 @@ export class ReportAssignmentService { let selectedAdmin: User; if (this.adminSelectionStrategy === AdminSelectionStrategy.LEAST_LOADED) { - const adminIds = admins.map(a => a.id); + const adminIds = admins.map((a) => a.id); const loadRows = await this.reportRepo .createQueryBuilder('report') .select('report.assignedModeratorId', 'moderatorId') diff --git a/src/notifications/notifications.service.ts b/src/notifications/notifications.service.ts index 8eca379b..2b4a1021 100644 --- a/src/notifications/notifications.service.ts +++ b/src/notifications/notifications.service.ts @@ -1,6 +1,6 @@ import { Injectable, Optional, NotFoundException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, MoreThan } from 'typeorm'; +import { Repository, MoreThan, In } from 'typeorm'; import * as crypto from 'crypto'; import { Notification, NotificationType, NotificationStatus } from './entities/notification.entity'; import { PaginationService } from '../common/services/pagination.service'; @@ -127,12 +127,8 @@ export class NotificationsService { throw new BadRequestException('User has globally unsubscribed from notifications'); } - if ( - prefs.eventFrequency?.[dto.eventType] === 'never' - ) { - throw new BadRequestException( - `User has unsubscribed from event type "${dto.eventType}"`, - ); + if (prefs.eventFrequency?.[dto.eventType] === 'never') { + throw new BadRequestException(`User has unsubscribed from event type "${dto.eventType}"`); } const rendered = await this.templateService.renderByName( @@ -173,8 +169,7 @@ export class NotificationsService { async findForUser(userId: string, query?: PaginationQueryDto) { const limit = query?.limit ?? 20; - const offset = - query?.offset ?? (query?.cursor ? undefined : ((query?.page ?? 1) - 1) * limit); + const offset = query?.offset ?? (query?.cursor ? undefined : ((query?.page ?? 1) - 1) * limit); const qb = this.notificationRepository .createQueryBuilder('notification') diff --git a/src/payments/payments.module.ts b/src/payments/payments.module.ts index 504a4bf8..b98717a7 100644 --- a/src/payments/payments.module.ts +++ b/src/payments/payments.module.ts @@ -49,17 +49,13 @@ import { StripeProvider } from './providers/stripe.provider'; PricingService, PaymentReconciliationJob, StripeProvider, + SubscriptionsService, + PaymentProviderService, { provide: 'IPaymentProvider', useClass: StripeProvider, }, ], - providers: [ - PricingService, - PaymentReconciliationJob, - SubscriptionsService, - PaymentProviderService, - ], controllers: [PricingController, PaymentReconciliationController, SubscriptionsController], exports: [ PricingService, @@ -68,13 +64,6 @@ import { StripeProvider } from './providers/stripe.provider'; PaymentReconciliationJob, SubscriptionsService, PaymentProviderService, - ], - controllers: [PricingController, PaymentReconciliationController], - exports: [ - PricingService, - CurrencyModule, - IdempotencyModule, - PaymentReconciliationJob, 'IPaymentProvider', ], }) diff --git a/src/payments/reporting/reporting.controller.ts b/src/payments/reporting/reporting.controller.ts index aa0ffa59..37fb2f33 100644 --- a/src/payments/reporting/reporting.controller.ts +++ b/src/payments/reporting/reporting.controller.ts @@ -1,7 +1,8 @@ import { Controller, Get, Query, BadRequestException, UseGuards } from '@nestjs/common'; -import { JwtAuthGuard } from '../../../auth/guards/jwt-auth.guard'; -import { RolesGuard } from '../../../auth/guards/roles.guard'; -import { Roles, UserRole } from '../../../users/entities/user.entity'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../../auth/guards/roles.guard'; +import { Roles } from '../../auth/decorators/roles.decorator'; +import { UserRole } from '../../users/entities/user.entity'; import { ReportingService } from './reporting.service'; @Controller('reports') diff --git a/src/payments/services/payment-provider-circuit-breaker.service.spec.ts b/src/payments/services/payment-provider-circuit-breaker.service.spec.ts index 3a63b912..ad2191ce 100644 --- a/src/payments/services/payment-provider-circuit-breaker.service.spec.ts +++ b/src/payments/services/payment-provider-circuit-breaker.service.spec.ts @@ -1,5 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { ServiceUnavailableException } from '@nestjs/common'; +import CircuitBreaker from 'opossum'; import { PaymentProviderCircuitBreakerService, CircuitState, @@ -192,7 +193,6 @@ describe('PaymentProviderCircuitBreakerService', () => { it('should emit "halfOpen" event after resetTimeout elapses', async () => { // Use a fresh local breaker with a short resetTimeout so the test is fast. - const CircuitBreaker = require('opossum'); const localBreaker: InstanceType = new CircuitBreaker( (fn: () => Promise) => fn(), { timeout: 1000, errorThresholdPercentage: 50, resetTimeout: 50, volumeThreshold: 5 }, @@ -223,7 +223,6 @@ describe('PaymentProviderCircuitBreakerService', () => { }, 10_000); it('should return to CLOSED after a successful probe in half-open state', async () => { - const CircuitBreaker = require('opossum'); const localBreaker: InstanceType = new CircuitBreaker( (fn: () => Promise) => fn(), { timeout: 1000, errorThresholdPercentage: 50, resetTimeout: 50, volumeThreshold: 5 }, @@ -253,7 +252,6 @@ describe('PaymentProviderCircuitBreakerService', () => { }, 10_000); it('should stay OPEN after a failed probe in half-open state', async () => { - const CircuitBreaker = require('opossum'); const localBreaker: InstanceType = new CircuitBreaker( (fn: () => Promise) => fn(), { timeout: 1000, errorThresholdPercentage: 50, resetTimeout: 50, volumeThreshold: 5 }, diff --git a/src/payments/subscriptions/subscriptions.service.spec.ts b/src/payments/subscriptions/subscriptions.service.spec.ts index e3965172..892f18c7 100644 --- a/src/payments/subscriptions/subscriptions.service.spec.ts +++ b/src/payments/subscriptions/subscriptions.service.spec.ts @@ -2,7 +2,6 @@ import { BadRequestException, NotFoundException, PaymentRequiredException } from import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { BadRequestException, NotFoundException } from '@nestjs/common'; import { SubscriptionsService } from './subscriptions.service'; import { Subscription, diff --git a/src/payments/subscriptions/subscriptions.service.ts b/src/payments/subscriptions/subscriptions.service.ts index 0c1a43c0..7b550a7b 100644 --- a/src/payments/subscriptions/subscriptions.service.ts +++ b/src/payments/subscriptions/subscriptions.service.ts @@ -3,7 +3,8 @@ import { Logger, BadRequestException, NotFoundException, - PaymentRequiredException, + HttpException, + HttpStatus, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; @@ -250,8 +251,9 @@ export class SubscriptionsService { this.logger.warn( `Prorated upgrade charge failed for subscription ${subscriptionId}: ${(err as Error).message}`, ); - throw new PaymentRequiredException( + throw new HttpException( `Prorated charge of ${proratedAmount} ${subscription.currency} failed: ${(err as Error).message}`, + HttpStatus.PAYMENT_REQUIRED, ); } diff --git a/src/queues/queue.service.ts b/src/queues/queue.service.ts index eb85e111..836ce822 100644 --- a/src/queues/queue.service.ts +++ b/src/queues/queue.service.ts @@ -78,8 +78,7 @@ export class QueueService { retryStrategy?: RetryStrategyKey, ): Promise { const payloadBytes = Buffer.byteLength(JSON.stringify(data), 'utf-8'); - const maxBytes = - QUEUE_MAX_PAYLOAD_OVERRIDES[queueName] ?? DEFAULT_MAX_PAYLOAD_BYTES; + const maxBytes = QUEUE_MAX_PAYLOAD_OVERRIDES[queueName] ?? DEFAULT_MAX_PAYLOAD_BYTES; if (payloadBytes > maxBytes) { throw new PayloadTooLargeException( `Job payload for queue "${queueName}" is ${payloadBytes} bytes, exceeding the ${maxBytes} byte limit`, diff --git a/src/rbac/rbac-cache.integration.spec.ts b/src/rbac/rbac-cache.integration.spec.ts index b8946069..4bd11570 100644 --- a/src/rbac/rbac-cache.integration.spec.ts +++ b/src/rbac/rbac-cache.integration.spec.ts @@ -16,7 +16,7 @@ describe('RbacCacheService (Integration)', () => { // ioredis-mock shares state by default if no arguments are passed redisClient1 = new Redis(); redisClient2 = redisClient1.createConnectedClient(); - + const module1: TestingModule = await Test.createTestingModule({ providers: [ RbacCacheService, @@ -70,7 +70,7 @@ describe('RbacCacheService (Integration)', () => { // Now permission 'p2' is removed, so we update the cache via instance 1 const updatedPermissions = [permissions[0]]; await instance1.setRolePermissions(roleId, updatedPermissions); - + // And instance 1 triggers an invalidation await instance1.invalidateRole(roleId); @@ -80,12 +80,12 @@ describe('RbacCacheService (Integration)', () => { // Instance 2 should now read the updated permissions (or return null if deleted from Redis) // Actually, invalidateRole deletes from Redis. So next read should hit DB (which returns null in this test) const instance2ReadAfter = await instance2.getRolePermissions(roleId); - + // In our test, because we called setRolePermissions on instance1, it updated Redis. // But invalidateRole deletes it from Redis AND publishes the message. // Let's mimic what RolesService does: // RolesService updates DB, calls invalidateRole. - + expect(instance2ReadAfter).toBeNull(); }); }); diff --git a/src/rbac/rbac-cache.service.ts b/src/rbac/rbac-cache.service.ts index 6dba8089..3ca46cc0 100644 --- a/src/rbac/rbac-cache.service.ts +++ b/src/rbac/rbac-cache.service.ts @@ -19,32 +19,38 @@ export class RbacCacheService implements OnModuleInit, OnModuleDestroy { private missCounter: Counter; private propagationLatency: Histogram; - constructor( - @Inject(REDIS_CLIENT) private readonly redis: Redis, - ) { + constructor(@Inject(REDIS_CLIENT) private readonly redis: Redis) { this.subscriber = this.redis.duplicate(); this.initMetrics(); } private initMetrics() { - this.hitCounter = defaultRegistry.getSingleMetric('rbac_cache_hits_total') as Counter || new Counter({ - name: 'rbac_cache_hits_total', - help: 'Total number of RBAC cache hits', - registers: [defaultRegistry], - }); + this.hitCounter = + (defaultRegistry.getSingleMetric('rbac_cache_hits_total') as Counter) || + new Counter({ + name: 'rbac_cache_hits_total', + help: 'Total number of RBAC cache hits', + registers: [defaultRegistry], + }); - this.missCounter = defaultRegistry.getSingleMetric('rbac_cache_misses_total') as Counter || new Counter({ - name: 'rbac_cache_misses_total', - help: 'Total number of RBAC cache misses', - registers: [defaultRegistry], - }); + this.missCounter = + (defaultRegistry.getSingleMetric('rbac_cache_misses_total') as Counter) || + new Counter({ + name: 'rbac_cache_misses_total', + help: 'Total number of RBAC cache misses', + registers: [defaultRegistry], + }); - this.propagationLatency = defaultRegistry.getSingleMetric('rbac_revocation_propagation_latency_ms') as Histogram || new Histogram({ - name: 'rbac_revocation_propagation_latency_ms', - help: 'Latency of propagating RBAC cache revocations', - buckets: [1, 5, 10, 50, 100, 500, 1000], - registers: [defaultRegistry], - }); + this.propagationLatency = + (defaultRegistry.getSingleMetric( + 'rbac_revocation_propagation_latency_ms', + ) as Histogram) || + new Histogram({ + name: 'rbac_revocation_propagation_latency_ms', + help: 'Latency of propagating RBAC cache revocations', + buckets: [1, 5, 10, 50, 100, 500, 1000], + registers: [defaultRegistry], + }); } async onModuleInit() { @@ -61,15 +67,15 @@ export class RbacCacheService implements OnModuleInit, OnModuleDestroy { try { const { roleId, all, timestamp } = JSON.parse(message); const latency = Date.now() - timestamp; - + if (all) { this.localCache.clear(); - this.logger.debug(`Invalidated all roles in local cache`); + this.logger.debug('Invalidated all roles in local cache'); } else if (roleId) { this.localCache.delete(roleId); this.logger.debug(`Invalidated role ${roleId} in local cache`); } - + this.propagationLatency.observe(latency); } catch (err) { this.logger.error(`Error processing invalidation message: ${(err as Error).message}`); @@ -91,14 +97,14 @@ export class RbacCacheService implements OnModuleInit, OnModuleDestroy { const key = `${RBAC_CACHE_PREFIX}${roleId}`; const cached = await this.redis.get(key); - + if (cached) { this.hitCounter.inc(); const parsed = JSON.parse(cached) as Permission[]; this.localCache.set(roleId, parsed); return parsed; } - + this.missCounter.inc(); return null; } @@ -119,13 +125,19 @@ export class RbacCacheService implements OnModuleInit, OnModuleDestroy { async invalidateAllRoles(): Promise { let cursor = '0'; do { - const [nextCursor, keys] = await this.redis.scan(cursor, 'MATCH', `${RBAC_CACHE_PREFIX}*`, 'COUNT', 100); + const [nextCursor, keys] = await this.redis.scan( + cursor, + 'MATCH', + `${RBAC_CACHE_PREFIX}*`, + 'COUNT', + 100, + ); cursor = nextCursor; if (keys.length > 0) { await this.redis.del(...keys); } } while (cursor !== '0'); - + const message = JSON.stringify({ all: true, timestamp: Date.now() }); await this.redis.publish(RBAC_INVALIDATION_CHANNEL, message); } diff --git a/src/rbac/rbac.module.ts b/src/rbac/rbac.module.ts index 357c108d..ff29ce68 100644 --- a/src/rbac/rbac.module.ts +++ b/src/rbac/rbac.module.ts @@ -17,10 +17,10 @@ import { IpAllowlistGuard } from '../common/guards/ip-allowlist.guard'; */ @Module({ imports: [ - ConfigModule, - TypeOrmModule.forFeature([Permission, Role]), + ConfigModule, + TypeOrmModule.forFeature([Permission, Role]), AuditLogModule, - RedisModule.forRoot() + RedisModule.forRoot(), ], controllers: [PermissionsController, RolesController], providers: [PermissionsService, RolesService, RbacCacheService, IpAllowlistGuard], diff --git a/src/rbac/roles/roles.controller.ts b/src/rbac/roles/roles.controller.ts index e49db5ee..1b7247a7 100644 --- a/src/rbac/roles/roles.controller.ts +++ b/src/rbac/roles/roles.controller.ts @@ -8,35 +8,21 @@ import { Delete, UseGuards, Req, + Query, } from '@nestjs/common'; import { Request } from 'express'; -import { ApiBearerAuth } from '@nestjs/swagger'; +import { ApiBearerAuth, ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { RolesService } from './roles.service'; import { Role } from '../entities/role.entity'; import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; import { RolesGuard } from '../../auth/guards/roles.guard'; +import { IpAllowlistGuard } from '../../common/guards/ip-allowlist.guard'; import { Roles } from '../../auth/decorators/roles.decorator'; +import { UserRole } from '../../users/entities/user.entity'; import { CreateRoleDto } from './dto/create-role.dto'; import { UpdateRoleDto } from './dto/update-role.dto'; - -@ApiBearerAuth() -@UseGuards(JwtAuthGuard, RolesGuard) -@Roles('admin') -import { Controller, Get, Post, Body, Param, Put, Delete, Query } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; -import { RolesService } from './roles.service'; -import { Role } from '../entities/role.entity'; import { PaginationQueryDto } from '../../common/dto/pagination.dto'; import { PaginatedSwaggerDto } from '../../common/dto/paginated-response.dto'; -import { Controller, Get, Post, Body, Param, Put, Delete, UseGuards } from '@nestjs/common'; -import { ApiBearerAuth, ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; -import { RolesService } from './roles.service'; -import { Role } from '../entities/role.entity'; -import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; -import { RolesGuard } from '../../auth/guards/roles.guard'; -import { IpAllowlistGuard } from '../../common/guards/ip-allowlist.guard'; -import { Roles } from '../../auth/decorators/roles.decorator'; -import { UserRole } from '../../users/entities/user.entity'; @ApiTags('roles') @Controller('roles') @@ -60,10 +46,7 @@ export class RolesController { @Post() @Roles(UserRole.ADMIN) @ApiOperation({ summary: 'Create a new role (Admin only)' }) - async create( - @Body() createRoleDto: CreateRoleDto, - @Req() req: Request, - ): Promise { + async create(@Body() createRoleDto: CreateRoleDto, @Req() req: Request): Promise { return this.rolesService.createRole( createRoleDto.name, createRoleDto.description, @@ -82,10 +65,6 @@ export class RolesController { async findAll(@Query() query?: PaginationQueryDto, @Query('include') include?: string) { const includePermissions = include === 'permissions'; return this.rolesService.findAllRoles(query, includePermissions); - @Roles(UserRole.ADMIN) - @ApiOperation({ summary: 'List all roles (Admin only)' }) - async findAll(): Promise { - return this.rolesService.findAllRoles(); } @Get(':id') @@ -115,10 +94,6 @@ export class RolesController { @Delete(':id') async remove(@Param('id') id: string, @Req() req: Request): Promise { return this.rolesService.deleteRole(id, this.extractContext(req)); - @Roles(UserRole.ADMIN) - @ApiOperation({ summary: 'Delete a role (Admin only)' }) - async remove(@Param('id') id: string): Promise { - return this.rolesService.deleteRole(id); } @Post(':roleId/permissions/:permissionId') @@ -129,11 +104,7 @@ export class RolesController { @Param('permissionId') permissionId: string, @Req() req: Request, ): Promise { - return this.rolesService.addPermissionToRole( - roleId, - permissionId, - this.extractContext(req), - ); + return this.rolesService.addPermissionToRole(roleId, permissionId, this.extractContext(req)); } @Delete(':roleId/permissions/:permissionId') diff --git a/src/rbac/roles/roles.service.spec.ts b/src/rbac/roles/roles.service.spec.ts index 66844a12..8664fcec 100644 --- a/src/rbac/roles/roles.service.spec.ts +++ b/src/rbac/roles/roles.service.spec.ts @@ -314,9 +314,9 @@ describe('RolesService', () => { }, ); - await expect( - service.updateRole('role-1', 'new-name', undefined, ['p-1']), - ).rejects.toThrow('DB constraint violation'); + await expect(service.updateRole('role-1', 'new-name', undefined, ['p-1'])).rejects.toThrow( + 'DB constraint violation', + ); // The transaction threw, so the update call inside the transaction // is the only place the name change would be persisted. @@ -344,9 +344,7 @@ describe('RolesService', () => { async (cb: (mgr: any) => Promise) => cb(manager), ); - await expect( - service.updateRole('missing-id', 'new-name'), - ).rejects.toThrow(NotFoundException); + await expect(service.updateRole('missing-id', 'new-name')).rejects.toThrow(NotFoundException); // No audit must be written for a non-existent role. expect(auditLogService.log).not.toHaveBeenCalled(); @@ -362,7 +360,9 @@ describe('RolesService', () => { const manager = buildManagerMock({ roleFindOne: jest.fn().mockResolvedValue({ ...baseRole, permissions: [] }), - roleQueryFindOne: jest.fn().mockResolvedValue({ ...baseRole, name: 'updated-name', permissions: [] }), + roleQueryFindOne: jest + .fn() + .mockResolvedValue({ ...baseRole, name: 'updated-name', permissions: [] }), }); (dataSource.transaction as jest.Mock).mockImplementationOnce( diff --git a/src/rbac/roles/roles.service.ts b/src/rbac/roles/roles.service.ts index dcc549e5..dedc6ac0 100644 --- a/src/rbac/roles/roles.service.ts +++ b/src/rbac/roles/roles.service.ts @@ -6,7 +6,7 @@ import { NotFoundException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { In, Repository } from 'typeorm'; +import { In, Repository, DataSource } from 'typeorm'; import { AuditLogService } from '../../audit-log/audit-log.service'; import { AuditAction, AuditCategory, AuditSeverity } from '../../audit-log/enums/audit-action.enum'; import { Permission } from '../entities/permission.entity'; @@ -187,7 +187,7 @@ export class RolesService { .of(id) .set(permissions); } - + await this.rbacCacheService.invalidateRole(id); const updated = await this.findRoleById(id, true); diff --git a/src/search/search.service.spec.ts b/src/search/search.service.spec.ts index 2603b5a6..f69a0aad 100644 --- a/src/search/search.service.spec.ts +++ b/src/search/search.service.spec.ts @@ -1,5 +1,4 @@ import { BadRequestException } from '@nestjs/common'; -import { SearchService } from './search.service'; import { SearchService, SEARCH_CACHE_TTL_MS } from './search.service'; import { Repository, QueryFailedError } from 'typeorm'; import { ElasticsearchService } from '@nestjs/elasticsearch'; diff --git a/src/search/search.service.ts b/src/search/search.service.ts index 74ca4300..0083644d 100644 --- a/src/search/search.service.ts +++ b/src/search/search.service.ts @@ -1,10 +1,17 @@ -import { Injectable, Logger, Inject, Optional, BadRequestException } from '@nestjs/common'; -import { Injectable, Logger, Inject, Optional, OnModuleInit } from '@nestjs/common'; +import { + Injectable, + Logger, + Inject, + Optional, + BadRequestException, + ServiceUnavailableException, + OnModuleInit, +} from '@nestjs/common'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { ElasticsearchService as NestElasticsearchService } from '@nestjs/elasticsearch'; import type { Cache } from 'cache-manager'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, Brackets } from 'typeorm'; +import { Repository, Brackets, QueryFailedError } from 'typeorm'; import { Course, CourseStatus } from '../courses/entities/course.entity'; import { LRUCache } from 'lru-cache'; import { IsolationService } from '../tenancy/isolation/isolation.service'; @@ -134,7 +141,6 @@ export class SearchService implements OnModuleInit { await this.validateFilters(filters); } - const cacheKey = `search:${safeQuery}:${JSON.stringify(filters)}:${sort}:${page}`; const cacheKey = this.buildSearchCacheKey(safeQuery, filters, sort, page, limit); if (this.cacheManager) { diff --git a/src/security/request-signing.service.spec.ts b/src/security/request-signing.service.spec.ts index 239894dc..5bba76b2 100644 --- a/src/security/request-signing.service.spec.ts +++ b/src/security/request-signing.service.spec.ts @@ -95,7 +95,9 @@ describe('RequestSigningService', () => { describe('buildPayloadWithNonce', () => { it('includes nonce between timestamp and body', () => { const result = service.buildPayloadWithNonce(BASE_PARTS); - expect(result).toBe(`POST:/api/payments:${BASE_PARTS.timestamp}:unique-nonce-123:{"amount":1000,"currency":"USD"}`); + expect(result).toBe( + `POST:/api/payments:${BASE_PARTS.timestamp}:unique-nonce-123:{"amount":1000,"currency":"USD"}`, + ); }); }); @@ -188,4 +190,4 @@ describe('RequestSigningService', () => { expect(result.reason).toBe('timestamp_expired'); }); }); -}); \ No newline at end of file +}); diff --git a/src/sharding/sharding.controller.ts b/src/sharding/sharding.controller.ts index 4106da7e..35604ad2 100644 --- a/src/sharding/sharding.controller.ts +++ b/src/sharding/sharding.controller.ts @@ -20,6 +20,11 @@ import { RouteShardDto } from './dto/route-shard.dto'; import { StartMigrationDto } from './dto/start-migration.dto'; import { ManualRebalanceDto } from './dto/manual-rebalance.dto'; import { AutoRebalanceDto } from './dto/auto-rebalance.dto'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { IpAllowlistGuard } from '../common/guards/ip-allowlist.guard'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { UserRole } from '../users/entities/user.entity'; /** * ShardingController @@ -43,9 +48,6 @@ import { AutoRebalanceDto } from './dto/auto-rebalance.dto'; * POST /sharding/ring/rebuild — rebuild consistent-hash ring */ @ApiTags('sharding') -@ApiBearerAuth() -@UseGuards(JwtAuthGuard, RolesGuard) -@Roles('admin') @Controller('sharding') @UseGuards(IpAllowlistGuard, JwtAuthGuard, RolesGuard) @ApiBearerAuth() diff --git a/src/tenancy/customization/customization.service.ts b/src/tenancy/customization/customization.service.ts index 27951aa9..0d9e2753 100644 --- a/src/tenancy/customization/customization.service.ts +++ b/src/tenancy/customization/customization.service.ts @@ -105,7 +105,8 @@ export class CustomizationService { }; return await this.customizationRepository.save(customization); } - private readonly DOMAIN_REGEX = /^(?![.-])(?!.*--)[a-zA-Z0-9-]{1,63}(?:\.[a-zA-Z0-9-]{1,63})*\.[a-zA-Z]{2,}$/; + private readonly DOMAIN_REGEX = + /^(?![.-])(?!.*--)[a-zA-Z0-9-]{1,63}(?:\.[a-zA-Z0-9-]{1,63})*\.[a-zA-Z]{2,}$/; private readonly BLOCKED_SUFFIXES = ['.local', '.localhost', '.internal', '.example']; private validateDomain(domain: string): void { @@ -116,7 +117,7 @@ export class CustomizationService { if (/^(\d{1,3}\.){3}\d{1,3}$/.test(trimmed)) { throw new BadRequestException('IP literals are not allowed as custom domains'); } - if (trimmed.startsWith('localhost') || this.BLOCKED_SUFFIXES.some(s => trimmed.endsWith(s))) { + if (trimmed.startsWith('localhost') || this.BLOCKED_SUFFIXES.some((s) => trimmed.endsWith(s))) { throw new BadRequestException('Localhost and internal suffixes are not allowed'); } if (!this.DOMAIN_REGEX.test(trimmed)) { @@ -131,7 +132,9 @@ export class CustomizationService { this.validateDomain(domain); const normalized = domain.toLowerCase().trim(); - const existing = await this.customizationRepository.findOne({ where: { customDomain: normalized } }); + const existing = await this.customizationRepository.findOne({ + where: { customDomain: normalized }, + }); if (existing && existing.tenantId !== tenantId) { throw new ConflictException('This domain is already claimed by another tenant'); } @@ -170,9 +173,7 @@ export class CustomizationService { } const token = customization.domainVerificationToken; - const matched = records.some((recordSet) => - recordSet.some((entry) => entry.trim() === token), - ); + const matched = records.some((recordSet) => recordSet.some((entry) => entry.trim() === token)); if (!matched) { throw new BadRequestException( diff --git a/src/tenancy/guards/tenant-limit.guard.ts b/src/tenancy/guards/tenant-limit.guard.ts index cc4d3ae4..242a78a6 100644 --- a/src/tenancy/guards/tenant-limit.guard.ts +++ b/src/tenancy/guards/tenant-limit.guard.ts @@ -10,11 +10,7 @@ import { TenancyService } from '../tenancy.service'; export const LIMIT_TYPE_KEY = 'limit_type'; export function LimitType(type: 'user' | 'storage') { - return function ( - _target: unknown, - _propertyKey: string, - descriptor: PropertyDescriptor, - ) { + return function (_target: unknown, _propertyKey: string, descriptor: PropertyDescriptor) { Reflect.defineMetadata(LIMIT_TYPE_KEY, type, descriptor.value); }; } @@ -24,10 +20,9 @@ export class TenantLimitGuard implements CanActivate { constructor(private readonly tenancyService: TenancyService) {} async canActivate(context: ExecutionContext): Promise { - const limitType = Reflect.getMetadata( - LIMIT_TYPE_KEY, - context.getHandler(), - ) as string | undefined; + const limitType = Reflect.getMetadata(LIMIT_TYPE_KEY, context.getHandler()) as + | string + | undefined; if (!limitType) { return true; diff --git a/src/tracing/opentelemetry.ts b/src/tracing/opentelemetry.ts index 4203df7c..437d1a6f 100644 --- a/src/tracing/opentelemetry.ts +++ b/src/tracing/opentelemetry.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-console -- tracing bootstrap runs before the app logger is available */ import { diag, DiagConsoleLogger, DiagLogLevel } from '@opentelemetry/api'; import { resourceFromAttributes } from '@opentelemetry/resources'; import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions'; diff --git a/src/workers/base/base.worker.ts b/src/workers/base/base.worker.ts index 795a80b3..ad222694 100644 --- a/src/workers/base/base.worker.ts +++ b/src/workers/base/base.worker.ts @@ -5,7 +5,11 @@ import { getSharedRedisClient } from '../../config/cache.config'; import { ConfigService } from '@nestjs/config'; import { IWorkerResult, IWorkerMetrics, IWorkerHealthCheck } from '../interfaces/worker.interfaces'; import { extractCorrelationIdFromJob } from '../../queues/utils/correlation-job.util'; -import { generateCorrelationId, getCorrelationId, runWithCorrelationId } from '../../common/utils/correlation.utils'; +import { + generateCorrelationId, + getCorrelationId, + runWithCorrelationId, +} from '../../common/utils/correlation.utils'; /** * Abstract base worker class @@ -114,8 +118,7 @@ export abstract class BaseWorker { ); const correlationId = getCorrelationId() ?? extractCorrelationIdFromJob(job) ?? 'unknown'; - const errMsg = - error instanceof Error ? error.message : 'Unknown error'; + const errMsg = error instanceof Error ? error.message : 'Unknown error'; const errStack = error instanceof Error ? error.stack : undefined; this.logger.error(