diff --git a/src/ai-integration/ai-integration.module.ts b/src/ai-integration/ai-integration.module.ts index 59b2979..bacb4e0 100644 --- a/src/ai-integration/ai-integration.module.ts +++ b/src/ai-integration/ai-integration.module.ts @@ -33,4 +33,4 @@ import { QueueConsumerService } from './services/queue-consumer.service'; }, ], }) -export class AiIntegrationModule { } +export class AiIntegrationModule {} diff --git a/src/ai-integration/services/queue-consumer.service.ts b/src/ai-integration/services/queue-consumer.service.ts index 134badc..1cac3dd 100644 --- a/src/ai-integration/services/queue-consumer.service.ts +++ b/src/ai-integration/services/queue-consumer.service.ts @@ -1,10 +1,10 @@ -import { Processor, WorkerHost } from "@nestjs/bullmq"; -import { RedisQueues, Services } from "src/utils/constants"; -import { AiSummarizationService } from "./summarization.service"; +import { Processor, WorkerHost } from '@nestjs/bullmq'; +import { RedisQueues, Services } from 'src/utils/constants'; +import { AiSummarizationService } from './summarization.service'; import { Job } from 'bullmq'; -import { SummarizeJob } from "src/common/interfaces/summarizeJob.interface"; -import { Inject } from "@nestjs/common"; -import { PrismaService } from "src/prisma/prisma.service"; +import { SummarizeJob } from 'src/common/interfaces/summarizeJob.interface'; +import { Inject } from '@nestjs/common'; +import { PrismaService } from 'src/prisma/prisma.service'; @Processor(RedisQueues.postQueue.name) export class QueueConsumerService extends WorkerHost { @@ -30,11 +30,10 @@ export class QueueConsumerService extends WorkerHost { private async handleSummarizePostContent(job: Job) { const { postContent, postId } = job.data; const summary = await this.aiSummarizationService.summarizePost(postContent); - + await this.prismaService.post.update({ where: { id: postId }, data: { summary }, }); } - -} \ No newline at end of file +} diff --git a/src/ai-integration/services/summarization.service.ts b/src/ai-integration/services/summarization.service.ts index ce0b6f7..b53b8ca 100644 --- a/src/ai-integration/services/summarization.service.ts +++ b/src/ai-integration/services/summarization.service.ts @@ -22,21 +22,20 @@ export class AiSummarizationService { // GPT-4.1 / GPT-4o / GPT-o-mini etc. const response = await this.openai.responses.create({ - model: "gpt-4o-mini", // similar price/perf to gemini flash + model: 'gpt-4o-mini', // similar price/perf to gemini flash input: prompt, }); - const summary = - response.output_text; + const summary = response.output_text; if (!summary || summary.trim().length === 0) { - return "Summary unavailable."; + return 'Summary unavailable.'; } return summary; } catch (error) { - console.error("Error summarizing post:", error); - return "Summary unavailable."; + console.error('Error summarizing post:', error); + return 'Summary unavailable.'; } } } diff --git a/src/app.module.ts b/src/app.module.ts index e2f8aa4..4830e36 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -19,6 +19,7 @@ import { MessagesModule } from './messages/messages.module'; import { ConversationsModule } from './conversations/conversations.module'; import { PrismaModule } from './prisma/prisma.module'; import { AiIntegrationModule } from './ai-integration/ai-integration.module'; +import { GatewayModule } from './gateway/gateway.module'; import envSchema from './config/validate-config'; import { BullModule } from '@nestjs/bullmq'; import redisConfig from './config/redis.config'; @@ -83,6 +84,7 @@ const envFilePath = '.env'; ConversationsModule, PrismaModule, AiIntegrationModule, + GatewayModule, NotificationsModule, ], controllers: [], diff --git a/src/auth/guards/optional-jwt-auth/optional-jwt-auth.guard.spec.ts b/src/auth/guards/optional-jwt-auth/optional-jwt-auth.guard.spec.ts index 757d97b..d109f7c 100644 --- a/src/auth/guards/optional-jwt-auth/optional-jwt-auth.guard.spec.ts +++ b/src/auth/guards/optional-jwt-auth/optional-jwt-auth.guard.spec.ts @@ -1,7 +1,7 @@ -import { OptionalJwtAuthGuard } from './optional-jwt-auth.guard'; - -describe('OptionalJwtAuthGuard', () => { - it('should be defined', () => { - expect(new OptionalJwtAuthGuard()).toBeDefined(); - }); -}); +import { OptionalJwtAuthGuard } from './optional-jwt-auth.guard'; + +describe('OptionalJwtAuthGuard', () => { + it('should be defined', () => { + expect(new OptionalJwtAuthGuard()).toBeDefined(); + }); +}); diff --git a/src/auth/services/otp/otp.service.spec.ts b/src/auth/services/otp/otp.service.spec.ts index bafcc8b..4041521 100644 --- a/src/auth/services/otp/otp.service.spec.ts +++ b/src/auth/services/otp/otp.service.spec.ts @@ -1,34 +1,34 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { OtpService } from './otp.service'; -import { Services } from 'src/utils/constants'; - -describe('OtpService', () => { - let service: OtpService; - - const mockRedisService = { - get: jest.fn(), - set: jest.fn(), - del: jest.fn(), - }; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - { - provide: Services.OTP, - useClass: OtpService, - }, - { - provide: Services.REDIS, - useValue: mockRedisService, - }, - ], - }).compile(); - - service = module.get(Services.OTP); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); -}); +import { Test, TestingModule } from '@nestjs/testing'; +import { OtpService } from './otp.service'; +import { Services } from 'src/utils/constants'; + +describe('OtpService', () => { + let service: OtpService; + + const mockRedisService = { + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: Services.OTP, + useClass: OtpService, + }, + { + provide: Services.REDIS, + useValue: mockRedisService, + }, + ], + }).compile(); + + service = module.get(Services.OTP); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/common/config/mailer.config.ts b/src/common/config/mailer.config.ts index e97f91e..66d2f8d 100644 --- a/src/common/config/mailer.config.ts +++ b/src/common/config/mailer.config.ts @@ -4,7 +4,7 @@ export default registerAs('mailer', () => ({ // Use AWS SES first, fallback to Resend if it fails // Set to 'false' to use Resend only (skip AWS SES entirely) useAwsFirst: process.env.EMAIL_USE_AWS_FIRST !== 'false', // Default to true - + awsSes: { smtpHost: process.env.AWS_SES_SMTP_HOST || 'email-smtp.us-east-1.amazonaws.com', smtpPort: Number.parseInt(process.env.AWS_SES_SMTP_PORT || '587', 10), @@ -13,12 +13,12 @@ export default registerAs('mailer', () => ({ fromEmail: process.env.AWS_SES_FROM_EMAIL || 'noreply@hankers.tech', region: process.env.AWS_SES_REGION || 'us-east-1', }, - + resend: { apiKey: process.env.RESEND_API_KEY, fromEmail: process.env.RESEND_FROM_EMAIL || 'noreply@hankers.tech', }, - + azure: { connectionString: process.env.AZURE_EMAIL_CONNECTION_STRING, fromEmail: process.env.AZURE_EMAIL_FROM, diff --git a/src/common/interfaces/summarizeJob.interface.ts b/src/common/interfaces/summarizeJob.interface.ts index 0e20417..9bda086 100644 --- a/src/common/interfaces/summarizeJob.interface.ts +++ b/src/common/interfaces/summarizeJob.interface.ts @@ -1,4 +1,4 @@ export interface SummarizeJob { - postId: number; - postContent: string; -} \ No newline at end of file + postId: number; + postContent: string; +} diff --git a/src/config/configs.ts b/src/config/configs.ts index 5242363..c262ff6 100644 --- a/src/config/configs.ts +++ b/src/config/configs.ts @@ -3,5 +3,5 @@ import * as process from 'process'; dotenv.config(); export default { - openAiApiKey: process.env.OPENAI_API_KEY, -} \ No newline at end of file + openAiApiKey: process.env.OPENAI_API_KEY, +}; diff --git a/src/config/validate-config.ts b/src/config/validate-config.ts index fcbccfe..2a7ca4e 100644 --- a/src/config/validate-config.ts +++ b/src/config/validate-config.ts @@ -4,4 +4,4 @@ const envSchema = Joi.object({ OPENAI_API_KEY: Joi.string().required(), }).strict(); -export default envSchema; \ No newline at end of file +export default envSchema; diff --git a/src/email/email.controller.spec.ts b/src/email/email.controller.spec.ts index 2619ceb..ca08866 100644 --- a/src/email/email.controller.spec.ts +++ b/src/email/email.controller.spec.ts @@ -1,30 +1,30 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { EmailController } from './email.controller'; -import { EmailService } from './email.service'; -import { Services } from 'src/utils/constants'; - -describe('EmailController', () => { - let controller: EmailController; - - const mockEmailService = { - sendEmail: jest.fn(), - }; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [EmailController], - providers: [ - { - provide: Services.EMAIL, - useValue: mockEmailService, - }, - ], - }).compile(); - - controller = module.get(EmailController); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); +import { Test, TestingModule } from '@nestjs/testing'; +import { EmailController } from './email.controller'; +import { EmailService } from './email.service'; +import { Services } from 'src/utils/constants'; + +describe('EmailController', () => { + let controller: EmailController; + + const mockEmailService = { + sendEmail: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [EmailController], + providers: [ + { + provide: Services.EMAIL, + useValue: mockEmailService, + }, + ], + }).compile(); + + controller = module.get(EmailController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/email/email.service.ts b/src/email/email.service.ts index 94a247c..051c6ee 100644 --- a/src/email/email.service.ts +++ b/src/email/email.service.ts @@ -5,11 +5,7 @@ import { SendEmailDto } from './dto/send-email.dto'; import { readFileSync } from 'fs'; import { join } from 'path'; import { Resend } from 'resend'; -import { - EmailClient, - EmailMessage, - KnownEmailSendStatus, -} from '@azure/communication-email'; +import { EmailClient, EmailMessage, KnownEmailSendStatus } from '@azure/communication-email'; import * as nodemailer from 'nodemailer'; import type { Transporter } from 'nodemailer'; @@ -131,7 +127,9 @@ export class EmailService { }); try { - this.logger.log(`📧 [AWS SES] Sending email from: ${this.mailerConfiguration.awsSes.fromEmail}`); + this.logger.log( + `📧 [AWS SES] Sending email from: ${this.mailerConfiguration.awsSes.fromEmail}`, + ); this.logger.log(`📧 [AWS SES] Recipients: ${toRecipients.join(', ')}`); const info = await this.awsSesTransporter.sendMail({ @@ -172,7 +170,9 @@ export class EmailService { }); try { - this.logger.log(`📧 [RESEND] Sending email from: ${this.mailerConfiguration.resend.fromEmail}`); + this.logger.log( + `📧 [RESEND] Sending email from: ${this.mailerConfiguration.resend.fromEmail}`, + ); this.logger.log(`📧 [RESEND] Recipients: ${toRecipients.join(', ')}`); const response = await this.resendClient.emails.send({ @@ -233,7 +233,7 @@ export class EmailService { try { this.logger.log(`📧 [AZURE] Sending email from: ${this.mailerConfiguration.azure.fromEmail}`); - const recipientEmails = recipients.map(r => typeof r === 'string' ? r : r.email); + const recipientEmails = recipients.map((r) => (typeof r === 'string' ? r : r.email)); this.logger.log(`📧 [AZURE] Recipients: ${recipientEmails.join(', ')}`); const poller = await this.azureClient.beginSend(message); diff --git a/src/gateway/gateway.module.ts b/src/gateway/gateway.module.ts new file mode 100644 index 0000000..220db44 --- /dev/null +++ b/src/gateway/gateway.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { SocketGateway } from './socket.gateway'; +import { SocketService } from './socket.service'; +import { MessagesModule } from 'src/messages/messages.module'; + +@Module({ + imports: [MessagesModule], + providers: [SocketGateway, SocketService], + exports: [SocketService, SocketGateway], +}) +export class GatewayModule {} diff --git a/src/messages/messages.gateway.ts b/src/gateway/socket.gateway.ts similarity index 85% rename from src/messages/messages.gateway.ts rename to src/gateway/socket.gateway.ts index 1014720..871c472 100644 --- a/src/messages/messages.gateway.ts +++ b/src/gateway/socket.gateway.ts @@ -15,11 +15,11 @@ import { Server, Socket } from 'socket.io'; import { createAdapter } from '@socket.io/redis-adapter'; import { createClient } from 'redis'; import redisConfig from 'src/config/redis.config'; -import { MessagesService } from './messages.service'; -import { CreateMessageDto } from './dto/create-message.dto'; -import { UpdateMessageDto } from './dto/update-message.dto'; -import { MarkSeenDto } from './dto/mark-seen.dto'; -import { WebSocketExceptionFilter } from './exceptions/ws-exception.filter'; +import { MessagesService } from 'src/messages/messages.service'; +import { CreateMessageDto } from 'src/messages/dto/create-message.dto'; +import { UpdateMessageDto } from 'src/messages/dto/update-message.dto'; +import { MarkSeenDto } from 'src/messages/dto/mark-seen.dto'; +import { WebSocketExceptionFilter } from 'src/messages/exceptions/ws-exception.filter'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { NotificationType } from 'src/notifications/enums/notification.enum'; @@ -29,7 +29,7 @@ import { NotificationType } from 'src/notifications/enums/notification.enum'; }, }) @UseFilters(new WebSocketExceptionFilter()) -export class MessagesGateway implements OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit { +export class SocketGateway implements OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit { constructor( @Inject(Services.MESSAGES) private readonly messagesService: MessagesService, @@ -90,6 +90,8 @@ export class MessagesGateway implements OnGatewayConnection, OnGatewayDisconnect } } + // ======================== CONVERSATION HANDLERS ======================== + @SubscribeMessage('joinConversation') async handleJoin(@MessageBody() conversationId: number, @ConnectedSocket() socket: Socket) { try { @@ -432,4 +434,68 @@ export class MessagesGateway implements OnGatewayConnection, OnGatewayDisconnect throw error; } } + + // ======================== POST HANDLERS ======================== + + @SubscribeMessage('joinPost') + async handleJoinPost(@MessageBody() postId: number, @ConnectedSocket() socket: Socket) { + try { + const userId = socket.data.userId; + + if (!userId) { + throw new UnauthorizedException('User not authenticated'); + } + + const parsedPostId = Number(postId); + socket.join(`post_${parsedPostId}`); + + return { + status: 'success', + postId: parsedPostId, + message: 'Joined post room successfully', + }; + } catch (error) { + console.error(`Error joining post room: ${error.message}`); + throw error; + } + } + + @SubscribeMessage('leavePost') + async handleLeavePost(@MessageBody() postId: number, @ConnectedSocket() socket: Socket) { + try { + const userId = socket.data.userId; + + if (!userId) { + throw new UnauthorizedException('User not authenticated'); + } + + const parsedPostId = Number(postId); + socket.leave(`post_${parsedPostId}`); + + return { + status: 'success', + postId: parsedPostId, + message: 'Left post room successfully', + }; + } catch (error) { + console.error(`Error leaving post room: ${error.message}`); + throw error; + } + } + + // ======================== SOCKET EMIT HELPERS ======================== + + /** + * Emit a post stats update to all clients in the post room + */ + emitPostStatsUpdate( + postId: number, + eventName: 'likeUpdate' | 'repostUpdate' | 'commentUpdate', + count: number, + ) { + this.server.to(`post_${postId}`).emit(eventName, { + postId, + count, + }); + } } diff --git a/src/gateway/socket.service.ts b/src/gateway/socket.service.ts new file mode 100644 index 0000000..d5379f8 --- /dev/null +++ b/src/gateway/socket.service.ts @@ -0,0 +1,15 @@ +import { Injectable } from '@nestjs/common'; +import { SocketGateway } from './socket.gateway'; + +@Injectable() +export class SocketService { + constructor(private readonly socketGateway: SocketGateway) {} + + emitPostStatsUpdate( + postId: number, + eventName: 'likeUpdate' | 'repostUpdate' | 'commentUpdate', + count: number, + ) { + this.socketGateway.emitPostStatsUpdate(postId, eventName, count); + } +} diff --git a/src/messages/messages.gateway.spec.ts b/src/messages/messages.gateway.spec.ts deleted file mode 100644 index c5df3de..0000000 --- a/src/messages/messages.gateway.spec.ts +++ /dev/null @@ -1,401 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { MessagesGateway } from './messages.gateway'; -import { MessagesService } from './messages.service'; -import { Services } from 'src/utils/constants'; -import { UnauthorizedException } from '@nestjs/common'; -import { Server, Socket } from 'socket.io'; -import redisConfig from 'src/config/redis.config'; - -describe('MessagesGateway', () => { - let gateway: MessagesGateway; - let messagesService: MessagesService; - let mockServer: Partial; - let mockSocket: Partial; - - const mockMessagesService = { - isUserInConversation: jest.fn(), - markMessagesAsSeen: jest.fn(), - create: jest.fn(), - update: jest.fn(), - getConversationUsers: jest.fn(), - }; - - const mockRedisConfig = { - redisHost: 'localhost', - redisPort: 6379, - }; - - beforeEach(async () => { - mockServer = { - to: jest.fn().mockReturnThis(), - emit: jest.fn(), - sockets: { - adapter: { - rooms: new Map(), - }, - } as any, - }; - - mockSocket = { - id: 'socket-123', - data: { userId: 1, username: 'testuser' }, - join: jest.fn(), - to: jest.fn().mockReturnThis(), - emit: jest.fn(), - disconnect: jest.fn(), - }; - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - MessagesGateway, - { - provide: Services.MESSAGES, - useValue: mockMessagesService, - }, - { - provide: redisConfig.KEY, - useValue: mockRedisConfig, - }, - ], - }).compile(); - - gateway = module.get(MessagesGateway); - messagesService = module.get(Services.MESSAGES); - gateway.server = mockServer as Server; - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('should be defined', () => { - expect(gateway).toBeDefined(); - }); - - describe('handleConnection', () => { - it('should track connected user and join user room', () => { - gateway.handleConnection(mockSocket as Socket); - - expect(mockSocket.join).toHaveBeenCalledWith('user_1'); - }); - - it('should disconnect socket if userId is missing', () => { - const socketWithoutUser = { ...mockSocket, data: {} }; - - gateway.handleConnection(socketWithoutUser as Socket); - - expect(socketWithoutUser.disconnect).toHaveBeenCalled(); - }); - - it('should add multiple sockets for the same user', () => { - const socket1 = { ...mockSocket, id: 'socket-1', join: jest.fn() }; - const socket2 = { ...mockSocket, id: 'socket-2', join: jest.fn() }; - - gateway.handleConnection(socket1 as unknown as Socket); - gateway.handleConnection(socket2 as unknown as Socket); - - expect(socket1.join).toHaveBeenCalledWith('user_1'); - expect(socket2.join).toHaveBeenCalledWith('user_1'); - }); - }); - - describe('handleDisconnect', () => { - it('should remove socket from connected users', () => { - gateway.handleConnection(mockSocket as Socket); - expect(mockSocket.join).toHaveBeenCalledWith('user_1'); - - gateway.handleDisconnect(mockSocket as Socket); - - // Just verify it doesn't throw - expect(true).toBe(true); - }); - - it('should keep user in map if they have other active sockets', () => { - const socket1 = { ...mockSocket, id: 'socket-1', join: jest.fn() }; - const socket2 = { ...mockSocket, id: 'socket-2', join: jest.fn() }; - - gateway.handleConnection(socket1 as unknown as Socket); - gateway.handleConnection(socket2 as unknown as Socket); - - gateway.handleDisconnect(socket1 as unknown as Socket); - - // Both sockets joined the same room, socket2 should still be in user_1 - expect(socket1.join).toHaveBeenCalledWith('user_1'); - expect(socket2.join).toHaveBeenCalledWith('user_1'); - }); - - it('should handle disconnect gracefully if userId is missing', () => { - const socketWithoutUser = { ...mockSocket, data: {} }; - - expect(() => gateway.handleDisconnect(socketWithoutUser as Socket)).not.toThrow(); - }); - }); - - describe('handleJoin', () => { - it('should allow user to join conversation if they are a participant', async () => { - mockMessagesService.isUserInConversation.mockResolvedValue(true); - mockMessagesService.markMessagesAsSeen.mockResolvedValue({ count: 5 }); - - const result = await gateway.handleJoin(1, mockSocket as Socket); - - expect(result.status).toBe('success'); - expect(mockSocket.join).toHaveBeenCalledWith('conversation_1'); - expect(messagesService.isUserInConversation).toHaveBeenCalledWith({ - conversationId: 1, - senderId: 1, - text: '', - }); - expect(messagesService.markMessagesAsSeen).toHaveBeenCalledWith(1, 1); - }); - - it('should throw UnauthorizedException if user is not authenticated', async () => { - const unauthSocket = { ...mockSocket, data: {} }; - - await expect(gateway.handleJoin(1, unauthSocket as Socket)).rejects.toThrow( - UnauthorizedException, - ); - }); - - it('should throw UnauthorizedException if user is not a participant', async () => { - mockMessagesService.isUserInConversation.mockResolvedValue(false); - - await expect(gateway.handleJoin(1, mockSocket as Socket)).rejects.toThrow( - UnauthorizedException, - ); - }); - - it('should handle string conversationId by parsing to number', async () => { - mockMessagesService.isUserInConversation.mockResolvedValue(true); - mockMessagesService.markMessagesAsSeen.mockResolvedValue({ count: 0 }); - - await gateway.handleJoin('1' as any, mockSocket as Socket); - - expect(mockSocket.join).toHaveBeenCalledWith('conversation_1'); - }); - }); - - describe('create', () => { - const createMessageDto = { - conversationId: 1, - senderId: 1, - text: 'Hello, World!', - }; - - const mockMessage = { - id: 1, - senderId: 1, - conversationId: 1, - text: 'Hello, World!', - createdAt: new Date(), - }; - - const mockParticipants = { - user1Id: 1, - user2Id: 2, - }; - - beforeEach(() => { - mockMessagesService.getConversationUsers.mockResolvedValue(mockParticipants); - mockMessagesService.create.mockResolvedValue(mockMessage); - }); - - it('should create a message and emit to conversation room', async () => { - const conversationRoom = new Set(['socket-1', 'socket-2']); - const recipientRoom = new Set(['socket-2']); - - mockServer.sockets!.adapter.rooms.set('conversation_1', conversationRoom); - mockServer.sockets!.adapter.rooms.set('user_2', recipientRoom); - - const result = await gateway.create(createMessageDto, mockSocket as Socket); - - expect(result.status).toBe('success'); - expect(result.data).toEqual(mockMessage); - expect(messagesService.create).toHaveBeenCalledWith(createMessageDto); - expect(mockServer.to).toHaveBeenCalledWith('conversation_1'); - expect(mockServer.emit).toHaveBeenCalledWith('messageCreated', mockMessage); - }); - - it('should throw UnauthorizedException if user is not authenticated', async () => { - const unauthSocket = { ...mockSocket, data: {} }; - - await expect(gateway.create(createMessageDto, unauthSocket as Socket)).rejects.toThrow( - UnauthorizedException, - ); - }); - - it('should throw UnauthorizedException if senderId does not match authenticated user', async () => { - const invalidDto = { ...createMessageDto, senderId: 2 }; - - await expect(gateway.create(invalidDto, mockSocket as Socket)).rejects.toThrow( - UnauthorizedException, - ); - }); - - it('should throw UnauthorizedException if user is not part of conversation', async () => { - mockMessagesService.getConversationUsers.mockResolvedValue({ - user1Id: 2, - user2Id: 3, - }); - - await expect(gateway.create(createMessageDto, mockSocket as Socket)).rejects.toThrow( - UnauthorizedException, - ); - }); - - it('should send notification to recipient if not in conversation room', async () => { - const conversationRoom = new Set(['socket-1']); - const recipientRoom = new Set(['socket-3']); - - mockServer.sockets!.adapter.rooms.set('conversation_1', conversationRoom); - mockServer.sockets!.adapter.rooms.set('user_2', recipientRoom); - - await gateway.create(createMessageDto, mockSocket as Socket); - - expect(mockServer.to).toHaveBeenCalledWith('user_2'); - expect(mockServer.emit).toHaveBeenCalledWith('newMessageNotification', mockMessage); - }); - }); - - describe('update', () => { - const updateMessageDto = { - id: 1, - senderId: 1, - text: 'Updated text', - }; - - const mockUpdatedMessage = { - id: 1, - senderId: 1, - conversationId: 1, - text: 'Updated text', - updatedAt: new Date(), - }; - - const mockParticipants = { - user1Id: 1, - user2Id: 2, - }; - - beforeEach(() => { - mockMessagesService.update.mockResolvedValue(mockUpdatedMessage); - mockMessagesService.getConversationUsers.mockResolvedValue(mockParticipants); - }); - - it('should update message and emit to conversation room', async () => { - const conversationRoom = new Set(['socket-1', 'socket-2']); - const recipientRoom = new Set(['socket-2']); - - mockServer.sockets!.adapter.rooms.set('conversation_1', conversationRoom); - mockServer.sockets!.adapter.rooms.set('user_2', recipientRoom); - - const result = await gateway.update(updateMessageDto, mockSocket as Socket); - - expect(result.status).toBe('success'); - expect(result.data).toEqual(mockUpdatedMessage); - expect(messagesService.update).toHaveBeenCalledWith(updateMessageDto, 1); - expect(mockServer.to).toHaveBeenCalledWith('conversation_1'); - expect(mockServer.emit).toHaveBeenCalledWith('messageUpdated', mockUpdatedMessage); - }); - - it('should throw UnauthorizedException if user is not authenticated', async () => { - const unauthSocket = { ...mockSocket, data: {} }; - - await expect(gateway.update(updateMessageDto, unauthSocket as Socket)).rejects.toThrow( - UnauthorizedException, - ); - }); - - it('should send edit notification if recipient not in conversation room', async () => { - const conversationRoom = new Set(['socket-1']); - const recipientRoom = new Set(['socket-3']); - - mockServer.sockets!.adapter.rooms.set('conversation_1', conversationRoom); - mockServer.sockets!.adapter.rooms.set('user_2', recipientRoom); - - await gateway.update(updateMessageDto, mockSocket as Socket); - - expect(mockServer.to).toHaveBeenCalledWith('user_2'); - expect(mockServer.emit).toHaveBeenCalledWith('editMessageNotification', mockUpdatedMessage); - }); - }); - - describe('markMessagesAsSeen', () => { - const markSeenDto = { - conversationId: 1, - userId: 1, - }; - - it('should mark messages as seen and notify others', async () => { - mockMessagesService.markMessagesAsSeen.mockResolvedValue(undefined); - - const result = await gateway.markMessagesAsSeen(markSeenDto, mockSocket as Socket); - - expect(result.status).toBe('success'); - expect(messagesService.markMessagesAsSeen).toHaveBeenCalledWith(1, 1); - expect(mockSocket.to).toHaveBeenCalledWith('conversation_1'); - expect(mockSocket.emit).toHaveBeenCalledWith('messagesSeen', { - conversationId: 1, - userId: 1, - timestamp: expect.any(String), - }); - }); - - it('should throw UnauthorizedException if user is not authenticated', async () => { - const unauthSocket = { ...mockSocket, data: {} }; - - await expect(gateway.markMessagesAsSeen(markSeenDto, unauthSocket as Socket)).rejects.toThrow( - UnauthorizedException, - ); - }); - - it('should throw UnauthorizedException if userId does not match', async () => { - const invalidDto = { ...markSeenDto, userId: 2 }; - - await expect(gateway.markMessagesAsSeen(invalidDto, mockSocket as Socket)).rejects.toThrow( - UnauthorizedException, - ); - }); - }); - - describe('handleTyping', () => { - it('should emit typing event to others in conversation', async () => { - const result = await gateway.handleTyping({ conversationId: 1 }, mockSocket as Socket); - - expect(result.status).toBe('success'); - expect(mockSocket.to).toHaveBeenCalledWith('conversation_1'); - expect(mockSocket.emit).toHaveBeenCalledWith('userTyping', { - conversationId: 1, - userId: 1, - }); - }); - - it('should throw UnauthorizedException if user is not authenticated', async () => { - const unauthSocket = { ...mockSocket, data: {} }; - - await expect( - gateway.handleTyping({ conversationId: 1 }, unauthSocket as Socket), - ).rejects.toThrow(UnauthorizedException); - }); - }); - - describe('handleStopTyping', () => { - it('should emit stop typing event to others in conversation', async () => { - const result = await gateway.handleStopTyping({ conversationId: 1 }, mockSocket as Socket); - - expect(result.status).toBe('success'); - expect(mockSocket.to).toHaveBeenCalledWith('conversation_1'); - expect(mockSocket.emit).toHaveBeenCalledWith('userStoppedTyping', { - conversationId: 1, - userId: 1, - }); - }); - - it('should throw UnauthorizedException if user is not authenticated', async () => { - const unauthSocket = { ...mockSocket, data: {} }; - - await expect( - gateway.handleStopTyping({ conversationId: 1 }, unauthSocket as Socket), - ).rejects.toThrow(UnauthorizedException); - }); - }); -}); diff --git a/src/messages/messages.module.ts b/src/messages/messages.module.ts index 627b0d8..97e9670 100644 --- a/src/messages/messages.module.ts +++ b/src/messages/messages.module.ts @@ -1,6 +1,5 @@ import { Module } from '@nestjs/common'; import { MessagesService } from './messages.service'; -import { MessagesGateway } from './messages.gateway'; import { MessagesController } from './messages.controller'; import { Services } from 'src/utils/constants'; import { JwtModule } from '@nestjs/jwt'; @@ -25,11 +24,11 @@ import redisConfig from 'src/config/redis.config'; ], controllers: [MessagesController], providers: [ - MessagesGateway, { provide: Services.MESSAGES, useClass: MessagesService, }, ], + exports: [Services.MESSAGES], }) export class MessagesModule {} diff --git a/src/post/decorators/content-required-if-no-media.decorator.ts b/src/post/decorators/content-required-if-no-media.decorator.ts index 81256af..7a88265 100644 --- a/src/post/decorators/content-required-if-no-media.decorator.ts +++ b/src/post/decorators/content-required-if-no-media.decorator.ts @@ -1,8 +1,4 @@ -import { - registerDecorator, - ValidationArguments, - ValidationOptions, -} from 'class-validator'; +import { registerDecorator, ValidationArguments, ValidationOptions } from 'class-validator'; export function IsContentRequiredIfNoMedia(validationOptions?: ValidationOptions) { return function (object: Object, propertyName: string) { @@ -15,8 +11,7 @@ export function IsContentRequiredIfNoMedia(validationOptions?: ValidationOptions validate(value: any, args: ValidationArguments) { const dto = args.object as any; - const hasMedia = - Array.isArray(dto.media) && dto.media.length > 0; + const hasMedia = Array.isArray(dto.media) && dto.media.length > 0; if (!hasMedia) { return typeof value === 'string' && value.trim().length > 0; diff --git a/src/post/decorators/is-parent-id-allowed.decorator.ts b/src/post/decorators/is-parent-id-allowed.decorator.ts index ccbaacd..880f084 100644 --- a/src/post/decorators/is-parent-id-allowed.decorator.ts +++ b/src/post/decorators/is-parent-id-allowed.decorator.ts @@ -1,9 +1,5 @@ // src/common/validators/parent-id.validator.ts -import { - registerDecorator, - ValidationArguments, - ValidationOptions, -} from 'class-validator'; +import { registerDecorator, ValidationArguments, ValidationOptions } from 'class-validator'; import { PostType } from '@prisma/client'; export function IsParentIdAllowed(validationOptions?: ValidationOptions) { @@ -18,7 +14,7 @@ export function IsParentIdAllowed(validationOptions?: ValidationOptions) { const dto = args.object as any; if (dto.type === PostType.POST && value !== undefined && value !== null) { - return false; + return false; } return true; diff --git a/src/post/decorators/parent-required-for-reply-or-quote.decorator.ts b/src/post/decorators/parent-required-for-reply-or-quote.decorator.ts index c3d266e..de1f182 100644 --- a/src/post/decorators/parent-required-for-reply-or-quote.decorator.ts +++ b/src/post/decorators/parent-required-for-reply-or-quote.decorator.ts @@ -13,8 +13,10 @@ export function IsParentRequiredForReplyOrQuote(validationOptions?: ValidationOp const dto = args.object as any; // If type is REPLY or QUOTE → parentId must exist - if ((dto.type === PostType.REPLY || dto.type === PostType.QUOTE) && - (dto.parentId === null || dto.parentId === undefined)) { + if ( + (dto.type === PostType.REPLY || dto.type === PostType.QUOTE) && + (dto.parentId === null || dto.parentId === undefined) + ) { return false; } diff --git a/src/post/dto/create-post.dto.ts b/src/post/dto/create-post.dto.ts index edfbbdb..7093b3b 100644 --- a/src/post/dto/create-post.dto.ts +++ b/src/post/dto/create-post.dto.ts @@ -1,5 +1,13 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsArray, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, MaxLength } from 'class-validator'; +import { + IsArray, + IsEnum, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + MaxLength, +} from 'class-validator'; import { PostType, PostVisibility } from '@prisma/client'; import { Transform } from 'class-transformer'; import { IsParentIdAllowed } from '../decorators/is-parent-id-allowed.decorator'; @@ -70,7 +78,7 @@ export class CreatePostDto { @IsOptional() @IsArray() @IsNumber({}, { each: true }) - @Transform(({ value }) => { + @Transform(({ value }) => { if (!value) return undefined; const parsed = JSON.parse(value); if (Array.isArray(parsed)) { diff --git a/src/post/dto/post-stats-response.dto.ts b/src/post/dto/post-stats-response.dto.ts new file mode 100644 index 0000000..819395d --- /dev/null +++ b/src/post/dto/post-stats-response.dto.ts @@ -0,0 +1,41 @@ +import { ApiProperty } from '@nestjs/swagger'; + +class PostCountsDto { + @ApiProperty({ + description: 'Number of likes on the post', + example: 150, + }) + likesCount: number; + + @ApiProperty({ + description: 'Number of reposts of the post', + example: 75, + }) + retweetsCount: number; + + @ApiProperty({ + description: 'Number of replies to the post', + example: 30, + }) + commentsCount: number; +} + +export class GetPostStatsResponseDto { + @ApiProperty({ + description: 'Status of the response', + example: 'success', + }) + status: string; + + @ApiProperty({ + description: 'Response message', + example: 'Post stats retrieved successfully', + }) + message: string; + + @ApiProperty({ + description: 'The post stats data', + type: PostCountsDto, + }) + data: PostCountsDto; +} diff --git a/src/post/interfaces/post.interface.ts b/src/post/interfaces/post.interface.ts index 10567fc..dea7df0 100644 --- a/src/post/interfaces/post.interface.ts +++ b/src/post/interfaces/post.interface.ts @@ -36,9 +36,9 @@ export interface RawPost { replyCount: number; User: User; media: Media[]; - likes: { user_id: number; }[]; - repostedBy: { user_id: number; }[]; - mentions: { user_id: number; }[]; + likes: { user_id: number }[]; + repostedBy: { user_id: number }[]; + mentions: { user_id: number }[]; } export interface TransformedPost { diff --git a/src/post/post-timeline.service.spec.ts b/src/post/post-timeline.service.spec.ts index 077cdf1..75acbbe 100644 --- a/src/post/post-timeline.service.spec.ts +++ b/src/post/post-timeline.service.spec.ts @@ -119,7 +119,10 @@ describe('PostService - Timeline Endpoints', () => { { ...mockPostWithAllData, id: 1, personalizationScore: 30.0 }, { ...mockPostWithAllData, id: 2, personalizationScore: 20.0 }, ]; - const qualityScores = new Map([[1, 0.7], [2, 0.9]]); + const qualityScores = new Map([ + [1, 0.7], + [2, 0.9], + ]); mockPrismaService.$queryRawUnsafe.mockResolvedValue(candidatePosts); mockMlService.getQualityScores.mockResolvedValue(qualityScores); @@ -362,7 +365,10 @@ describe('PostService - Timeline Endpoints', () => { { ...mockPostWithAllData, id: 1, interest_id: 1, personalizationScore: 50.0 }, { ...mockPostWithAllData, id: 2, interest_id: null, personalizationScore: 15.0 }, ]; - const qualityScores = new Map([[1, 0.8], [2, 0.9]]); + const qualityScores = new Map([ + [1, 0.8], + [2, 0.9], + ]); mockPrismaService.$queryRawUnsafe.mockResolvedValue(posts); mockMlService.getQualityScores.mockResolvedValue(qualityScores); diff --git a/src/post/post.controller.ts b/src/post/post.controller.ts index c2d2076..0bfe64b 100644 --- a/src/post/post.controller.ts +++ b/src/post/post.controller.ts @@ -45,6 +45,7 @@ import { import { ToggleRepostResponseDto, GetRepostersResponseDto } from './dto/repost-response.dto'; import { SearchByHashtagResponseDto } from './dto/hashtag-search-response.dto'; import { SearchPostsResponseDto } from './dto/search-response.dto'; +import { GetPostStatsResponseDto } from './dto/post-stats-response.dto'; import { ErrorResponseDto } from 'src/common/dto/error-response.dto'; import { JwtAuthGuard } from 'src/auth/guards/jwt-auth/jwt-auth.guard'; @@ -389,6 +390,45 @@ export class PostController { }; } + @Get(':postId/stats') + @UseGuards(JwtAuthGuard) + @ApiCookieAuth() + @ApiOperation({ + summary: 'Get post stats', + description: + 'Retrieves engagement stats for a post including likes count, reposts count, replies count, and quotes count. Stats are cached for performance.', + }) + @ApiParam({ + name: 'postId', + type: Number, + description: 'The ID of the post to get stats for', + example: 1, + }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Post stats retrieved successfully', + type: GetPostStatsResponseDto, + }) + @ApiResponse({ + status: HttpStatus.NOT_FOUND, + description: 'Post not found', + type: ErrorResponseDto, + }) + @ApiResponse({ + status: HttpStatus.UNAUTHORIZED, + description: 'Unauthorized - Token missing or invalid', + type: ErrorResponseDto, + }) + async getPostStats(@Param('postId') postId: number) { + const stats = await this.postService.getPostStats(+postId); + + return { + status: 'success', + message: 'Post stats retrieved successfully', + data: stats, + }; + } + @Get('summary/:postId') @UseGuards(JwtAuthGuard) @ApiCookieAuth() diff --git a/src/post/post.module.ts b/src/post/post.module.ts index 21bc99b..ae60560 100644 --- a/src/post/post.module.ts +++ b/src/post/post.module.ts @@ -11,6 +11,8 @@ import { AiSummarizationService } from 'src/ai-integration/services/summarizatio import { BullModule } from '@nestjs/bullmq'; import { HttpModule } from '@nestjs/axios'; import { MLService } from './services/ml.service'; +import { RedisModule } from 'src/redis/redis.module'; +import { GatewayModule } from 'src/gateway/gateway.module'; @Module({ controllers: [PostController], @@ -49,6 +51,8 @@ import { MLService } from './services/ml.service'; imports: [ PrismaModule, HttpModule, + RedisModule, + GatewayModule, BullModule.registerQueue({ name: RedisQueues.postQueue.name, defaultJobOptions: { diff --git a/src/post/services/like.service.ts b/src/post/services/like.service.ts index 3a4d918..9754d4e 100644 --- a/src/post/services/like.service.ts +++ b/src/post/services/like.service.ts @@ -13,7 +13,7 @@ export class LikeService { @Inject(Services.POST) private readonly postService: PostService, private readonly eventEmitter: EventEmitter2, - ) { } + ) {} async togglePostLike(postId: number, userId: number) { const existingLike = await this.prismaService.like.findUnique({ @@ -34,6 +34,9 @@ export class LikeService { }, }); + // Update/create cache and emit WebSocket event + await this.postService.updatePostStatsCache(postId, 'likesCount', -1); + return { liked: false, message: 'Post unliked' }; } @@ -50,6 +53,9 @@ export class LikeService { }, }); + // Update/create cache and emit WebSocket event + await this.postService.updatePostStatsCache(postId, 'likesCount', 1); + // Emit notification event (don't notify yourself) if (post && post.user_id !== userId) { this.eventEmitter.emit('notification.create', { @@ -93,25 +99,21 @@ export class LikeService { skip: (page - 1) * limit, take: limit, }); - - const likedPostsIds = likes.map(like => like.post_id); + + const likedPostsIds = likes.map((like) => like.post_id); const likedPosts = await this.postService.findPosts({ where: { is_deleted: false, - id: { in: likedPostsIds } + id: { in: likedPostsIds }, }, userId, limit, - page - }) - const orderMap = new Map( - likes.map((m, index) => [m.post_id, index]) - ); + page, + }); + const orderMap = new Map(likes.map((m, index) => [m.post_id, index])); - likedPosts.sort( - (a, b) => orderMap.get(a.postId)! - orderMap.get(b.postId)! - ); + likedPosts.sort((a, b) => orderMap.get(a.postId)! - orderMap.get(b.postId)!); return likedPosts; } } diff --git a/src/post/services/mention.service.ts b/src/post/services/mention.service.ts index e580189..d789f23 100644 --- a/src/post/services/mention.service.ts +++ b/src/post/services/mention.service.ts @@ -13,7 +13,7 @@ export class MentionService { @Inject(Services.POST) private readonly postService: PostService, private readonly eventEmitter: EventEmitter2, - ) { } + ) {} private async checkUserExists(userId: number) { const user = await this.prismaService.user.findUnique({ @@ -84,19 +84,19 @@ export class MentionService { take: limit, }); - const postsIds = mentions.map(mention => mention.post_id); + const postsIds = mentions.map((mention) => mention.post_id); const mentionPosts = await this.postService.findPosts({ where: { is_deleted: false, - id: { in: postsIds } + id: { in: postsIds }, }, userId, limit, - page - }) - - return mentionPosts + page, + }); + + return mentionPosts; } async getMentionsForPost(postId: number, page: number = 1, limit: number = 10) { diff --git a/src/post/services/post.service.ts b/src/post/services/post.service.ts index 11d892d..ebb8a43 100644 --- a/src/post/services/post.service.ts +++ b/src/post/services/post.service.ts @@ -18,10 +18,15 @@ import { Queue } from 'bullmq'; import { SummarizeJob } from 'src/common/interfaces/summarizeJob.interface'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { NotificationType } from 'src/notifications/enums/notification.enum'; +import { RedisService } from 'src/redis/redis.service'; +import { SocketService } from 'src/gateway/socket.service'; import { MLService } from './ml.service'; import { RawPost, TransformedPost } from '../interfaces/post.interface'; +export const POST_STATS_CACHE_PREFIX = 'post_stats:'; +const POST_STATS_CACHE_TTL = 300; // 5 minutes in seconds + // This interface now reflects the complex object returned by our query export interface FeedPostResponse { @@ -241,7 +246,10 @@ export class PostService { @InjectQueue(RedisQueues.postQueue.name) private readonly postQueue: Queue, private readonly eventEmitter: EventEmitter2, - ) { } + @Inject(Services.REDIS) + private readonly redisService: RedisService, + private readonly socketService: SocketService, + ) {} private extractHashtags(content: string): string[] { if (!content) return []; @@ -391,10 +399,11 @@ export class PostService { }); await tx.mention.createMany({ - data: postData.mentionsIds?.map((id) => ({ - post_id: post.id, - user_id: id, - })) ?? [], + data: + postData.mentionsIds?.map((id) => ({ + post_id: post.id, + user_id: id, + })) ?? [], }); // Handle notifications after transaction @@ -443,9 +452,8 @@ export class PostService { select: { id: true }, }); - if (existingUsers.length !== uniqueIds.length) { - throw new UnprocessableEntityException("Some user IDs are invalid") + throw new UnprocessableEntityException('Some user IDs are invalid'); } } @@ -454,8 +462,8 @@ export class PostService { try { const { content, media, userId } = createPostDto; urls = await this.storageService.uploadFiles(media); - console.log(createPostDto.mentionsIds) - await this.checkUsersExistence(createPostDto.mentionsIds ?? []) + console.log(createPostDto.mentionsIds); + await this.checkUsersExistence(createPostDto.mentionsIds ?? []); const hashtags = this.extractHashtags(content); @@ -463,6 +471,14 @@ export class PostService { const post = await this.createPostTransaction(createPostDto, hashtags, mediaWithType); + // Update parent post stats cache if this is a reply or quote + if ( + createPostDto.parentId && + (createPostDto.type === 'REPLY' || createPostDto.type === 'QUOTE') + ) { + await this.updatePostStatsCache(createPostDto.parentId, 'commentsCount', 1); + } + if (post.content) { await this.addToSummarizationQueue({ postContent: post.content, postId: post.id }); } @@ -511,16 +527,16 @@ export class PostService { const where = hasFilters ? { - ...(userId && { user_id: userId }), - ...(hashtag && { hashtags: { some: { tag: hashtag } } }), - ...(type && { type }), - is_deleted: false, - } + ...(userId && { user_id: userId }), + ...(hashtag && { hashtags: { some: { tag: hashtag } } }), + ...(type && { type }), + is_deleted: false, + } : { - // TODO: improve this fallback - visibility: PostVisibility.EVERY_ONE, // fallback: only public posts - is_deleted: false, - }; + // TODO: improve this fallback + visibility: PostVisibility.EVERY_ONE, // fallback: only public posts + is_deleted: false, + }; const posts = await this.prismaService.post.findMany({ where, @@ -711,12 +727,12 @@ export class PostService { isSimpleRepost && post.repostedBy ? post.repostedBy : { - userId: post.user_id, - username: post.username, - verified: post.isVerified, - name: post.authorName || post.username, - avatar: post.authorProfileImage, - }; + userId: post.user_id, + username: post.username, + verified: post.isVerified, + name: post.authorName || post.username, + avatar: post.authorProfileImage, + }; // Build originalPostData let originalPostData: any = null; @@ -1079,7 +1095,7 @@ export class PostService { } async deletePost(postId: number) { - return this.prismaService.$transaction(async (tx) => { + const result = await this.prismaService.$transaction(async (tx) => { const post = await tx.post.findFirst({ where: { id: postId, is_deleted: false }, }); @@ -1105,11 +1121,20 @@ export class PostService { where: { post_id: { in: postIds } }, }); - return tx.post.updateMany({ + await tx.post.updateMany({ where: { id: { in: postIds } }, data: { is_deleted: true }, }); + + return { post, repliesAndQuotesCount: repliesAndQuotes.length }; }); + + // Update parent post stats cache if this was a reply or quote + if (result.post.parent_id && (result.post.type === 'REPLY' || result.post.type === 'QUOTE')) { + await this.updatePostStatsCache(result.post.parent_id, 'commentsCount', -1); + } + + return result; } async getPostById(postId: number, userId: number) { @@ -1125,6 +1150,108 @@ export class PostService { return post; } + async getPostStats(postId: number) { + const cacheKey = `${POST_STATS_CACHE_PREFIX}${postId}`; + + // Try to get stats from cache + const cachedStats = await this.redisService.get(cacheKey); + if (cachedStats) { + await this.redisService.expire(cacheKey, POST_STATS_CACHE_TTL); // Refresh TTL + return JSON.parse(cachedStats); + } + + // Check if post exists + const post = await this.prismaService.post.findFirst({ + where: { id: postId, is_deleted: false }, + select: { id: true }, + }); + + if (!post) { + throw new NotFoundException('Post not found'); + } + + // Fetch stats from database + const [likesCount, repostsCount, repliesCount] = await Promise.all([ + this.prismaService.like.count({ + where: { post_id: postId }, + }), + this.prismaService.repost.count({ + where: { post_id: postId }, + }), + this.prismaService.post.count({ + where: { + parent_id: postId, + is_deleted: false, + }, + }), + ]); + + const stats = { + likesCount: likesCount, + retweetsCount: repostsCount, + commentsCount: repliesCount, + }; + + // Cache the stats + await this.redisService.set(cacheKey, JSON.stringify(stats), POST_STATS_CACHE_TTL); + + return stats; + } + + async updatePostStatsCache( + postId: number, + field: 'likesCount' | 'retweetsCount' | 'commentsCount', + delta: number, + ): Promise { + const cacheKey = `${POST_STATS_CACHE_PREFIX}${postId}`; + + // Try to get stats from cache + let cachedStats = await this.redisService.get(cacheKey); + let stats: { likesCount: number; retweetsCount: number; commentsCount: number }; + + if (!cachedStats) { + // Cache doesn't exist, fetch from DB and create cache + const [likesCount, repostsCount, repliesCount] = await Promise.all([ + this.prismaService.like.count({ where: { post_id: postId } }), + this.prismaService.repost.count({ where: { post_id: postId } }), + this.prismaService.post.count({ where: { parent_id: postId, is_deleted: false } }), + ]); + + stats = { + likesCount, + retweetsCount: repostsCount, + commentsCount: repliesCount, + }; + } else { + // Update the cached stats + stats = JSON.parse(cachedStats); + stats[field] = Math.max(0, (stats[field] || 0) + delta); + } + + // Cache with TTL + await this.redisService.set(cacheKey, JSON.stringify(stats), POST_STATS_CACHE_TTL); + + // Emit WebSocket event with the updated count + const eventName = this.mapFieldToEventName(field); + const count = stats[field]; + this.socketService.emitPostStatsUpdate(postId, eventName, count); + + return count; + } + + private mapFieldToEventName( + field: 'likesCount' | 'retweetsCount' | 'commentsCount', + ): 'likeUpdate' | 'repostUpdate' | 'commentUpdate' { + switch (field) { + case 'likesCount': + return 'likeUpdate'; + case 'retweetsCount': + return 'repostUpdate'; + case 'commentsCount': + return 'commentUpdate'; + } + } + async getForYouFeed( userId: number, page: number = 1, @@ -1703,12 +1830,12 @@ export class PostService { isSimpleRepost && post.repostedBy ? post.repostedBy : { - userId: post.user_id, - username: post.username, - verified: post.isVerified, - name: post.authorName || post.username, - avatar: post.authorProfileImage, - }; + userId: post.user_id, + username: post.username, + verified: post.isVerified, + name: post.authorName || post.username, + avatar: post.authorProfileImage, + }; return { // User Information (reposter for simple reposts, author otherwise) @@ -1742,42 +1869,42 @@ export class PostService { originalPostData: isSimpleRepost || isQuote ? { - userId: post.user_id, - username: post.username, - verified: post.isVerified, - name: post.authorName || post.username, - avatar: post.authorProfileImage, - postId: post.id, - date: post.created_at, - likesCount: post.likeCount, - retweetsCount: post.repostCount, - commentsCount: post.replyCount, - isLikedByMe: post.isLikedByMe, - isFollowedByMe: post.isFollowedByMe, - isRepostedByMe: post.isRepostedByMe || false, - text: post.content || '', - media: Array.isArray(post.mediaUrls) ? post.mediaUrls : [], - ...(isQuote && post.originalPost - ? { - // Override with quoted post data for quotes - userId: post.originalPost.author.userId, - username: post.originalPost.author.username, - verified: post.originalPost.author.isVerified, - name: post.originalPost.author.name, - avatar: post.originalPost.author.avatar, - postId: post.originalPost.postId, - date: post.originalPost.createdAt, - likesCount: post.originalPost.likeCount, - retweetsCount: post.originalPost.repostCount, - commentsCount: post.originalPost.replyCount, - isLikedByMe: post.originalPost.isLikedByMe, - isFollowedByMe: post.originalPost.isFollowedByMe, - isRepostedByMe: post.originalPost.isRepostedByMe, - text: post.originalPost.content || '', - media: post.originalPost.media || [], - } - : {}), - } + userId: post.user_id, + username: post.username, + verified: post.isVerified, + name: post.authorName || post.username, + avatar: post.authorProfileImage, + postId: post.id, + date: post.created_at, + likesCount: post.likeCount, + retweetsCount: post.repostCount, + commentsCount: post.replyCount, + isLikedByMe: post.isLikedByMe, + isFollowedByMe: post.isFollowedByMe, + isRepostedByMe: post.isRepostedByMe || false, + text: post.content || '', + media: Array.isArray(post.mediaUrls) ? post.mediaUrls : [], + ...(isQuote && post.originalPost + ? { + // Override with quoted post data for quotes + userId: post.originalPost.author.userId, + username: post.originalPost.author.username, + verified: post.originalPost.author.isVerified, + name: post.originalPost.author.name, + avatar: post.originalPost.author.avatar, + postId: post.originalPost.postId, + date: post.originalPost.createdAt, + likesCount: post.originalPost.likeCount, + retweetsCount: post.originalPost.repostCount, + commentsCount: post.originalPost.replyCount, + isLikedByMe: post.originalPost.isLikedByMe, + isFollowedByMe: post.originalPost.isFollowedByMe, + isRepostedByMe: post.originalPost.isRepostedByMe, + text: post.originalPost.content || '', + media: post.originalPost.media || [], + } + : {}), + } : undefined, // Scores data diff --git a/src/post/services/post.spec.ts b/src/post/services/post.spec.ts index 6181fe2..a052cec 100644 --- a/src/post/services/post.spec.ts +++ b/src/post/services/post.spec.ts @@ -1,5 +1,3 @@ - - import { Test, TestingModule } from '@nestjs/testing'; import { PrismaService } from '../../prisma/prisma.service'; import { PostService } from './post.service'; @@ -92,7 +90,7 @@ describe('Post Service', () => { const mockUrls = ['https://s3/image.jpg', 'https://s3/video.mp4']; const mockFiles = [ { mimetype: 'image/jpeg' }, - { mimetype: 'video/mp4' } + { mimetype: 'video/mp4' }, ] as Express.Multer.File[]; const createPostDto = { @@ -273,9 +271,7 @@ describe('Post Service', () => { limit: 10, }; - const mockPosts = [ - { id: 1, content: 'Post with #pain', user_id: 1 }, - ]; + const mockPosts = [{ id: 1, content: 'Post with #pain', user_id: 1 }]; prisma.post.findMany.mockResolvedValue(mockPosts); @@ -291,7 +287,6 @@ describe('Post Service', () => { }); expect(result).toEqual(mockPosts); }); - }); describe('getPostById', () => { @@ -355,7 +350,7 @@ describe('Post Service', () => { }, }; - prisma.$transaction.mockImplementation(c => c(mockTx)); + prisma.$transaction.mockImplementation((c) => c(mockTx)); const result = await service.deletePost(postId); @@ -400,7 +395,9 @@ describe('Post Service', () => { prisma.post.findFirst.mockResolvedValue(mockPost); - await expect(service.summarizePost(postId)).rejects.toThrow('Post has no content to summarize'); + await expect(service.summarizePost(postId)).rejects.toThrow( + 'Post has no content to summarize', + ); }); }); }); diff --git a/src/post/services/repost.service.ts b/src/post/services/repost.service.ts index f804228..8ad81d1 100644 --- a/src/post/services/repost.service.ts +++ b/src/post/services/repost.service.ts @@ -1,8 +1,9 @@ -import { Inject, Injectable, NotFoundException } from '@nestjs/common'; +import { Inject, Injectable, NotFoundException, forwardRef } from '@nestjs/common'; import { PrismaService } from 'src/prisma/prisma.service'; import { Services } from 'src/utils/constants'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { NotificationType } from 'src/notifications/enums/notification.enum'; +import { PostService } from './post.service'; @Injectable() export class RepostService { @@ -10,6 +11,8 @@ export class RepostService { @Inject(Services.PRISMA) private readonly prismaService: PrismaService, private readonly eventEmitter: EventEmitter2, + @Inject(forwardRef(() => Services.POST)) + private readonly postService: PostService, ) {} async toggleRepost(postId: number, userId: number) { @@ -23,6 +26,9 @@ export class RepostService { where: { post_id_user_id: { post_id: postId, user_id: userId } }, }); + // Update/create cache and emit WebSocket event + await this.postService.updatePostStatsCache(postId, 'retweetsCount', -1); + return { message: 'Repost removed' }; } else { // Fetch post to get author for notification @@ -35,6 +41,9 @@ export class RepostService { data: { post_id: postId, user_id: userId }, }); + // Update/create cache and emit WebSocket event + await this.postService.updatePostStatsCache(postId, 'retweetsCount', 1); + // Emit notification event (don't notify yourself) if (post && post.user_id !== userId) { this.eventEmitter.emit('notification.create', { diff --git a/src/prisma/prisma.module.ts b/src/prisma/prisma.module.ts index 223a5b6..e0f01ee 100644 --- a/src/prisma/prisma.module.ts +++ b/src/prisma/prisma.module.ts @@ -1,19 +1,19 @@ -import { Module } from '@nestjs/common'; -import { PrismaService } from './prisma.service'; -import { Services } from 'src/utils/constants'; - -@Module({ - providers: [ - { - provide: Services.PRISMA, - useClass: PrismaService, - }, - ], - exports: [ - { - provide: Services.PRISMA, - useClass: PrismaService, - }, - ], -}) -export class PrismaModule {} +import { Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; +import { Services } from 'src/utils/constants'; + +@Module({ + providers: [ + { + provide: Services.PRISMA, + useClass: PrismaService, + }, + ], + exports: [ + { + provide: Services.PRISMA, + useClass: PrismaService, + }, + ], +}) +export class PrismaModule {} diff --git a/src/profile/dto/profile-with-follow-status-response.dto.ts b/src/profile/dto/profile-with-follow-status-response.dto.ts index d37463a..98cf3c6 100644 --- a/src/profile/dto/profile-with-follow-status-response.dto.ts +++ b/src/profile/dto/profile-with-follow-status-response.dto.ts @@ -1,5 +1,4 @@ import { ApiProperty } from '@nestjs/swagger'; import { ProfileResponseDto } from './profile-response.dto'; -export class ProfileWithFollowStatusDto extends ProfileResponseDto { -} +export class ProfileWithFollowStatusDto extends ProfileResponseDto {} diff --git a/src/redis/redis.module.ts b/src/redis/redis.module.ts index d5d094a..ab093b7 100644 --- a/src/redis/redis.module.ts +++ b/src/redis/redis.module.ts @@ -1,22 +1,22 @@ -import { ConfigModule } from '@nestjs/config'; -import redisConfig from 'src/config/redis.config'; -import { Services } from 'src/utils/constants'; -import { RedisService } from './redis.service'; -import { Module } from '@nestjs/common'; - -@Module({ - imports: [ConfigModule.forFeature(redisConfig)], - providers: [ - { - provide: Services.REDIS, - useClass: RedisService, - }, - ], - exports: [ - { - provide: Services.REDIS, - useClass: RedisService, - }, - ], -}) -export class RedisModule {} +import { ConfigModule } from '@nestjs/config'; +import redisConfig from 'src/config/redis.config'; +import { Services } from 'src/utils/constants'; +import { RedisService } from './redis.service'; +import { Module } from '@nestjs/common'; + +@Module({ + imports: [ConfigModule.forFeature(redisConfig)], + providers: [ + { + provide: Services.REDIS, + useClass: RedisService, + }, + ], + exports: [ + { + provide: Services.REDIS, + useClass: RedisService, + }, + ], +}) +export class RedisModule {} diff --git a/src/redis/redis.service.ts b/src/redis/redis.service.ts index 1051b09..786336d 100644 --- a/src/redis/redis.service.ts +++ b/src/redis/redis.service.ts @@ -44,6 +44,11 @@ export class RedisService implements OnModuleInit { return await this.client.ttl(key); } + async expire(key: string, seconds: number): Promise { + const result = await this.client.expire(key, seconds); + return result === 1; + } + async del(key: string): Promise { return await this.client.del(key); } diff --git a/src/storage/pipes/file-upload.pipe.ts b/src/storage/pipes/file-upload.pipe.ts index f8b7034..81492d1 100644 --- a/src/storage/pipes/file-upload.pipe.ts +++ b/src/storage/pipes/file-upload.pipe.ts @@ -15,5 +15,5 @@ export const ImageVideoUploadPipe = new ParseFilePipe({ fileType: ALLOWED_FILE_TYPES_REGEX, }), ], - fileIsRequired: false, + fileIsRequired: false, }); diff --git a/src/storage/storage.service.ts b/src/storage/storage.service.ts index ed0ccbf..05940d9 100644 --- a/src/storage/storage.service.ts +++ b/src/storage/storage.service.ts @@ -1,78 +1,82 @@ import { Injectable, NotFoundException } from '@nestjs/common'; -import { S3Client, PutObjectCommand, DeleteObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'; +import { + S3Client, + PutObjectCommand, + DeleteObjectCommand, + HeadObjectCommand, +} from '@aws-sdk/client-s3'; import { ConfigService } from '@nestjs/config'; import { extname } from 'path'; import { v4 as uuid } from 'uuid'; @Injectable() export class StorageService { - private s3Client: S3Client; - private bucketName: string; - private region: string; + private s3Client: S3Client; + private bucketName: string; + private region: string; - constructor(private configService: ConfigService) { - this.bucketName = this.configService.get('AWS_S3_BUCKET_NAME') || 'hankers-uploads-prod'; - this.region = this.configService.get('AWS_REGION') || 'us-east-1'; - // No credentials needed for public bucket - this.s3Client = new S3Client({ - region: this.region, - }); - } + constructor(private configService: ConfigService) { + this.bucketName = + this.configService.get('AWS_S3_BUCKET_NAME') || 'hankers-uploads-prod'; + this.region = this.configService.get('AWS_REGION') || 'us-east-1'; + // No credentials needed for public bucket + this.s3Client = new S3Client({ + region: this.region, + }); + } - async uploadFiles(files?: Express.Multer.File[]): Promise { - if (!files || files.length === 0) return []; + async uploadFiles(files?: Express.Multer.File[]): Promise { + if (!files || files.length === 0) return []; - const uploads = files.map(async (file) => { - const fileExt = extname(file.originalname); - const key = `${uuid()}${fileExt}`; + const uploads = files.map(async (file) => { + const fileExt = extname(file.originalname); + const key = `${uuid()}${fileExt}`; - const command = new PutObjectCommand({ - Bucket: this.bucketName, - Key: key, - Body: file.buffer, - ContentType: file.mimetype, - }); + const command = new PutObjectCommand({ + Bucket: this.bucketName, + Key: key, + Body: file.buffer, + ContentType: file.mimetype, + }); - await this.s3Client.send(command); + await this.s3Client.send(command); - // Return the public S3 URL - return `https://${this.bucketName}.s3.${this.region}.amazonaws.com/${key}`; - }); + // Return the public S3 URL + return `https://${this.bucketName}.s3.${this.region}.amazonaws.com/${key}`; + }); - return await Promise.all(uploads); - } - - async deleteFile(s3UrlOrKey: string): Promise { - // Extract key from S3 URL or use as-is if it's already a key - const key = s3UrlOrKey.includes('/') - ? s3UrlOrKey.split('/').pop()! - : s3UrlOrKey; + return await Promise.all(uploads); + } - try { - // Check if object exists - const headCommand = new HeadObjectCommand({ - Bucket: this.bucketName, - Key: key, - }); - await this.s3Client.send(headCommand); + async deleteFile(s3UrlOrKey: string): Promise { + // Extract key from S3 URL or use as-is if it's already a key + const key = s3UrlOrKey.includes('/') ? s3UrlOrKey.split('/').pop()! : s3UrlOrKey; - // Delete the object - const deleteCommand = new DeleteObjectCommand({ - Bucket: this.bucketName, - Key: key, - }); - await this.s3Client.send(deleteCommand); - } catch (error: any) { - if (error.name === 'NotFound') { - throw new NotFoundException(`File not found: ${key}`); - } - throw error; - } - } + try { + // Check if object exists + const headCommand = new HeadObjectCommand({ + Bucket: this.bucketName, + Key: key, + }); + await this.s3Client.send(headCommand); - async deleteFiles(s3UrlsOrKeys: string[]): Promise { - if (!s3UrlsOrKeys || s3UrlsOrKeys.length === 0) return; + // Delete the object + const deleteCommand = new DeleteObjectCommand({ + Bucket: this.bucketName, + Key: key, + }); + await this.s3Client.send(deleteCommand); + } catch (error: any) { + if (error.name === 'NotFound') { + throw new NotFoundException(`File not found: ${key}`); + } + throw error; + } + } - await Promise.all(s3UrlsOrKeys.map((url) => this.deleteFile(url))); - } + async deleteFiles(s3UrlsOrKeys: string[]): Promise { + if (!s3UrlsOrKeys || s3UrlsOrKeys.length === 0) return; + + await Promise.all(s3UrlsOrKeys.map((url) => this.deleteFile(url))); + } } diff --git a/src/users/users.service.spec.ts b/src/users/users.service.spec.ts index 6b52893..f4dc098 100644 --- a/src/users/users.service.spec.ts +++ b/src/users/users.service.spec.ts @@ -484,10 +484,7 @@ describe('UsersService', () => { mockPrismaService.$transaction.mockResolvedValue([2, mockFollowing]); // Mock follow relationship query - mockPrismaService.follow.findMany.mockResolvedValue([ - { followingId: 2 }, - { followingId: 3 }, - ]); + mockPrismaService.follow.findMany.mockResolvedValue([{ followingId: 2 }, { followingId: 3 }]); const result = await service.getFollowing(userId, undefined, undefined, authenticatedUserId); diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 8584a53..a1ff7d6 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -147,7 +147,12 @@ export class UsersService { } } - async getFollowers(userId: number, page: number = 1, limit: number = 10, authenticatedUserId: number) { + async getFollowers( + userId: number, + page: number = 1, + limit: number = 10, + authenticatedUserId: number, + ) { const [totalItems, followers] = await this.prismaService.$transaction([ this.prismaService.follow.count({ where: { followingId: userId }, @@ -204,7 +209,12 @@ export class UsersService { return { data, metadata }; } - async getFollowing(userId: number, page: number = 1, limit: number = 10, authenticatedUserId: number) { + async getFollowing( + userId: number, + page: number = 1, + limit: number = 10, + authenticatedUserId: number, + ) { const [totalItems, following] = await this.prismaService.$transaction([ this.prismaService.follow.count({ where: { followerId: userId }, diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 4df6580..a3d45e6 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -17,9 +17,6 @@ describe('AppController (e2e)', () => { }); it('/ (GET)', () => { - return request(app.getHttpServer()) - .get('/') - .expect(200) - .expect('Hello World!'); + return request(app.getHttpServer()).get('/').expect(200).expect('Hello World!'); }); });