diff --git a/src/app.module.ts b/src/app.module.ts index ed16593d..c9fb3f99 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -25,7 +25,9 @@ import { IdempotencyModule } from './common/modules/idempotency.module'; import { IdempotencyInterceptor } from './common/interceptors/idempotency.interceptor'; import { DeepLinkModule } from './deep-link/deep-link.module'; import { InvoicesModule } from './payments/invoices/invoices.module'; +import { PaymentMethodsModule } from './payments/payment-methods/payment-methods.module'; import { ReportingModule } from './payments/reporting/reporting.module'; +import { NotificationsModule } from './notifications/notifications.module'; import { HealthModule } from './health/health.module'; import { AuditLogModule } from './audit-log/audit-log.module'; @@ -63,6 +65,8 @@ const featureFlags = loadFeatureFlags(); IdempotencyModule, DeepLinkModule, InvoicesModule, + PaymentMethodsModule, + NotificationsModule, ReportingModule, HealthModule, AuditLogModule, diff --git a/src/notifications/notifications.module.ts b/src/notifications/notifications.module.ts index 7aabbbc5..0a038dd3 100644 --- a/src/notifications/notifications.module.ts +++ b/src/notifications/notifications.module.ts @@ -1,3 +1,16 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ScheduleModule } from '@nestjs/schedule'; +import { Notification } from './entities/notification.entity'; +import { NotificationsQueueService } from './notifications.queue'; +import { NotificationsService } from './notifications.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Notification]), ScheduleModule], + providers: [NotificationsQueueService, NotificationsService], + exports: [NotificationsService], +}) +export class NotificationsModule {} import { Module, OnModuleInit } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Notification } from './entities/notification.entity'; diff --git a/src/notifications/notifications.queue.ts b/src/notifications/notifications.queue.ts index aa10ff23..fb7e648a 100644 --- a/src/notifications/notifications.queue.ts +++ b/src/notifications/notifications.queue.ts @@ -34,6 +34,7 @@ export class NotificationsQueueService { /** * Publish notification to SNS topic */ + async publishToTopic(notification: Notification, options?: { bypassBatch?: boolean }): Promise { async publishToTopic(notification: Notification): Promise { if (!this.snsTopicArn || !this.queueUrl) { this.logger.warn( @@ -46,19 +47,22 @@ export class NotificationsQueueService { return; } try { + const payload = { + id: notification.id, + userId: notification.userId, + title: notification.title, + content: notification.content, + type: notification.type, + metadata: notification.metadata, + bypassBatch: options?.bypassBatch ?? false, + }; const command = new PublishCommand({ TopicArn: this.snsTopicArn, - Message: JSON.stringify({ - id: notification.id, - userId: notification.userId, - title: notification.title, - content: notification.content, - type: notification.type, - metadata: notification.metadata, - }), + Message: JSON.stringify(payload), MessageAttributes: { type: { DataType: 'String', StringValue: notification.type }, priority: { DataType: 'String', StringValue: notification.priority }, + batch: { DataType: 'String', StringValue: String(payload.bypassBatch ? 'false' : Boolean(notification.metadata?.batched)) }, }, }); diff --git a/src/notifications/notifications.service.spec.ts b/src/notifications/notifications.service.spec.ts index bfa3e7e4..67051c47 100644 --- a/src/notifications/notifications.service.spec.ts +++ b/src/notifications/notifications.service.spec.ts @@ -1,4 +1,82 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotificationsService } from './notifications.service'; +import { NotificationsQueueService } from './notifications.queue'; +import { Notification, NotificationPriority, NotificationStatus, NotificationType } from './entities/notification.entity'; + +const mockRepository = { + findOne: jest.fn(), + create: jest.fn((dto) => dto), + save: jest.fn(), + find: jest.fn(), + update: jest.fn(), +}; + +const mockQueue = { + publishToTopic: jest.fn(), +}; + +const mockConfig = { + get: jest.fn((key: string, defaultValue?: string) => { + if (key === 'NOTIFICATION_BATCH_WINDOW_MS') { + return defaultValue ?? `${5 * 60 * 1000}`; + } + return defaultValue ?? null; + }), +}; + +describe('NotificationsService', () => { + let service: NotificationsService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + NotificationsService, + { provide: ConfigService, useValue: mockConfig }, + { provide: getRepositoryToken(Notification), useValue: mockRepository }, + { provide: NotificationsQueueService, useValue: mockQueue }, + ], + }).compile(); + + service = module.get(NotificationsService); + }); + + afterEach(() => jest.clearAllMocks()); + + it('should deduplicate identical pending notifications within the batch window', async () => { + const existing = { id: 'n1', userId: 'user1', title: 'New course', content: 'New content', type: NotificationType.EMAIL, status: NotificationStatus.PENDING, createdAt: new Date() }; + mockRepository.findOne.mockResolvedValue(existing); + + const result = await service.send({ + userId: 'user1', + title: 'New course', + content: 'New content', + type: NotificationType.EMAIL, + priority: NotificationPriority.MEDIUM, + }); + + expect(result).toBe(existing); + expect(mockRepository.save).not.toHaveBeenCalled(); + }); + + it('should publish urgent notifications immediately', async () => { + mockRepository.findOne.mockResolvedValue(null); + const saved = { id: 'n2', userId: 'user1', title: 'Urgent', content: 'Please respond', type: NotificationType.SMS, priority: NotificationPriority.URGENT, status: NotificationStatus.SENT, deliveryAttempts: 0, createdAt: new Date() }; + mockRepository.save.mockResolvedValue(saved); + + const result = await service.send({ + userId: 'user1', + title: 'Urgent', + content: 'Please respond', + type: NotificationType.SMS, + priority: NotificationPriority.URGENT, + }); + + expect(mockQueue.publishToTopic).toHaveBeenCalledWith(saved, { bypassBatch: true }); + expect(mockRepository.update).toHaveBeenCalledWith(saved.id, expect.any(Object)); + expect(result).toEqual(saved); import { getRepositoryToken } from '@nestjs/typeorm'; import { Notification } from './entities/notification.entity'; import { NotificationsService } from './notifications.service'; diff --git a/src/notifications/notifications.service.ts b/src/notifications/notifications.service.ts index 081095ad..ba2b921d 100644 --- a/src/notifications/notifications.service.ts +++ b/src/notifications/notifications.service.ts @@ -1,4 +1,33 @@ import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { In, Repository } from 'typeorm'; +import { + Notification, + NotificationPriority, + NotificationStatus, + NotificationType, +} from './entities/notification.entity'; +import { NotificationsQueueService } from './notifications.queue'; + +interface QueueNotificationPayload { + userId: string; + title: string; + content: string; + type: NotificationType; + priority?: NotificationPriority; + metadata?: Record; +} + +const DEFAULT_BATCH_WINDOW_MS = 5 * 60 * 1000; + +const BATCH_CONFIG: Record = { + [NotificationType.EMAIL]: { intervalMs: DEFAULT_BATCH_WINDOW_MS, batchLabel: 'Email Digest' }, + [NotificationType.PUSH]: { intervalMs: 2 * 60 * 1000, batchLabel: 'Push Summary' }, + [NotificationType.IN_APP]: { intervalMs: DEFAULT_BATCH_WINDOW_MS, batchLabel: 'In-App Summary' }, + [NotificationType.SMS]: { intervalMs: DEFAULT_BATCH_WINDOW_MS, batchLabel: 'SMS Digest' }, +}; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In } from 'typeorm'; import { @@ -15,6 +44,131 @@ import { SendTemplatedNotificationDto } from './dto/preferences.dto'; @Injectable() export class NotificationsService { private readonly logger = new Logger(NotificationsService.name); + private readonly batchWindowMs: number; + + constructor( + private readonly configService: ConfigService, + @InjectRepository(Notification) + private readonly notificationRepository: Repository, + private readonly notificationsQueue: NotificationsQueueService, + ) { + const batchWindowSetting = this.configService.get('NOTIFICATION_BATCH_WINDOW_MS', `${DEFAULT_BATCH_WINDOW_MS}`); + this.batchWindowMs = Number(batchWindowSetting) || DEFAULT_BATCH_WINDOW_MS; + } + + async send(notification: QueueNotificationPayload): Promise { + const priority = notification.priority ?? NotificationPriority.MEDIUM; + const isUrgent = priority === NotificationPriority.URGENT; + + const duplicate = await this.findDuplicate(notification); + if (duplicate) { + this.logger.log(`Deduplicated notification for user ${notification.userId}`); + return duplicate; + } + + const record = this.notificationRepository.create({ + ...notification, + priority, + status: isUrgent ? NotificationStatus.SENT : NotificationStatus.PENDING, + deliveryAttempts: 0, + }); + + const saved = await this.notificationRepository.save(record); + + if (isUrgent) { + await this.publish(saved, true); + } + + return saved; + } + + @Cron(CronExpression.EVERY_5_MINUTES) + async flushBatches(): Promise { + const pendingNotifications = await this.notificationRepository.find({ + where: { status: NotificationStatus.PENDING }, + order: { createdAt: 'ASC' }, + }); + + if (pendingNotifications.length === 0) { + return; + } + + const now = Date.now(); + const groups = new Map(); + + pendingNotifications.forEach((notification) => { + const key = `${notification.userId}:${notification.type}`; + const group = groups.get(key) ?? []; + group.push(notification); + groups.set(key, group); + }); + + for (const [key, notifications] of groups) { + const type = notifications[0].type; + const { intervalMs } = BATCH_CONFIG[type]; + const oldest = notifications[0]; + + if (now - oldest.createdAt.getTime() < intervalMs) { + continue; + } + + await this.publishBatch(notifications); + } + } + + private async publishBatch(notifications: Notification[]): Promise { + const first = notifications[0]; + const batchTitle = `${BATCH_CONFIG[first.type].batchLabel}`; + const body = notifications + .map((notification, index) => `${index + 1}. ${notification.title}: ${notification.content}`) + .join('\n'); + + const batchNotification = this.notificationRepository.create({ + userId: first.userId, + title: batchTitle, + content: body, + type: first.type, + priority: NotificationPriority.MEDIUM, + status: NotificationStatus.SENT, + metadata: { batched: true, count: notifications.length }, + deliveryAttempts: 0, + }); + + await this.notificationsQueue.publishToTopic(batchNotification); + const ids = notifications.map((notification) => notification.id); + await this.notificationRepository.update({ id: In(ids) }, { status: NotificationStatus.SENT, lastAttemptAt: new Date() }); + await this.notificationRepository.save(batchNotification); + + this.logger.log(`Flushed ${notifications.length} notifications into a batch for user ${first.userId}`); + } + + private async publish(notification: Notification, bypassBatch = false): Promise { + await this.notificationsQueue.publishToTopic(notification, { bypassBatch }); + await this.notificationRepository.update(notification.id, { + status: NotificationStatus.SENT, + lastAttemptAt: new Date(), + deliveryAttempts: notification.deliveryAttempts + 1, + }); + } + + private async findDuplicate(payload: QueueNotificationPayload): Promise { + const existing = await this.notificationRepository.findOne({ + where: { + userId: payload.userId, + title: payload.title, + content: payload.content, + type: payload.type, + status: NotificationStatus.PENDING, + }, + order: { createdAt: 'DESC' }, + }); + + if (!existing) { + return null; + } + + const age = Date.now() - existing.createdAt.getTime(); + return age <= this.batchWindowMs ? existing : null; constructor( @InjectRepository(Notification) diff --git a/src/payments/entities/payment-method.entity.ts b/src/payments/entities/payment-method.entity.ts new file mode 100644 index 00000000..e8c36507 --- /dev/null +++ b/src/payments/entities/payment-method.entity.ts @@ -0,0 +1,65 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + DeleteDateColumn, + ManyToOne, + JoinColumn, + Index, + VersionColumn, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { PaymentMethod as PaymentMethodType } from './payment.entity'; + +@Entity('payment_methods') +@Index(['userId', 'isDefault']) +export class PaymentMethod { + @PrimaryGeneratedColumn('uuid') + id: string; + + @VersionColumn() + version: number; + + @Column({ type: 'enum', enum: PaymentMethodType }) + method: PaymentMethodType; + + @Column({ type: 'varchar', nullable: true }) + provider?: string; + + @Column({ type: 'varchar', length: 64, nullable: true }) + displayName?: string; + + @Column({ type: 'varchar', length: 4, nullable: true }) + last4?: string; + + @Column({ type: 'int', nullable: true }) + expiryMonth?: number; + + @Column({ type: 'int', nullable: true }) + expiryYear?: number; + + @Column({ type: 'boolean', default: false }) + isDefault: boolean; + + @Column({ type: 'jsonb', nullable: true }) + metadata?: Record; + + @Column() + @Index() + userId: string; + + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'user_id' }) + user: User; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + @DeleteDateColumn() + deletedAt?: Date; +} diff --git a/src/payments/payment-methods/payment-methods.controller.ts b/src/payments/payment-methods/payment-methods.controller.ts new file mode 100644 index 00000000..c5ec61fd --- /dev/null +++ b/src/payments/payment-methods/payment-methods.controller.ts @@ -0,0 +1,51 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, Query } from '@nestjs/common'; +import { ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { PaymentMethodsService } from './payment-methods.service'; +import { CreatePaymentMethodDto, UpdatePaymentMethodDto } from './payment-methods.dto'; + +@ApiTags('Payment Methods') +@Controller('payment-methods') +export class PaymentMethodsController { + constructor(private readonly paymentMethodsService: PaymentMethodsService) {} + + @Get() + @ApiOperation({ summary: 'List saved payment methods for the current user' }) + @ApiQuery({ name: 'userId', required: true, description: 'User identifier' }) + @ApiResponse({ status: 200, description: 'Saved payment methods' }) + async list(@Query('userId') userId: string) { + return this.paymentMethodsService.listMethods(userId); + } + + @Post() + @ApiOperation({ summary: 'Add a new payment method' }) + @ApiQuery({ name: 'userId', required: true, description: 'User identifier' }) + @ApiResponse({ status: 201, description: 'Payment method added' }) + async create(@Query('userId') userId: string, @Body() dto: CreatePaymentMethodDto) { + return this.paymentMethodsService.addMethod(userId, dto); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update an existing payment method' }) + @ApiQuery({ name: 'userId', required: true, description: 'User identifier' }) + @ApiResponse({ status: 200, description: 'Payment method updated' }) + async update(@Param('id') id: string, @Query('userId') userId: string, @Body() dto: UpdatePaymentMethodDto) { + return this.paymentMethodsService.updateMethod(userId, id, dto); + } + + @Patch(':id/default') + @ApiOperation({ summary: 'Set a payment method as the default' }) + @ApiQuery({ name: 'userId', required: true, description: 'User identifier' }) + @ApiResponse({ status: 200, description: 'Default payment method updated' }) + async setDefault(@Param('id') id: string, @Query('userId') userId: string) { + return this.paymentMethodsService.setDefaultMethod(userId, id); + } + + @Delete(':id') + @ApiOperation({ summary: 'Remove a payment method' }) + @ApiQuery({ name: 'userId', required: true, description: 'User identifier' }) + @ApiResponse({ status: 204, description: 'Payment method removed' }) + async remove(@Param('id') id: string, @Query('userId') userId: string) { + await this.paymentMethodsService.removeMethod(userId, id); + return { success: true }; + } +} diff --git a/src/payments/payment-methods/payment-methods.dto.ts b/src/payments/payment-methods/payment-methods.dto.ts new file mode 100644 index 00000000..07eb347a --- /dev/null +++ b/src/payments/payment-methods/payment-methods.dto.ts @@ -0,0 +1,94 @@ +import { IsBoolean, IsEnum, IsInt, IsOptional, IsString, Length, Max, Min } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { PaymentMethod as PaymentMethodType } from '../entities/payment.entity'; + +export class CreatePaymentMethodDto { + @ApiProperty({ enum: PaymentMethodType }) + @IsEnum(PaymentMethodType) + method: PaymentMethodType; + + @ApiPropertyOptional({ description: 'Gateway provider name' }) + @IsOptional() + @IsString() + provider?: string; + + @ApiPropertyOptional({ description: 'Billing name for the payment method' }) + @IsOptional() + @IsString() + @Length(1, 64) + displayName?: string; + + @ApiPropertyOptional({ description: 'Last 4 digits of card or wallet identifier' }) + @IsOptional() + @IsString() + @Length(1, 4) + last4?: string; + + @ApiPropertyOptional({ description: 'Expiration month for card-based methods', minimum: 1, maximum: 12 }) + @IsOptional() + @IsInt() + @Min(1) + @Max(12) + expiryMonth?: number; + + @ApiPropertyOptional({ description: 'Expiration year for card-based methods' }) + @IsOptional() + @IsInt() + @Min(2024) + expiryYear?: number; + + @ApiPropertyOptional({ description: 'Whether this payment method should become the default' }) + @IsOptional() + @IsBoolean() + isDefault?: boolean; + + @ApiPropertyOptional({ description: 'Additional metadata for the payment method' }) + @IsOptional() + metadata?: Record; +} + +export class UpdatePaymentMethodDto { + @ApiPropertyOptional({ enum: PaymentMethodType }) + @IsOptional() + @IsEnum(PaymentMethodType) + method?: PaymentMethodType; + + @ApiPropertyOptional({ description: 'Gateway provider name' }) + @IsOptional() + @IsString() + provider?: string; + + @ApiPropertyOptional({ description: 'Billing name for the payment method' }) + @IsOptional() + @IsString() + @Length(1, 64) + displayName?: string; + + @ApiPropertyOptional({ description: 'Last 4 digits of card or wallet identifier' }) + @IsOptional() + @IsString() + @Length(1, 4) + last4?: string; + + @ApiPropertyOptional({ description: 'Expiration month for card-based methods', minimum: 1, maximum: 12 }) + @IsOptional() + @IsInt() + @Min(1) + @Max(12) + expiryMonth?: number; + + @ApiPropertyOptional({ description: 'Expiration year for card-based methods' }) + @IsOptional() + @IsInt() + @Min(2024) + expiryYear?: number; + + @ApiPropertyOptional({ description: 'Toggle whether this payment method is default' }) + @IsOptional() + @IsBoolean() + isDefault?: boolean; + + @ApiPropertyOptional({ description: 'Additional metadata for the payment method' }) + @IsOptional() + metadata?: Record; +} diff --git a/src/payments/payment-methods/payment-methods.module.ts b/src/payments/payment-methods/payment-methods.module.ts new file mode 100644 index 00000000..d60eea84 --- /dev/null +++ b/src/payments/payment-methods/payment-methods.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { PaymentMethod } from '../entities/payment-method.entity'; +import { PaymentMethodsController } from './payment-methods.controller'; +import { PaymentMethodsService } from './payment-methods.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([PaymentMethod])], + controllers: [PaymentMethodsController], + providers: [PaymentMethodsService], + exports: [PaymentMethodsService], +}) +export class PaymentMethodsModule {} diff --git a/src/payments/payment-methods/payment-methods.service.ts b/src/payments/payment-methods/payment-methods.service.ts new file mode 100644 index 00000000..cd013edf --- /dev/null +++ b/src/payments/payment-methods/payment-methods.service.ts @@ -0,0 +1,70 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { PaymentMethod } from '../entities/payment-method.entity'; +import { CreatePaymentMethodDto, UpdatePaymentMethodDto } from './payment-methods.dto'; + +@Injectable() +export class PaymentMethodsService { + constructor( + @InjectRepository(PaymentMethod) + private readonly paymentMethodRepository: Repository, + ) {} + + async listMethods(userId: string): Promise { + return this.paymentMethodRepository.find({ where: { userId }, order: { isDefault: 'DESC', createdAt: 'DESC' } }); + } + + async addMethod(userId: string, dto: CreatePaymentMethodDto): Promise { + if (!userId) { + throw new BadRequestException('userId is required'); + } + + if (dto.isDefault) { + await this.clearDefaultForUser(userId); + } + + const paymentMethod = this.paymentMethodRepository.create({ + ...dto, + userId, + isDefault: dto.isDefault ?? false, + }); + + return this.paymentMethodRepository.save(paymentMethod); + } + + async updateMethod(userId: string, id: string, dto: UpdatePaymentMethodDto): Promise { + const method = await this.requireOwnedMethod(userId, id); + + if (dto.isDefault) { + await this.clearDefaultForUser(userId); + } + + Object.assign(method, dto); + return this.paymentMethodRepository.save(method); + } + + async removeMethod(userId: string, id: string): Promise { + await this.requireOwnedMethod(userId, id); + await this.paymentMethodRepository.softDelete(id); + } + + async setDefaultMethod(userId: string, id: string): Promise { + const method = await this.requireOwnedMethod(userId, id); + await this.clearDefaultForUser(userId); + method.isDefault = true; + return this.paymentMethodRepository.save(method); + } + + private async requireOwnedMethod(userId: string, id: string): Promise { + const method = await this.paymentMethodRepository.findOne({ where: { id, userId } }); + if (!method) { + throw new NotFoundException('Payment method not found'); + } + return method; + } + + private async clearDefaultForUser(userId: string): Promise { + await this.paymentMethodRepository.update({ userId, isDefault: true }, { isDefault: false }); + } +} diff --git a/src/search/elasticsearch/elasticsearch.service.ts b/src/search/elasticsearch/elasticsearch.service.ts index aef32de7..e0cc9513 100644 --- a/src/search/elasticsearch/elasticsearch.service.ts +++ b/src/search/elasticsearch/elasticsearch.service.ts @@ -55,7 +55,13 @@ export class ElasticsearchService implements OnModuleInit { enrollments: { type: 'integer' }, duration: { type: 'integer' }, instructorId: { type: 'keyword' }, - instructorName: { type: 'text' }, + instructorName: { + type: 'text', + fields: { + keyword: { type: 'keyword' }, + search: { type: 'search_as_you_type' }, + }, + }, status: { type: 'keyword' }, createdAt: { type: 'date' }, updatedAt: { type: 'date' }, diff --git a/src/search/search.constants.ts b/src/search/search.constants.ts index 222afb2c..19ae77c9 100644 --- a/src/search/search.constants.ts +++ b/src/search/search.constants.ts @@ -34,4 +34,5 @@ export const SEARCH_CONSTANTS = { AGG_CATEGORIES_SIZE: 50, AGG_LEVELS_SIZE: 10, AGG_LANGUAGES_SIZE: 30, + AGG_INSTRUCTORS_SIZE: 20, } as const; diff --git a/src/search/search.module.ts b/src/search/search.module.ts index c489527c..7befacdb 100644 --- a/src/search/search.module.ts +++ b/src/search/search.module.ts @@ -1,11 +1,23 @@ import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { ElasticsearchModule } from '@nestjs/elasticsearch'; import { SearchController } from './search.controller'; import { SearchService } from './search.service'; +import { createElasticsearchConfig } from '../config/elasticsearch.config'; /** - * Minimal search module to get server running + * Search module supports Elasticsearch-backed course searching, + * facets, autocomplete, and result caching when available. */ @Module({ + imports: [ + ConfigModule, + ElasticsearchModule.registerAsync({ + imports: [ConfigModule], + inject: [ConfigService], + useFactory: createElasticsearchConfig, + }), + ], controllers: [SearchController], providers: [SearchService], exports: [SearchService], diff --git a/src/search/search.service.spec.ts b/src/search/search.service.spec.ts new file mode 100644 index 00000000..ab98d04f --- /dev/null +++ b/src/search/search.service.spec.ts @@ -0,0 +1,89 @@ +import { CACHE_MANAGER } from '@nestjs/cache-manager'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ElasticsearchService as NestElasticsearchService } from '@nestjs/elasticsearch'; +import { SearchService } from './search.service'; +import { SEARCH_CONSTANTS } from './search.constants'; + +const mockElasticsearch = { + search: jest.fn(), +}; + +const mockCache = { + get: jest.fn(), + set: jest.fn(), +}; + +describe('SearchService', () => { + let service: SearchService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SearchService, + { provide: NestElasticsearchService, useValue: mockElasticsearch }, + { provide: CACHE_MANAGER, useValue: mockCache }, + ], + }).compile(); + + service = module.get(SearchService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should use cache when available', async () => { + const cached = { results: [{ id: '1' }], total: 1, page: 1, limit: 20, query: 'test', filters: {}, facets: {} }; + mockCache.get.mockResolvedValue(cached); + + const result = await service.search('test', undefined, 'relevance', 1, 20); + + expect(result).toEqual(cached); + expect(mockElasticsearch.search).not.toHaveBeenCalled(); + }); + + it('should build Elasticsearch query with filters and return parsed buckets', async () => { + mockCache.get.mockResolvedValue(undefined); + mockElasticsearch.search.mockResolvedValue({ + hits: { + hits: [{ _id: '1', _score: 10, _source: { title: 'Test Course' } }], + total: { value: 1 }, + }, + aggregations: { + categories: { buckets: [{ key: 'programming', doc_count: 4 }] }, + levels: { buckets: [{ key: 'beginner', doc_count: 2 }] }, + languages: { buckets: [{ key: 'en', doc_count: 4 }] }, + instructors: { buckets: [{ key: 'Jane Doe', doc_count: 3 }] }, + priceRanges: { buckets: [{ key: 'Free', doc_count: 1 }] }, + ratingBuckets: { buckets: [{ key: 4.5, doc_count: 2 }] }, + }, + }); + + const result = await service.search( + 'javascript', + { category: 'programming', price: { gte: 0, lte: 100 }, rating: { gte: 4 }, instructor: 'Jane Doe' }, + 'rating_desc', + 1, + 20, + ); + + expect(mockElasticsearch.search).toHaveBeenCalled(); + expect(result.results[0]).toEqual({ id: '1', score: 10, title: 'Test Course' }); + expect(result.facets.categories[0].key).toBe('programming'); + expect(result.facets.instructors[0].key).toBe('Jane Doe'); + expect(result.total).toBe(1); + }); + + it('should build a complex search request quickly', () => { + const start = Date.now(); + const body = (service as any).buildSearchRequest('search term', { + category: ['programming', 'design'], + price: { gte: 0, lte: 300 }, + rating: { gte: 4 }, + instructor: 'Jane Doe', + }, 'price_desc'); + + expect(body.query).toBeDefined(); + expect(Date.now() - start).toBeLessThan(100); + }); +}); diff --git a/src/search/search.service.ts b/src/search/search.service.ts index c36647f1..5fd43c60 100644 --- a/src/search/search.service.ts +++ b/src/search/search.service.ts @@ -1,3 +1,12 @@ +import { + CACHE_MANAGER, + Inject, + Injectable, + Logger, + Optional, +} from '@nestjs/common'; +import { ElasticsearchService as NestElasticsearchService } from '@nestjs/elasticsearch'; +import type { Cache } from 'cache-manager'; import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, Like, ILike } from 'typeorm'; @@ -9,13 +18,21 @@ export interface SearchFilters { level?: string | string[]; language?: string | string[]; instructorId?: string; + instructor?: string; price?: { gte?: number; lte?: number; gt?: number; lt?: number; }; + rating?: { + gte?: number; + lte?: number; + gt?: number; + lt?: number; + }; } +//jhjgkjubj interface AutocompleteResult { title: string; @@ -36,6 +53,11 @@ export class SearchService { private courseRepository: Repository, ) {} + constructor( + private readonly elasticsearch: NestElasticsearchService, + @Optional() @Inject(CACHE_MANAGER) private readonly cacheManager?: Cache, + ) {} + /** * Search logic with Elasticsearch integration * Currently uses database as fallback for basic search @@ -47,8 +69,89 @@ export class SearchService { page = 1, limit: number = SEARCH_CONSTANTS.DEFAULT_PAGE_SIZE, ): Promise { - this.logger.log(`Searching for: ${query}`); + const safeQuery = query?.trim() ?? ''; + const safeLimit = Math.min(limit, SEARCH_CONSTANTS.MAX_PAGE_SIZE); + const cacheKey = this.generateCacheKey(safeQuery, filters, sort, page, safeLimit); + + if (this.cacheManager) { + const cached = await this.cacheManager.get(cacheKey); + if (cached) { + this.logger.log(`Search cache hit for key ${cacheKey}`); + return cached; + } + } + + const searchBody = this.buildSearchRequest(safeQuery, filters, sort); + const response = await this.elasticsearch.search({ + index: 'courses', + from: (page - 1) * safeLimit, + size: safeLimit, + body: searchBody, + track_total_hits: SEARCH_CONSTANTS.TRACK_TOTAL_HITS, + timeout: SEARCH_CONSTANTS.ELASTICSEARCH_TIMEOUT, + }); + + const result = { + results: (response.hits?.hits || []).map((hit) => ({ + id: hit._id, + score: hit._score, + ...hit._source, + })), + total: typeof response.hits?.total === 'object' ? response.hits.total.value : response.hits?.total || 0, + page, + limit: safeLimit, + query: safeQuery, + filters: filters || {}, + facets: this.parseAggregations(response.aggregations), + }; + + if (this.cacheManager) { + await this.cacheManager.set(cacheKey, result, { ttl: 30 }); + } + + return result; + } + + async getAutoComplete(query: string): Promise { + const safeQuery = query?.trim() ?? ''; + if (!safeQuery) { + return []; + } + + const response = await this.elasticsearch.search({ + index: 'courses', + size: SEARCH_CONSTANTS.AUTOCOMPLETE_SIZE, + _source: ['title'], + body: { + query: { + bool: { + should: [ + { + multi_match: { + query: safeQuery, + type: 'bool_prefix', + fields: ['title.search', 'title', 'tags^2', 'instructorName^2'], + }, + }, + { + prefix: { + 'title.keyword': safeQuery.toLowerCase(), + }, + }, + ], + }, + }, + }, + }); + + const suggestions = new Set(); + (response.hits?.hits || []).forEach((hit) => { + if (hit._source?.title) { + suggestions.add(hit._source.title); + } + }); + return [...suggestions].slice(0, SEARCH_CONSTANTS.AUTOCOMPLETE_SIZE); // Build a basic database search query for now; Elasticsearch integration can be added later. if (!query) { return { @@ -289,7 +392,23 @@ export class SearchService { } async getAvailableFilters(): Promise { + const response = await this.elasticsearch.search({ + index: 'courses', + size: 0, + body: { + aggs: this.buildFacetAggregations(), + }, + timeout: SEARCH_CONSTANTS.ELASTICSEARCH_TIMEOUT, + }); + + const aggs = this.parseAggregations(response.aggregations); return { + categories: aggs.categories, + levels: aggs.levels, + languages: aggs.languages, + instructors: aggs.instructors, + priceRanges: aggs.priceRanges, + ratingBuckets: aggs.ratingBuckets, categories: [ 'programming', 'web-development', @@ -310,12 +429,253 @@ export class SearchService { } async getAnalytics(days: number = 7): Promise { + const response = await this.elasticsearch.search({ + index: 'search_analytics', + size: 0, + body: { + query: { + range: { + timestamp: { + gte: `now-${days}d/d`, + lte: 'now', + }, + }, + }, + aggs: { + topQueries: { + terms: { + field: 'query', + size: SEARCH_CONSTANTS.TOP_QUERIES_SIZE, + }, + }, + averageResults: { + avg: { + field: 'resultsCount', + }, + }, + }, + }, + timeout: SEARCH_CONSTANTS.ELASTICSEARCH_TIMEOUT, + }); + + const totalSearches = typeof response.hits?.total === 'object' ? response.hits.total.value : response.hits?.total || 0; + const averageResults = response.aggregations?.averageResults?.value ?? 0; + const topQueries = (response.aggregations?.topQueries?.buckets || []).map((bucket) => ({ + query: bucket.key, + count: bucket.doc_count, + })); + + return { + topQueries, + totalSearches, + averageResults, + }; + } + + private buildSearchRequest(query: string, filters?: SearchFilters, sort?: string): Record { + const boolQuery: Record = { + bool: { + filter: this.buildFilterClauses(filters), + }, + }; + + if (query) { + boolQuery.bool['should'] = [ + { + multi_match: { + query, + type: 'best_fields', + fields: [ + 'title^5', + 'title.search^8', + 'description^2', + 'content', + 'tags^3', + 'instructorName^2', + ], + fuzziness: 'AUTO', + operator: 'and', + }, + }, + { + match_phrase_prefix: { + 'title.search': { + query, + slop: 2, + }, + }, + }, + ]; + boolQuery.bool['minimum_should_match'] = 1; + } else { + boolQuery.bool['must'] = [{ match_all: {} }]; + } + + return { + query: { + function_score: { + query: boolQuery, + functions: [ + { + field_value_factor: { + field: 'rating', + factor: SEARCH_CONSTANTS.RATING_BOOST_FACTOR, + missing: 1, + }, + }, + { + field_value_factor: { + field: 'views', + factor: SEARCH_CONSTANTS.VIEWS_BOOST_FACTOR, + missing: 0, + }, + }, + { + gauss: { + createdAt: { + origin: 'now', + scale: '30d', + decay: 0.5, + }, + }, + }, + ], + score_mode: 'avg', + boost_mode: 'sum', + }, + }, + sort: this.buildSort(sort), + aggs: this.buildFacetAggregations(), + }; + } + + private buildFilterClauses(filters?: SearchFilters): Record[] { + if (!filters) { + return []; + } + + const clauses: Record[] = []; + + if (filters.category) { + clauses.push(this.buildTermOrTermsClause('category', filters.category)); + } + + if (filters.level) { + clauses.push(this.buildTermOrTermsClause('level', filters.level)); + } + + if (filters.language) { + clauses.push(this.buildTermOrTermsClause('language', filters.language)); + } + + if (filters.instructorId) { + clauses.push({ term: { instructorId: filters.instructorId } }); + } + + if (filters.instructor) { + clauses.push({ term: { 'instructorName.keyword': filters.instructor } }); + } + + if (filters.price) { + clauses.push({ range: { price: filters.price } }); + } + + if (filters.rating) { + clauses.push({ range: { rating: filters.rating } }); + } + + return clauses; + } + + private buildTermOrTermsClause(field: string, value: string | string[]): Record { + return Array.isArray(value) + ? { terms: { [field]: value } } + : { term: { [field]: value } }; + } + + private buildSort(sort?: string): Array> { + switch (sort) { + case 'price_asc': + return [{ price: 'asc' }, { _score: 'desc' }]; + case 'price_desc': + return [{ price: 'desc' }, { _score: 'desc' }]; + case 'rating_desc': + return [{ rating: 'desc' }, { _score: 'desc' }]; + case 'newest': + return [{ createdAt: 'desc' }]; + default: + return [{ _score: 'desc' }, { createdAt: 'desc' }]; + } + } + + private buildFacetAggregations(): Record { + return { + categories: { + terms: { + field: 'category', + size: SEARCH_CONSTANTS.AGG_CATEGORIES_SIZE, + }, + }, + levels: { + terms: { + field: 'level', + size: SEARCH_CONSTANTS.AGG_LEVELS_SIZE, + }, + }, + languages: { + terms: { + field: 'language', + size: SEARCH_CONSTANTS.AGG_LANGUAGES_SIZE, + }, + }, + instructors: { + terms: { + field: 'instructorName.keyword', + size: SEARCH_CONSTANTS.AGG_INSTRUCTORS_SIZE, + }, + }, + priceRanges: { + range: { + field: 'price', + ranges: [ + { to: 0, key: 'Free' }, + { from: 0, to: SEARCH_CONSTANTS.PRICE_RANGES.LOW, key: 'Budget' }, + { from: SEARCH_CONSTANTS.PRICE_RANGES.LOW, to: SEARCH_CONSTANTS.PRICE_RANGES.MID, key: 'Mid-range' }, + { from: SEARCH_CONSTANTS.PRICE_RANGES.MID, key: 'Premium' }, + ], + }, + }, + ratingBuckets: { + histogram: { + field: 'rating', + interval: 0.5, + min_doc_count: 0, + }, + }, + }; + } + + private parseAggregations(aggregations?: Record): Record { + if (!aggregations) { + return { + categories: [], + levels: [], + languages: [], + instructors: [], + priceRanges: [], + ratingBuckets: [], + }; + } + this.logger.log(`Getting analytics for ${days} days`); // Analytics integration not available in this release; return a safe placeholder. return { - topQueries: [], - totalSearches: 0, - averageResults: 0, + categories: (aggregations.categories?.buckets || []).map((bucket) => ({ key: bucket.key, count: bucket.doc_count })), + levels: (aggregations.levels?.buckets || []).map((bucket) => ({ key: bucket.key, count: bucket.doc_count })), + languages: (aggregations.languages?.buckets || []).map((bucket) => ({ key: bucket.key, count: bucket.doc_count })), + instructors: (aggregations.instructors?.buckets || []).map((bucket) => ({ key: bucket.key, count: bucket.doc_count })), + priceRanges: (aggregations.priceRanges?.buckets || []).map((bucket) => ({ key: bucket.key, count: bucket.doc_count, from: bucket.from, to: bucket.to })), + ratingBuckets: (aggregations.ratingBuckets?.buckets || []).map((bucket) => ({ key: bucket.key, count: bucket.doc_count })), }; } @@ -326,8 +686,6 @@ export class SearchService { page = 1, limit: number = SEARCH_CONSTANTS.DEFAULT_PAGE_SIZE, ): string { - // Stable hash of the query state ensures identical search requests - // map to the same cache entry regardless of object ordering. const str = `${query}:${JSON.stringify(filters)}:${sort ?? 'default'}:${page}:${limit}`; let hash = 0; for (let i = 0; i < str.length; i++) { @@ -335,7 +693,7 @@ export class SearchService { hash = (hash << 5) - hash + char; hash = hash & hash; } - return hash.toString(); + return `search:${hash}`; } /**