diff --git a/apps/backend/src/social-engagement-module/database/migrations/create-social-engagement-tables.migration.ts b/apps/backend/src/social-engagement-module/database/migrations/create-social-engagement-tables.migration.ts new file mode 100644 index 0000000..a678f22 --- /dev/null +++ b/apps/backend/src/social-engagement-module/database/migrations/create-social-engagement-tables.migration.ts @@ -0,0 +1,187 @@ +// src/database/migrations/create-social-engagement-tables.migration.ts +import { MigrationInterface, QueryRunner, Table, TableIndex, TableForeignKey } from 'typeorm'; + +export class CreateSocialEngagementTables1715076125000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // Create ContentType enum + await queryRunner.query(` + CREATE TYPE "content_type_enum" AS ENUM ( + 'trading_signal', + 'market_analysis', + 'community_post', + 'comment' + ) + `); + + // Create EngagementType enum + await queryRunner.query(` + CREATE TYPE "engagement_type_enum" AS ENUM ( + 'like', + 'dislike' + ) + `); + + // Create social_engagements table + await queryRunner.createTable( + new Table({ + name: 'social_engagements', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + default: 'uuid_generate_v4()', + }, + { + name: 'userId', + type: 'uuid', + isNullable: false, + }, + { + name: 'contentId', + type: 'uuid', + isNullable: false, + }, + { + name: 'contentType', + type: 'content_type_enum', + isNullable: false, + }, + { + name: 'type', + type: 'engagement_type_enum', + isNullable: false, + }, + { + name: 'metadata', + type: 'jsonb', + isNullable: true, + }, + { + name: 'createdAt', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + }, + { + name: 'updatedAt', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + }, + ], + }), + true, + ); + + // Create engagement_counters table + await queryRunner.createTable( + new Table({ + name: 'engagement_counters', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + default: 'uuid_generate_v4()', + }, + { + name: 'contentId', + type: 'uuid', + isNullable: false, + }, + { + name: 'contentType', + type: 'content_type_enum', + isNullable: false, + }, + { + name: 'likesCount', + type: 'integer', + default: 0, + }, + { + name: 'dislikesCount', + type: 'integer', + default: 0, + }, + { + name: 'updatedAt', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + }, + ], + }), + true, + ); + + // Create indexes + await queryRunner.createIndex( + 'social_engagements', + new TableIndex({ + name: 'IDX_social_engagements_user_content', + columnNames: ['userId', 'contentId', 'contentType'], + isUnique: true, + }), + ); + + await queryRunner.createIndex( + 'social_engagements', + new TableIndex({ + name: 'IDX_social_engagements_user', + columnNames: ['userId'], + }), + ); + + await queryRunner.createIndex( + 'social_engagements', + new TableIndex({ + name: 'IDX_social_engagements_content', + columnNames: ['contentId', 'contentType'], + }), + ); + + await queryRunner.createIndex( + 'engagement_counters', + new TableIndex({ + name: 'IDX_engagement_counters_content', + columnNames: ['contentId', 'contentType'], + isUnique: true, + }), + ); + + // Add foreign key for userId if users table exists + const usersTableExists = await queryRunner.hasTable('users'); + if (usersTableExists) { + await queryRunner.createForeignKey( + 'social_engagements', + new TableForeignKey({ + columnNames: ['userId'], + referencedColumnNames: ['id'], + referencedTableName: 'users', + onDelete: 'CASCADE', + }), + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + // Drop foreign keys first + const usersTableExists = await queryRunner.hasTable('users'); + if (usersTableExists) { + const table = await queryRunner.getTable('social_engagements'); + const foreignKey = table.foreignKeys.find( + (fk) => fk.columnNames.indexOf('userId') !== -1, + ); + if (foreignKey) { + await queryRunner.dropForeignKey('social_engagements', foreignKey); + } + } + + // Drop tables + await queryRunner.dropTable('engagement_counters'); + await queryRunner.dropTable('social_engagements'); + + // Drop enums + await queryRunner.query(`DROP TYPE "engagement_type_enum"`); + await queryRunner.query(`DROP TYPE "content_type_enum"`); + } +} diff --git a/apps/backend/src/social-engagement-module/shared/enums/content-type.enum.ts b/apps/backend/src/social-engagement-module/shared/enums/content-type.enum.ts new file mode 100644 index 0000000..3b87b2f --- /dev/null +++ b/apps/backend/src/social-engagement-module/shared/enums/content-type.enum.ts @@ -0,0 +1,7 @@ +// src/shared/enums/content-type.enum.ts +export enum ContentType { + TRADING_SIGNAL = 'trading_signal', + MARKET_ANALYSIS = 'market_analysis', + COMMUNITY_POST = 'community_post', + COMMENT = 'comment', + } \ No newline at end of file diff --git a/apps/backend/src/social-engagement-module/shared/enums/engagement-type.enum.ts b/apps/backend/src/social-engagement-module/shared/enums/engagement-type.enum.ts new file mode 100644 index 0000000..11c6868 --- /dev/null +++ b/apps/backend/src/social-engagement-module/shared/enums/engagement-type.enum.ts @@ -0,0 +1,5 @@ +// src/shared/enums/engagement-type.enum.ts +export enum EngagementType { + LIKE = 'like', + DISLIKE = 'dislike', + } \ No newline at end of file diff --git a/apps/backend/src/social-engagement-module/shared/interfaces/content-polymorph.interface.ts b/apps/backend/src/social-engagement-module/shared/interfaces/content-polymorph.interface.ts new file mode 100644 index 0000000..405678a --- /dev/null +++ b/apps/backend/src/social-engagement-module/shared/interfaces/content-polymorph.interface.ts @@ -0,0 +1,7 @@ +// src/shared/interfaces/content-polymorph.interface.ts +import { ContentType } from '../enums/content-type.enum'; + +export interface ContentPolymorph { + contentId: string; + contentType: ContentType; +} \ No newline at end of file diff --git a/apps/backend/src/social-engagement-module/social-engagement/dto/create-engagement.dto.ts b/apps/backend/src/social-engagement-module/social-engagement/dto/create-engagement.dto.ts new file mode 100644 index 0000000..744260a --- /dev/null +++ b/apps/backend/src/social-engagement-module/social-engagement/dto/create-engagement.dto.ts @@ -0,0 +1,21 @@ +// src/social-engagement/dto/create-engagement.dto.ts +import { IsEnum, IsNotEmpty, IsOptional, IsUUID } from 'class-validator'; +import { EngagementType } from '../../shared/enums/engagement-type.enum'; +import { ContentType } from '../../shared/enums/content-type.enum'; + +export class CreateEngagementDto { + @IsNotEmpty() + @IsUUID() + contentId: string; + + @IsNotEmpty() + @IsEnum(ContentType) + contentType: ContentType; + + @IsNotEmpty() + @IsEnum(EngagementType) + type: EngagementType; + + @IsOptional() + metadata?: Record; +} \ No newline at end of file diff --git a/apps/backend/src/social-engagement-module/social-engagement/dto/engagement-response.dto.ts b/apps/backend/src/social-engagement-module/social-engagement/dto/engagement-response.dto.ts new file mode 100644 index 0000000..397c44a --- /dev/null +++ b/apps/backend/src/social-engagement-module/social-engagement/dto/engagement-response.dto.ts @@ -0,0 +1,18 @@ +// src/social-engagement/dto/engagement-response.dto.ts +import { EngagementType } from '../../shared/enums/engagement-type.enum'; +import { ContentType } from '../../shared/enums/content-type.enum'; + +export class EngagementResponseDto { + id: string; + userId: string; + contentId: string; + contentType: ContentType; + type: EngagementType; + createdAt: Date; + updatedAt: Date; + counters: { + likes: number; + dislikes: number; + }; + metadata?: Record; +} \ No newline at end of file diff --git a/apps/backend/src/social-engagement-module/social-engagement/entities/engagement-counter.entity.ts b/apps/backend/src/social-engagement-module/social-engagement/entities/engagement-counter.entity.ts new file mode 100644 index 0000000..b5a9c74 --- /dev/null +++ b/apps/backend/src/social-engagement-module/social-engagement/entities/engagement-counter.entity.ts @@ -0,0 +1,29 @@ +// src/social-engagement/entities/engagement-counter.entity.ts +import { Entity, Column, PrimaryGeneratedColumn, Index, UpdateDateColumn } from 'typeorm'; +import { ContentType } from '../../shared/enums/content-type.enum'; + +@Entity('engagement_counters') +@Index(['contentId', 'contentType'], { unique: true }) +export class EngagementCounter { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + @Index() + contentId: string; + + @Column({ + type: 'enum', + enum: ContentType, + }) + contentType: ContentType; + + @Column({ default: 0 }) + likesCount: number; + + @Column({ default: 0 }) + dislikesCount: number; + + @UpdateDateColumn() + updatedAt: Date; +} \ No newline at end of file diff --git a/apps/backend/src/social-engagement-module/social-engagement/entities/social-engagement.entity.ts b/apps/backend/src/social-engagement-module/social-engagement/entities/social-engagement.entity.ts new file mode 100644 index 0000000..fba374f --- /dev/null +++ b/apps/backend/src/social-engagement-module/social-engagement/entities/social-engagement.entity.ts @@ -0,0 +1,46 @@ +// src/social-engagement/entities/social-engagement.entity.ts +import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, UpdateDateColumn, ManyToOne, JoinColumn, Index } from 'typeorm'; +import { User } from '../../users/entities/user.entity'; +import { EngagementType } from '../../shared/enums/engagement-type.enum'; +import { ContentType } from '../../shared/enums/content-type.enum'; + +@Entity('social_engagements') +@Index(['userId', 'contentId', 'contentType'], { unique: true }) +export class SocialEngagement { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + @Index() + userId: string; + + @ManyToOne(() => User) + @JoinColumn({ name: 'userId' }) + user: User; + + @Column() + @Index() + contentId: string; + + @Column({ + type: 'enum', + enum: ContentType, + }) + contentType: ContentType; + + @Column({ + type: 'enum', + enum: EngagementType, + }) + type: EngagementType; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + // Additional metadata can be stored as JSON + @Column({ type: 'jsonb', nullable: true }) + metadata: Record; +} diff --git a/apps/backend/src/social-engagement-module/social-engagement/social-engagement.controller.ts b/apps/backend/src/social-engagement-module/social-engagement/social-engagement.controller.ts new file mode 100644 index 0000000..56f17bf --- /dev/null +++ b/apps/backend/src/social-engagement-module/social-engagement/social-engagement.controller.ts @@ -0,0 +1,124 @@ +// src/social-engagement/social-engagement.controller.ts +import { + Controller, + Post, + Get, + Delete, + Param, + Body, + UseGuards, + Query, + ParseIntPipe, + DefaultValuePipe, + HttpCode, + HttpStatus + } from '@nestjs/common'; + import { ApiBearerAuth, ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; + import { SocialEngagementService } from './social-engagement.service'; + import { CreateEngagementDto } from './dto/create-engagement.dto'; + import { EngagementResponseDto } from './dto/engagement-response.dto'; + import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; + import { CurrentUser } from '../auth/decorators/current-user.decorator'; + import { UserEntity } from '../users/entities/user.entity'; + import { ContentType } from '../shared/enums/content-type.enum'; + + @ApiTags('social-engagement') + @Controller('social-engagement') + export class SocialEngagementController { + constructor(private readonly service: SocialEngagementService) {} + + @Post() + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Create or toggle a like/dislike engagement' }) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'Engagement created or updated', + type: EngagementResponseDto + }) + async createEngagement( + @CurrentUser() user: UserEntity, + @Body() dto: CreateEngagementDto, + ): Promise { + return this.service.createEngagement(user.id, dto); + } + + @Delete(':id') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Remove an engagement' }) + @ApiResponse({ status: HttpStatus.NO_CONTENT, description: 'Engagement removed' }) + async removeEngagement( + @CurrentUser() user: UserEntity, + @Param('id') id: string, + ): Promise { + return this.service.removeEngagement(user.id, id); + } + + @Get('user/:contentId/:contentType') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ summary: 'Get current user engagement for a specific content' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'User engagement', + type: EngagementResponseDto + }) + async getUserEngagement( + @CurrentUser() user: UserEntity, + @Param('contentId') contentId: string, + @Param('contentType') contentType: ContentType, + ): Promise { + return this.service.getUserEngagement(user.id, contentId, contentType); + } + + @Get('count/:contentId/:contentType') + @ApiOperation({ summary: 'Get engagement counts for content' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Engagement counts', + schema: { + type: 'object', + properties: { + likes: { type: 'number' }, + dislikes: { type: 'number' }, + }, + }, + }) + async getContentEngagementCounts( + @Param('contentId') contentId: string, + @Param('contentType') contentType: ContentType, + ): Promise<{ likes: number; dislikes: number }> { + return this.service.getContentEngagementCounts(contentId, contentType); + } + + @Get(':contentId/:contentType') + @ApiOperation({ summary: 'Get all engagements for a specific content' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Content engagements', + schema: { + type: 'object', + properties: { + engagements: { + type: 'array', + items: { $ref: '#/components/schemas/EngagementResponseDto' } + }, + total: { type: 'number' }, + }, + }, + }) + async getContentEngagements( + @Param('contentId') contentId: string, + @Param('contentType') contentType: ContentType, + @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number, + @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number, + ): Promise<{ engagements: EngagementResponseDto[]; total: number }> { + return this.service.getContentEngagements(contentId, contentType, page, limit); + } + } + + + + \ No newline at end of file diff --git a/apps/backend/src/social-engagement-module/social-engagement/social-engagement.module.ts b/apps/backend/src/social-engagement-module/social-engagement/social-engagement.module.ts new file mode 100644 index 0000000..e6c8690 --- /dev/null +++ b/apps/backend/src/social-engagement-module/social-engagement/social-engagement.module.ts @@ -0,0 +1,22 @@ +// src/social-engagement/social-engagement.module.ts +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { EventEmitterModule } from '@nestjs/event-emitter'; +import { SocialEngagementController } from './social-engagement.controller'; +import { SocialEngagementService } from './social-engagement.service'; +import { SocialEngagementRepository } from './social-engagement.repository'; +import { SocialEngagement } from './entities/social-engagement.entity'; +import { EngagementCounter } from './entities/engagement-counter.entity'; +import { SharedModule } from '../shared/shared.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([SocialEngagement, EngagementCounter]), + EventEmitterModule.forRoot(), + SharedModule, + ], + controllers: [SocialEngagementController], + providers: [SocialEngagementService, SocialEngagementRepository], + exports: [SocialEngagementService], +}) +export class SocialEngagementModule {} \ No newline at end of file diff --git a/apps/backend/src/social-engagement-module/social-engagement/social-engagement.repository.ts b/apps/backend/src/social-engagement-module/social-engagement/social-engagement.repository.ts new file mode 100644 index 0000000..b2d4336 --- /dev/null +++ b/apps/backend/src/social-engagement-module/social-engagement/social-engagement.repository.ts @@ -0,0 +1,177 @@ +// src/social-engagement/social-engagement.repository.ts +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, Repository } from 'typeorm'; +import { SocialEngagement } from './entities/social-engagement.entity'; +import { EngagementCounter } from './entities/engagement-counter.entity'; +import { ContentPolymorph } from '../shared/interfaces/content-polymorph.interface'; +import { EngagementType } from '../shared/enums/engagement-type.enum'; + +@Injectable() +export class SocialEngagementRepository { + constructor( + @InjectRepository(SocialEngagement) + private engagementRepo: Repository, + @InjectRepository(EngagementCounter) + private counterRepo: Repository, + private dataSource: DataSource, + ) {} + + async findUserEngagement(userId: string, contentId: string, contentType: string): Promise { + return this.engagementRepo.findOne({ + where: { userId, contentId, contentType }, + }); + } + + async getEngagementCounters({ contentId, contentType }: ContentPolymorph): Promise { + const counter = await this.counterRepo.findOne({ + where: { contentId, contentType }, + }); + + if (counter) { + return counter; + } + + // Create counter if it doesn't exist + const newCounter = this.counterRepo.create({ + contentId, + contentType, + likesCount: 0, + dislikesCount: 0, + }); + + return this.counterRepo.save(newCounter); + } + + async createOrUpdateEngagement( + userId: string, + contentId: string, + contentType: string, + type: EngagementType, + metadata?: Record, + ): Promise<{ engagement: SocialEngagement; counters: EngagementCounter }> { + // We need a transaction to ensure data consistency + return this.dataSource.transaction(async (manager) => { + // Check if engagement exists + const existingEngagement = await manager.findOne(SocialEngagement, { + where: { userId, contentId, contentType }, + }); + + // Get or create counter + let counter = await manager.findOne(EngagementCounter, { + where: { contentId, contentType }, + }); + + if (!counter) { + counter = manager.create(EngagementCounter, { + contentId, + contentType, + likesCount: 0, + dislikesCount: 0, + }); + } + + let engagement: SocialEngagement; + + if (!existingEngagement) { + // Create new engagement + engagement = manager.create(SocialEngagement, { + userId, + contentId, + contentType, + type, + metadata, + }); + + // Update counter + if (type === EngagementType.LIKE) { + counter.likesCount += 1; + } else if (type === EngagementType.DISLIKE) { + counter.dislikesCount += 1; + } + } else if (existingEngagement.type !== type) { + // Toggle engagement + engagement = existingEngagement; + engagement.type = type; + engagement.metadata = metadata || engagement.metadata; + + // Update counter + if (type === EngagementType.LIKE) { + counter.likesCount += 1; + counter.dislikesCount -= 1; + } else if (type === EngagementType.DISLIKE) { + counter.likesCount -= 1; + counter.dislikesCount += 1; + } + } else { + // Remove engagement (clicking the same button again) + if (type === EngagementType.LIKE) { + counter.likesCount -= 1; + } else if (type === EngagementType.DISLIKE) { + counter.dislikesCount -= 1; + } + + await manager.remove(existingEngagement); + return { + engagement: null, + counters: await manager.save(counter) + }; + } + + // Save both entities + await manager.save(counter); + return { + engagement: await manager.save(engagement), + counters: counter + }; + }); + } + + async removeEngagement(id: string): Promise { + const engagement = await this.engagementRepo.findOne({ + where: { id }, + }); + + if (!engagement) { + return; + } + + // We need a transaction to ensure data consistency + await this.dataSource.transaction(async (manager) => { + // Get counter + const counter = await manager.findOne(EngagementCounter, { + where: { + contentId: engagement.contentId, + contentType: engagement.contentType + }, + }); + + if (counter) { + // Update counter + if (engagement.type === EngagementType.LIKE) { + counter.likesCount = Math.max(0, counter.likesCount - 1); + } else if (engagement.type === EngagementType.DISLIKE) { + counter.dislikesCount = Math.max(0, counter.dislikesCount - 1); + } + await manager.save(counter); + } + + // Remove engagement + await manager.remove(engagement); + }); + } + + async findContentEngagements( + contentId: string, + contentType: string, + page = 1, + limit = 10, + ): Promise<[SocialEngagement[], number]> { + return this.engagementRepo.findAndCount({ + where: { contentId, contentType }, + skip: (page - 1) * limit, + take: limit, + order: { createdAt: 'DESC' }, + }); + } +} diff --git a/apps/backend/src/social-engagement-module/social-engagement/social-engagement.service.spec.ts b/apps/backend/src/social-engagement-module/social-engagement/social-engagement.service.spec.ts new file mode 100644 index 0000000..ac7e3b0 --- /dev/null +++ b/apps/backend/src/social-engagement-module/social-engagement/social-engagement.service.spec.ts @@ -0,0 +1,402 @@ +// src/social-engagement/social-engagement.service.spec.ts +import { Test, TestingModule } from '@nestjs/testing'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { SocialEngagementService } from './social-engagement.service'; +import { SocialEngagementRepository } from './social-engagement.repository'; +import { RateLimiterService } from '../shared/services/rate-limiter.service'; +import { CreateEngagementDto } from './dto/create-engagement.dto'; +import { EngagementType } from '../shared/enums/engagement-type.enum'; +import { ContentType } from '../shared/enums/content-type.enum'; +import { NotFoundException, ForbiddenException } from '@nestjs/common'; + +describe('SocialEngagementService', () => { + let service: SocialEngagementService; + let repository: SocialEngagementRepository; + let eventEmitter: EventEmitter2; + let rateLimiter: RateLimiterService; + + const mockRepository = { + findUserEngagement: jest.fn(), + getEngagementCounters: jest.fn(), + createOrUpdateEngagement: jest.fn(), + removeEngagement: jest.fn(), + findContentEngagements: jest.fn(), + }; + + const mockEventEmitter = { + emit: jest.fn(), + }; + + const mockRateLimiter = { + checkLimit: jest.fn().mockResolvedValue(true), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SocialEngagementService, + { + provide: SocialEngagementRepository, + useValue: mockRepository, + }, + { + provide: EventEmitter2, + useValue: mockEventEmitter, + }, + { + provide: RateLimiterService, + useValue: mockRateLimiter, + }, + ], + }).compile(); + + service = module.get(SocialEngagementService); + repository = module.get(SocialEngagementRepository); + eventEmitter = module.get(EventEmitter2); + rateLimiter = module.get(RateLimiterService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('createEngagement', () => { + it('should create a new engagement', async () => { + const userId = 'user-123'; + const dto: CreateEngagementDto = { + contentId: 'content-123', + contentType: ContentType.TRADING_SIGNAL, + type: EngagementType.LIKE, + }; + + const mockEngagement = { + id: 'engagement-123', + userId, + contentId: dto.contentId, + contentType: dto.contentType, + type: dto.type, + createdAt: new Date(), + updatedAt: new Date(), + metadata: {}, + }; + + const mockCounters = { + id: 'counter-123', + contentId: dto.contentId, + contentType: dto.contentType, + likesCount: 1, + dislikesCount: 0, + updatedAt: new Date(), + }; + + mockRepository.createOrUpdateEngagement.mockResolvedValue({ + engagement: mockEngagement, + counters: mockCounters, + }); + + const result = await service.createEngagement(userId, dto); + + expect(rateLimiter.checkLimit).toHaveBeenCalledWith(`engagement_${userId}`, 10, 60); + expect(mockRepository.createOrUpdateEngagement).toHaveBeenCalledWith( + userId, + dto.contentId, + dto.contentType, + dto.type, + dto.metadata, + ); + expect(mockEventEmitter.emit).toHaveBeenCalledWith( + 'engagement.created', + expect.objectContaining({ + userId, + contentId: dto.contentId, + contentType: dto.contentType, + type: dto.type, + }), + ); + expect(result).toEqual({ + id: mockEngagement.id, + userId: mockEngagement.userId, + contentId: mockEngagement.contentId, + contentType: mockEngagement.contentType, + type: mockEngagement.type, + createdAt: mockEngagement.createdAt, + updatedAt: mockEngagement.updatedAt, + counters: { + likes: mockCounters.likesCount, + dislikes: mockCounters.dislikesCount, + }, + metadata: mockEngagement.metadata, + }); + }); + + it('should return null when engagement is removed (toggle off)', async () => { + const userId = 'user-123'; + const dto: CreateEngagementDto = { + contentId: 'content-123', + contentType: ContentType.TRADING_SIGNAL, + type: EngagementType.LIKE, + }; + + const mockCounters = { + id: 'counter-123', + contentId: dto.contentId, + contentType: dto.contentType, + likesCount: 0, + dislikesCount: 0, + updatedAt: new Date(), + }; + + mockRepository.createOrUpdateEngagement.mockResolvedValue({ + engagement: null, + counters: mockCounters, + }); + + const result = await service.createEngagement(userId, dto); + + expect(result).toBeNull(); + expect(mockEventEmitter.emit).toHaveBeenCalledWith( + 'engagement.created', + expect.any(Object), + ); + }); + }); + + describe('removeEngagement', () => { + it('should remove an engagement', async () => { + const userId = 'user-123'; + const engagementId = 'engagement-123'; + const mockEngagement = { + id: engagementId, + userId, + contentId: 'content-123', + contentType: ContentType.TRADING_SIGNAL, + type: EngagementType.LIKE, + createdAt: new Date(), + updatedAt: new Date(), + metadata: {}, + }; + + mockRepository.findUserEngagement.mockResolvedValue(mockEngagement); + + await service.removeEngagement(userId, engagementId); + + expect(mockRepository.findUserEngagement).toHaveBeenCalledWith(userId, engagementId, null); + expect(mockRepository.removeEngagement).toHaveBeenCalledWith(engagementId); + expect(mockEventEmitter.emit).toHaveBeenCalledWith( + 'engagement.removed', + expect.objectContaining({ + userId, + contentId: mockEngagement.contentId, + contentType: mockEngagement.contentType, + type: mockEngagement.type, + }), + ); + }); + + it('should throw NotFoundException when engagement not found', async () => { + const userId = 'user-123'; + const engagementId = 'non-existent-id'; + + mockRepository.findUserEngagement.mockResolvedValue(null); + + await expect(service.removeEngagement(userId, engagementId)).rejects.toThrow( + NotFoundException, + ); + expect(mockRepository.removeEngagement).not.toHaveBeenCalled(); + }); + + it('should throw ForbiddenException when user tries to remove another user engagement', async () => { + const userId = 'user-123'; + const otherUserId = 'user-456'; + const engagementId = 'engagement-123'; + const mockEngagement = { + id: engagementId, + userId: otherUserId, // Different user + contentId: 'content-123', + contentType: ContentType.TRADING_SIGNAL, + type: EngagementType.LIKE, + createdAt: new Date(), + updatedAt: new Date(), + metadata: {}, + }; + + mockRepository.findUserEngagement.mockResolvedValue(mockEngagement); + + await expect(service.removeEngagement(userId, engagementId)).rejects.toThrow( + ForbiddenException, + ); + expect(mockRepository.removeEngagement).not.toHaveBeenCalled(); + }); + }); + + describe('getUserEngagement', () => { + it('should return user engagement for specific content', async () => { + const userId = 'user-123'; + const contentId = 'content-123'; + const contentType = ContentType.TRADING_SIGNAL; + + const mockEngagement = { + id: 'engagement-123', + userId, + contentId, + contentType, + type: EngagementType.LIKE, + createdAt: new Date(), + updatedAt: new Date(), + metadata: {}, + }; + + const mockCounters = { + id: 'counter-123', + contentId, + contentType, + likesCount: 5, + dislikesCount: 2, + updatedAt: new Date(), + }; + + mockRepository.findUserEngagement.mockResolvedValue(mockEngagement); + mockRepository.getEngagementCounters.mockResolvedValue(mockCounters); + + const result = await service.getUserEngagement(userId, contentId, contentType); + + expect(mockRepository.findUserEngagement).toHaveBeenCalledWith(userId, contentId, contentType); + expect(mockRepository.getEngagementCounters).toHaveBeenCalledWith({ + contentId, + contentType, + }); + + expect(result).toEqual({ + id: mockEngagement.id, + userId: mockEngagement.userId, + contentId: mockEngagement.contentId, + contentType: mockEngagement.contentType, + type: mockEngagement.type, + createdAt: mockEngagement.createdAt, + updatedAt: mockEngagement.updatedAt, + counters: { + likes: mockCounters.likesCount, + dislikes: mockCounters.dislikesCount, + }, + metadata: mockEngagement.metadata, + }); + }); + + it('should return null when user has no engagement for content', async () => { + const userId = 'user-123'; + const contentId = 'content-123'; + const contentType = ContentType.TRADING_SIGNAL; + + mockRepository.findUserEngagement.mockResolvedValue(null); + + const result = await service.getUserEngagement(userId, contentId, contentType); + + expect(result).toBeNull(); + expect(mockRepository.getEngagementCounters).not.toHaveBeenCalled(); + }); + }); + + describe('getContentEngagementCounts', () => { + it('should return engagement counts for content', async () => { + const contentId = 'content-123'; + const contentType = ContentType.TRADING_SIGNAL; + + const mockCounters = { + id: 'counter-123', + contentId, + contentType, + likesCount: 10, + dislikesCount: 3, + updatedAt: new Date(), + }; + + mockRepository.getEngagementCounters.mockResolvedValue(mockCounters); + + const result = await service.getContentEngagementCounts(contentId, contentType); + + expect(mockRepository.getEngagementCounters).toHaveBeenCalledWith({ + contentId, + contentType, + }); + expect(result).toEqual({ + likes: mockCounters.likesCount, + dislikes: mockCounters.dislikesCount, + }); + }); + }); + + describe('getContentEngagements', () => { + it('should return paginated engagements for content', async () => { + const contentId = 'content-123'; + const contentType = ContentType.TRADING_SIGNAL; + const page = 1; + const limit = 10; + + const mockEngagements = [ + { + id: 'engagement-123', + userId: 'user-123', + contentId, + contentType, + type: EngagementType.LIKE, + createdAt: new Date(), + updatedAt: new Date(), + metadata: {}, + }, + { + id: 'engagement-456', + userId: 'user-456', + contentId, + contentType, + type: EngagementType.DISLIKE, + createdAt: new Date(), + updatedAt: new Date(), + metadata: {}, + }, + ]; + + const mockCounters = { + id: 'counter-123', + contentId, + contentType, + likesCount: 15, + dislikesCount: 7, + updatedAt: new Date(), + }; + + mockRepository.findContentEngagements.mockResolvedValue([mockEngagements, 2]); + mockRepository.getEngagementCounters.mockResolvedValue(mockCounters); + + const result = await service.getContentEngagements(contentId, contentType, page, limit); + + expect(mockRepository.findContentEngagements).toHaveBeenCalledWith( + contentId, + contentType, + page, + limit + ); + expect(mockRepository.getEngagementCounters).toHaveBeenCalledWith({ + contentId, + contentType, + }); + + expect(result).toEqual({ + engagements: mockEngagements.map(engagement => ({ + id: engagement.id, + userId: engagement.userId, + contentId: engagement.contentId, + contentType: engagement.contentType, + type: engagement.type, + createdAt: engagement.createdAt, + updatedAt: engagement.updatedAt, + counters: { + likes: mockCounters.likesCount, + dislikes: mockCounters.dislikesCount, + }, + metadata: engagement.metadata, + })), + total: 2, + }); + }); + }); +}); \ No newline at end of file diff --git a/apps/backend/src/social-engagement-module/social-engagement/social-engagement.service.ts b/apps/backend/src/social-engagement-module/social-engagement/social-engagement.service.ts new file mode 100644 index 0000000..e6f665f --- /dev/null +++ b/apps/backend/src/social-engagement-module/social-engagement/social-engagement.service.ts @@ -0,0 +1,157 @@ +// src/social-engagement/social-engagement.service.ts +import { Injectable, NotFoundException, ConflictException, ForbiddenException } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { SocialEngagementRepository } from './social-engagement.repository'; +import { CreateEngagementDto } from './dto/create-engagement.dto'; +import { EngagementResponseDto } from './dto/engagement-response.dto'; +import { SocialEngagement } from './entities/social-engagement.entity'; +import { EngagementCounter } from './entities/engagement-counter.entity'; +import { EngagementType } from '../shared/enums/engagement-type.enum'; +import { RateLimiterService } from '../shared/services/rate-limiter.service'; + +@Injectable() +export class SocialEngagementService { + constructor( + private repository: SocialEngagementRepository, + private eventEmitter: EventEmitter2, + private rateLimiter: RateLimiterService, + ) {} + + private mapToResponseDto( + engagement: SocialEngagement, + counters: EngagementCounter + ): EngagementResponseDto { + if (!engagement) { + return null; + } + + return { + id: engagement.id, + userId: engagement.userId, + contentId: engagement.contentId, + contentType: engagement.contentType, + type: engagement.type, + createdAt: engagement.createdAt, + updatedAt: engagement.updatedAt, + counters: { + likes: counters?.likesCount || 0, + dislikes: counters?.dislikesCount || 0, + }, + metadata: engagement.metadata, + }; + } + + async createEngagement( + userId: string, + dto: CreateEngagementDto + ): Promise { + // Check rate limit + await this.rateLimiter.checkLimit(`engagement_${userId}`, 10, 60); // 10 actions per minute + + // Create or update engagement + const { engagement, counters } = await this.repository.createOrUpdateEngagement( + userId, + dto.contentId, + dto.contentType, + dto.type, + dto.metadata, + ); + + // Emit event for analytics + this.eventEmitter.emit('engagement.created', { + userId, + contentId: dto.contentId, + contentType: dto.contentType, + type: dto.type, + timestamp: new Date(), + }); + + return this.mapToResponseDto(engagement, counters); + } + + async removeEngagement( + userId: string, + engagementId: string + ): Promise { + const engagement = await this.repository.findUserEngagement(userId, engagementId, null); + + if (!engagement) { + throw new NotFoundException('Engagement not found'); + } + + if (engagement.userId !== userId) { + throw new ForbiddenException('Cannot remove another user\'s engagement'); + } + + await this.repository.removeEngagement(engagementId); + + // Emit event for analytics + this.eventEmitter.emit('engagement.removed', { + userId, + contentId: engagement.contentId, + contentType: engagement.contentType, + type: engagement.type, + timestamp: new Date(), + }); + } + + async getUserEngagement( + userId: string, + contentId: string, + contentType: string + ): Promise { + const engagement = await this.repository.findUserEngagement(userId, contentId, contentType); + + if (!engagement) { + return null; + } + + const counters = await this.repository.getEngagementCounters({ + contentId, + contentType, + }); + + return this.mapToResponseDto(engagement, counters); + } + + async getContentEngagementCounts( + contentId: string, + contentType: string + ): Promise<{ likes: number; dislikes: number }> { + const counters = await this.repository.getEngagementCounters({ + contentId, + contentType, + }); + + return { + likes: counters.likesCount, + dislikes: counters.dislikesCount, + }; + } + + async getContentEngagements( + contentId: string, + contentType: string, + page = 1, + limit = 10 + ): Promise<{ engagements: EngagementResponseDto[]; total: number }> { + const [engagements, total] = await this.repository.findContentEngagements( + contentId, + contentType, + page, + limit + ); + + const counters = await this.repository.getEngagementCounters({ + contentId, + contentType, + }); + + return { + engagements: engagements.map(engagement => + this.mapToResponseDto(engagement, counters) + ), + total, + }; + } +} \ No newline at end of file