diff --git a/prisma/migrations/20251207155924_add_friends_system/migration.sql b/prisma/migrations/20251207155924_add_friends_system/migration.sql new file mode 100644 index 0000000..bd991f6 --- /dev/null +++ b/prisma/migrations/20251207155924_add_friends_system/migration.sql @@ -0,0 +1,42 @@ +-- CreateEnum +CREATE TYPE "FriendRequestStatus" AS ENUM ('PENDING', 'ACCEPTED', 'DENIED'); + +-- CreateTable +CREATE TABLE "friend_requests" ( + "id" TEXT NOT NULL, + "sender_id" TEXT NOT NULL, + "receiver_id" TEXT NOT NULL, + "status" "FriendRequestStatus" NOT NULL DEFAULT 'PENDING', + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "friend_requests_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "friendships" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "friend_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "friendships_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "friend_requests_sender_id_receiver_id_key" ON "friend_requests"("sender_id", "receiver_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "friendships_user_id_friend_id_key" ON "friendships"("user_id", "friend_id"); + +-- AddForeignKey +ALTER TABLE "friend_requests" ADD CONSTRAINT "friend_requests_sender_id_fkey" FOREIGN KEY ("sender_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "friend_requests" ADD CONSTRAINT "friend_requests_receiver_id_fkey" FOREIGN KEY ("receiver_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "friendships" ADD CONSTRAINT "friendships_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "friendships" ADD CONSTRAINT "friendships_friend_id_fkey" FOREIGN KEY ("friend_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 8c6ed78..83440e2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3,7 +3,6 @@ generator client { provider = "prisma-client-js" - output = "../node_modules/.prisma/client" } datasource db { @@ -43,5 +42,44 @@ model User { /// Timestamp of last login lastLoginAt DateTime? @map("last_login_at") + sentFriendRequests FriendRequest[] @relation("SentFriendRequests") + receivedFriendRequests FriendRequest[] @relation("ReceivedFriendRequests") + userFriendships Friendship[] @relation("UserFriendships") + friendFriendships Friendship[] @relation("FriendFriendships") + @@map("users") } + +model FriendRequest { + id String @id @default(uuid()) + senderId String @map("sender_id") + receiverId String @map("receiver_id") + status FriendRequestStatus @default(PENDING) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + sender User @relation("SentFriendRequests", fields: [senderId], references: [id], onDelete: Cascade) + receiver User @relation("ReceivedFriendRequests", fields: [receiverId], references: [id], onDelete: Cascade) + + @@unique([senderId, receiverId]) + @@map("friend_requests") +} + +model Friendship { + id String @id @default(uuid()) + userId String @map("user_id") + friendId String @map("friend_id") + createdAt DateTime @default(now()) @map("created_at") + + user User @relation("UserFriendships", fields: [userId], references: [id], onDelete: Cascade) + friend User @relation("FriendFriendships", fields: [friendId], references: [id], onDelete: Cascade) + + @@unique([userId, friendId]) + @@map("friendships") +} + +enum FriendRequestStatus { + PENDING + ACCEPTED + DENIED +} diff --git a/src/app.module.ts b/src/app.module.ts index f5fd4df..c99288b 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -6,6 +6,7 @@ import { UsersModule } from './users/users.module'; import { AuthModule } from './auth/auth.module'; import { DatabaseModule } from './database/database.module'; import { WsModule } from './ws/ws.module'; +import { FriendsModule } from './friends/friends.module'; /** * Validates required environment variables. @@ -43,16 +44,16 @@ function validateEnvironment(config: Record): Record { */ @Module({ imports: [ - // Configure global environment variables with validation ConfigModule.forRoot({ - isGlobal: true, // Make ConfigService available throughout the app - envFilePath: '.env', // Load from .env file - validate: validateEnvironment, // Validate required environment variables + isGlobal: true, + envFilePath: '.env', + validate: validateEnvironment, }), - DatabaseModule, // Global database module for Prisma + DatabaseModule, UsersModule, AuthModule, WsModule, + FriendsModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/friends/dto/friend-response.dto.ts b/src/friends/dto/friend-response.dto.ts new file mode 100644 index 0000000..9c5bff5 --- /dev/null +++ b/src/friends/dto/friend-response.dto.ts @@ -0,0 +1,88 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class UserBasicDto { + @ApiProperty({ + description: 'User unique identifier', + example: '550e8400-e29b-41d4-a716-446655440000', + }) + id: string; + + @ApiProperty({ + description: "User's display name", + example: 'John Doe', + }) + name: string; + + @ApiProperty({ + description: "User's username", + example: 'johndoe', + required: false, + }) + username?: string; + + @ApiProperty({ + description: "User's profile picture URL", + example: 'https://example.com/avatar.jpg', + required: false, + }) + picture?: string; +} + +export class FriendRequestResponseDto { + @ApiProperty({ + description: 'Friend request unique identifier', + example: '550e8400-e29b-41d4-a716-446655440000', + }) + id: string; + + @ApiProperty({ + description: 'Sender information', + type: UserBasicDto, + }) + sender: UserBasicDto; + + @ApiProperty({ + description: 'Receiver information', + type: UserBasicDto, + }) + receiver: UserBasicDto; + + @ApiProperty({ + description: 'Friend request status', + enum: ['PENDING', 'ACCEPTED', 'DENIED'], + example: 'PENDING', + }) + status: string; + + @ApiProperty({ + description: 'Friend request creation timestamp', + example: '2024-01-01T00:00:00.000Z', + }) + createdAt: Date; + + @ApiProperty({ + description: 'Friend request last update timestamp', + example: '2024-01-01T00:00:00.000Z', + }) + updatedAt: Date; +} + +export class FriendshipResponseDto { + @ApiProperty({ + description: 'Friendship unique identifier', + example: '550e8400-e29b-41d4-a716-446655440000', + }) + id: string; + + @ApiProperty({ + description: 'Friend information', + type: UserBasicDto, + }) + friend: UserBasicDto; + + @ApiProperty({ + description: 'Friendship creation timestamp', + example: '2024-01-01T00:00:00.000Z', + }) + createdAt: Date; +} diff --git a/src/friends/dto/search-users.dto.ts b/src/friends/dto/search-users.dto.ts new file mode 100644 index 0000000..513c973 --- /dev/null +++ b/src/friends/dto/search-users.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsOptional, IsString } from 'class-validator'; + +export class SearchUsersDto { + @ApiProperty({ + description: 'Username to search for (partial match)', + example: 'john', + required: false, + }) + @IsOptional() + @IsString() + username?: string; +} diff --git a/src/friends/dto/send-friend-request.dto.ts b/src/friends/dto/send-friend-request.dto.ts new file mode 100644 index 0000000..a999a03 --- /dev/null +++ b/src/friends/dto/send-friend-request.dto.ts @@ -0,0 +1,13 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, IsUUID } from 'class-validator'; + +export class SendFriendRequestDto { + @ApiProperty({ + description: 'User ID to send friend request to', + example: '550e8400-e29b-41d4-a716-446655440000', + }) + @IsNotEmpty() + @IsString() + @IsUUID() + receiverId: string; +} diff --git a/src/friends/friends.controller.spec.ts b/src/friends/friends.controller.spec.ts new file mode 100644 index 0000000..76d7d13 --- /dev/null +++ b/src/friends/friends.controller.spec.ts @@ -0,0 +1,298 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { FriendsController } from './friends.controller'; +import { FriendsService } from './friends.service'; +import { UsersService } from '../users/users.service'; +import { AuthService } from '../auth/auth.service'; +import { StateGateway } from '../ws/state/state.gateway'; + +enum FriendRequestStatus { + PENDING = 'PENDING', + ACCEPTED = 'ACCEPTED', + DENIED = 'DENIED', +} + +describe('FriendsController', () => { + let controller: FriendsController; + + const mockAuthUser = { + keycloakSub: 'f:realm:user1', + email: 'user1@example.com', + name: 'User One', + username: 'user1', + }; + + const mockUser1 = { + id: 'user-1', + keycloakSub: 'f:realm:user1', + email: 'user1@example.com', + name: 'User One', + username: 'user1', + picture: null, + roles: [], + lastLoginAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockUser2 = { + id: 'user-2', + keycloakSub: 'f:realm:user2', + email: 'user2@example.com', + name: 'User Two', + username: 'user2', + picture: null, + roles: [], + lastLoginAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockFriendRequest = { + id: 'request-1', + senderId: 'user-1', + receiverId: 'user-2', + status: FriendRequestStatus.PENDING, + createdAt: new Date(), + updatedAt: new Date(), + sender: mockUser1, + receiver: mockUser2, + }; + + const mockFriendship = { + id: 'friendship-1', + userId: 'user-1', + friendId: 'user-2', + createdAt: new Date(), + friend: mockUser2, + }; + + const mockFriendsService = { + sendFriendRequest: jest.fn(), + getPendingReceivedRequests: jest.fn(), + getPendingSentRequests: jest.fn(), + acceptFriendRequest: jest.fn(), + denyFriendRequest: jest.fn(), + getFriends: jest.fn(), + unfriend: jest.fn(), + }; + + const mockUsersService = { + searchUsers: jest.fn(), + }; + + const mockAuthService = { + syncUserFromToken: jest.fn(), + }; + + const mockStateGateway = { + emitFriendRequestReceived: jest.fn(), + emitFriendRequestAccepted: jest.fn(), + emitFriendRequestDenied: jest.fn(), + emitUnfriended: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [FriendsController], + providers: [ + { provide: FriendsService, useValue: mockFriendsService }, + { provide: UsersService, useValue: mockUsersService }, + { provide: AuthService, useValue: mockAuthService }, + { provide: StateGateway, useValue: mockStateGateway }, + ], + }).compile(); + + controller = module.get(FriendsController); + + jest.clearAllMocks(); + mockAuthService.syncUserFromToken.mockResolvedValue(mockUser1); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + describe('searchUsers', () => { + it('should return list of users matching search', async () => { + const users = [mockUser2]; + mockUsersService.searchUsers.mockResolvedValue(users); + + const result = await controller.searchUsers( + { username: 'user2' }, + mockAuthUser, + ); + + expect(result).toEqual([ + { + id: mockUser2.id, + name: mockUser2.name, + username: mockUser2.username, + picture: undefined, + }, + ]); + expect(mockUsersService.searchUsers).toHaveBeenCalledWith( + 'user2', + 'user-1', + ); + }); + }); + + describe('sendFriendRequest', () => { + it('should send friend request and emit WebSocket event', async () => { + mockFriendsService.sendFriendRequest.mockResolvedValue(mockFriendRequest); + + const result = await controller.sendFriendRequest( + { receiverId: 'user-2' }, + mockAuthUser, + ); + + expect(result).toEqual({ + id: mockFriendRequest.id, + sender: { + id: mockUser1.id, + name: mockUser1.name, + username: mockUser1.username, + picture: undefined, + }, + receiver: { + id: mockUser2.id, + name: mockUser2.name, + username: mockUser2.username, + picture: undefined, + }, + status: FriendRequestStatus.PENDING, + createdAt: mockFriendRequest.createdAt, + updatedAt: mockFriendRequest.updatedAt, + }); + expect(mockFriendsService.sendFriendRequest).toHaveBeenCalledWith( + 'user-1', + 'user-2', + ); + expect(mockStateGateway.emitFriendRequestReceived).toHaveBeenCalledWith( + 'user-2', + mockFriendRequest, + ); + }); + }); + + describe('getReceivedRequests', () => { + it('should return list of received friend requests', async () => { + mockFriendsService.getPendingReceivedRequests.mockResolvedValue([ + mockFriendRequest, + ]); + + const result = await controller.getReceivedRequests(mockAuthUser); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe(mockFriendRequest.id); + expect( + mockFriendsService.getPendingReceivedRequests, + ).toHaveBeenCalledWith('user-1'); + }); + }); + + describe('getSentRequests', () => { + it('should return list of sent friend requests', async () => { + mockFriendsService.getPendingSentRequests.mockResolvedValue([ + mockFriendRequest, + ]); + + const result = await controller.getSentRequests(mockAuthUser); + + expect(result).toHaveLength(1); + expect(result[0].id).toBe(mockFriendRequest.id); + expect(mockFriendsService.getPendingSentRequests).toHaveBeenCalledWith( + 'user-1', + ); + }); + }); + + describe('acceptFriendRequest', () => { + it('should accept friend request and emit WebSocket event', async () => { + const acceptedRequest = { + ...mockFriendRequest, + status: FriendRequestStatus.ACCEPTED, + }; + mockFriendsService.acceptFriendRequest.mockResolvedValue(acceptedRequest); + + const result = await controller.acceptFriendRequest( + 'request-1', + mockAuthUser, + ); + + expect(result.status).toBe(FriendRequestStatus.ACCEPTED); + expect(mockFriendsService.acceptFriendRequest).toHaveBeenCalledWith( + 'request-1', + 'user-1', + ); + expect(mockStateGateway.emitFriendRequestAccepted).toHaveBeenCalledWith( + 'user-1', + acceptedRequest, + ); + }); + }); + + describe('denyFriendRequest', () => { + it('should deny friend request and emit WebSocket event', async () => { + const deniedRequest = { + ...mockFriendRequest, + status: FriendRequestStatus.DENIED, + }; + mockFriendsService.denyFriendRequest.mockResolvedValue(deniedRequest); + + const result = await controller.denyFriendRequest( + 'request-1', + mockAuthUser, + ); + + expect(result.status).toBe(FriendRequestStatus.DENIED); + expect(mockFriendsService.denyFriendRequest).toHaveBeenCalledWith( + 'request-1', + 'user-1', + ); + expect(mockStateGateway.emitFriendRequestDenied).toHaveBeenCalledWith( + 'user-1', + deniedRequest, + ); + }); + }); + + describe('getFriends', () => { + it('should return list of friends', async () => { + mockFriendsService.getFriends.mockResolvedValue([mockFriendship]); + + const result = await controller.getFriends(mockAuthUser); + + expect(result).toEqual([ + { + id: mockFriendship.id, + friend: { + id: mockUser2.id, + name: mockUser2.name, + username: mockUser2.username, + picture: undefined, + }, + createdAt: mockFriendship.createdAt, + }, + ]); + expect(mockFriendsService.getFriends).toHaveBeenCalledWith('user-1'); + }); + }); + + describe('unfriend', () => { + it('should unfriend user and emit WebSocket event', async () => { + mockFriendsService.unfriend.mockResolvedValue(undefined); + + await controller.unfriend('user-2', mockAuthUser); + + expect(mockFriendsService.unfriend).toHaveBeenCalledWith( + 'user-1', + 'user-2', + ); + expect(mockStateGateway.emitUnfriended).toHaveBeenCalledWith( + 'user-2', + 'user-1', + ); + }); + }); +}); diff --git a/src/friends/friends.controller.ts b/src/friends/friends.controller.ts new file mode 100644 index 0000000..01f77dc --- /dev/null +++ b/src/friends/friends.controller.ts @@ -0,0 +1,389 @@ +import { + Controller, + Get, + Post, + Delete, + Param, + Body, + Query, + HttpCode, + UseGuards, + Logger, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiParam, + ApiBearerAuth, + ApiUnauthorizedResponse, + ApiQuery, +} from '@nestjs/swagger'; +import { User, FriendRequest } from '@prisma/client'; +import { FriendsService } from './friends.service'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { + CurrentUser, + type AuthenticatedUser, +} from '../auth/decorators/current-user.decorator'; +import { AuthService } from '../auth/auth.service'; +import { SendFriendRequestDto } from './dto/send-friend-request.dto'; +import { + FriendRequestResponseDto, + FriendshipResponseDto, + UserBasicDto, +} from './dto/friend-response.dto'; +import { SearchUsersDto } from './dto/search-users.dto'; + +type FriendRequestWithRelations = FriendRequest & { + sender: User; + receiver: User; +}; +import { UsersService } from '../users/users.service'; +import { StateGateway } from '../ws/state/state.gateway'; + +@ApiTags('friends') +@Controller('friends') +@UseGuards(JwtAuthGuard) +@ApiBearerAuth() +export class FriendsController { + private readonly logger = new Logger(FriendsController.name); + + constructor( + private readonly friendsService: FriendsService, + private readonly usersService: UsersService, + private readonly authService: AuthService, + private readonly stateGateway: StateGateway, + ) {} + + @Get('search') + @ApiOperation({ + summary: 'Search users by username', + description: 'Search for users by username to send friend requests', + }) + @ApiQuery({ + name: 'username', + required: false, + description: 'Username to search for (partial match)', + }) + @ApiResponse({ + status: 200, + description: 'List of users matching search criteria', + type: [UserBasicDto], + }) + @ApiUnauthorizedResponse({ + description: 'Invalid or missing JWT token', + }) + async searchUsers( + @Query() searchDto: SearchUsersDto, + @CurrentUser() authUser: AuthenticatedUser, + ): Promise { + const user = await this.authService.syncUserFromToken(authUser); + + this.logger.debug( + `Searching users with username: ${searchDto.username || 'all'}`, + ); + + const users = await this.usersService.searchUsers( + searchDto.username, + user.id, + ); + + return users.map((u: User) => ({ + id: u.id, + name: u.name, + username: u.username ?? undefined, + picture: u.picture ?? undefined, + })); + } + + @Post('requests') + @ApiOperation({ + summary: 'Send a friend request', + description: 'Send a friend request to another user', + }) + @ApiResponse({ + status: 201, + description: 'Friend request sent successfully', + type: FriendRequestResponseDto, + }) + @ApiResponse({ + status: 400, + description: 'Invalid request (self-request, already friends, etc.)', + }) + @ApiResponse({ + status: 404, + description: 'User not found', + }) + @ApiResponse({ + status: 409, + description: 'Friend request already exists', + }) + @ApiUnauthorizedResponse({ + description: 'Invalid or missing JWT token', + }) + async sendFriendRequest( + @Body() sendRequestDto: SendFriendRequestDto, + @CurrentUser() authUser: AuthenticatedUser, + ): Promise { + const user = await this.authService.syncUserFromToken(authUser); + + this.logger.log( + `User ${user.id} sending friend request to ${sendRequestDto.receiverId}`, + ); + + const friendRequest = await this.friendsService.sendFriendRequest( + user.id, + sendRequestDto.receiverId, + ); + + this.stateGateway.emitFriendRequestReceived( + sendRequestDto.receiverId, + friendRequest, + ); + + return this.mapFriendRequestToDto(friendRequest); + } + + @Get('requests/received') + @ApiOperation({ + summary: 'Get received friend requests', + description: 'Get all pending friend requests received by the current user', + }) + @ApiResponse({ + status: 200, + description: 'List of received friend requests', + type: [FriendRequestResponseDto], + }) + @ApiUnauthorizedResponse({ + description: 'Invalid or missing JWT token', + }) + async getReceivedRequests( + @CurrentUser() authUser: AuthenticatedUser, + ): Promise { + const user = await this.authService.syncUserFromToken(authUser); + + this.logger.debug(`Getting received friend requests for user ${user.id}`); + + const requests = await this.friendsService.getPendingReceivedRequests( + user.id, + ); + + return requests.map((req) => this.mapFriendRequestToDto(req)); + } + + @Get('requests/sent') + @ApiOperation({ + summary: 'Get sent friend requests', + description: 'Get all pending friend requests sent by the current user', + }) + @ApiResponse({ + status: 200, + description: 'List of sent friend requests', + type: [FriendRequestResponseDto], + }) + @ApiUnauthorizedResponse({ + description: 'Invalid or missing JWT token', + }) + async getSentRequests( + @CurrentUser() authUser: AuthenticatedUser, + ): Promise { + const user = await this.authService.syncUserFromToken(authUser); + + this.logger.debug(`Getting sent friend requests for user ${user.id}`); + + const requests = await this.friendsService.getPendingSentRequests(user.id); + + return requests.map((req) => this.mapFriendRequestToDto(req)); + } + + @Post('requests/:id/accept') + @ApiOperation({ + summary: 'Accept a friend request', + description: 'Accept a pending friend request', + }) + @ApiParam({ + name: 'id', + description: 'Friend request ID', + example: '550e8400-e29b-41d4-a716-446655440000', + }) + @ApiResponse({ + status: 200, + description: 'Friend request accepted successfully', + type: FriendRequestResponseDto, + }) + @ApiResponse({ + status: 400, + description: 'Invalid request (not receiver, already processed, etc.)', + }) + @ApiResponse({ + status: 404, + description: 'Friend request not found', + }) + @ApiUnauthorizedResponse({ + description: 'Invalid or missing JWT token', + }) + async acceptFriendRequest( + @Param('id') requestId: string, + @CurrentUser() authUser: AuthenticatedUser, + ): Promise { + const user = await this.authService.syncUserFromToken(authUser); + + this.logger.log(`User ${user.id} accepting friend request ${requestId}`); + + const friendRequest = await this.friendsService.acceptFriendRequest( + requestId, + user.id, + ); + + this.stateGateway.emitFriendRequestAccepted( + friendRequest.senderId, + friendRequest, + ); + + return this.mapFriendRequestToDto(friendRequest); + } + + @Post('requests/:id/deny') + @ApiOperation({ + summary: 'Deny a friend request', + description: 'Deny a pending friend request', + }) + @ApiParam({ + name: 'id', + description: 'Friend request ID', + example: '550e8400-e29b-41d4-a716-446655440000', + }) + @ApiResponse({ + status: 200, + description: 'Friend request denied successfully', + type: FriendRequestResponseDto, + }) + @ApiResponse({ + status: 400, + description: 'Invalid request (not receiver, already processed, etc.)', + }) + @ApiResponse({ + status: 404, + description: 'Friend request not found', + }) + @ApiUnauthorizedResponse({ + description: 'Invalid or missing JWT token', + }) + async denyFriendRequest( + @Param('id') requestId: string, + @CurrentUser() authUser: AuthenticatedUser, + ): Promise { + const user = await this.authService.syncUserFromToken(authUser); + + this.logger.log(`User ${user.id} denying friend request ${requestId}`); + + const friendRequest = await this.friendsService.denyFriendRequest( + requestId, + user.id, + ); + + this.stateGateway.emitFriendRequestDenied( + friendRequest.senderId, + friendRequest, + ); + + return this.mapFriendRequestToDto(friendRequest); + } + + @Get() + @ApiOperation({ + summary: 'Get friends list', + description: 'Get all friends of the current user', + }) + @ApiResponse({ + status: 200, + description: 'List of friends', + type: [FriendshipResponseDto], + }) + @ApiUnauthorizedResponse({ + description: 'Invalid or missing JWT token', + }) + async getFriends( + @CurrentUser() authUser: AuthenticatedUser, + ): Promise { + const user = await this.authService.syncUserFromToken(authUser); + + this.logger.debug(`Getting friends list for user ${user.id}`); + + const friendships = await this.friendsService.getFriends(user.id); + + return friendships.map((friendship) => ({ + id: friendship.id, + friend: { + id: friendship.friend.id, + name: friendship.friend.name, + username: friendship.friend.username ?? undefined, + picture: friendship.friend.picture ?? undefined, + }, + createdAt: friendship.createdAt, + })); + } + + @Delete(':friendId') + @HttpCode(204) + @ApiOperation({ + summary: 'Unfriend a user', + description: 'Remove a user from your friends list', + }) + @ApiParam({ + name: 'friendId', + description: 'Friend user ID', + example: '550e8400-e29b-41d4-a716-446655440000', + }) + @ApiResponse({ + status: 204, + description: 'Successfully unfriended', + }) + @ApiResponse({ + status: 400, + description: 'Invalid request (cannot unfriend yourself)', + }) + @ApiResponse({ + status: 404, + description: 'Friend not found', + }) + @ApiUnauthorizedResponse({ + description: 'Invalid or missing JWT token', + }) + async unfriend( + @Param('friendId') friendId: string, + @CurrentUser() authUser: AuthenticatedUser, + ): Promise { + const user = await this.authService.syncUserFromToken(authUser); + + this.logger.log(`User ${user.id} unfriending user ${friendId}`); + + await this.friendsService.unfriend(user.id, friendId); + + this.stateGateway.emitUnfriended(friendId, user.id); + } + + private mapFriendRequestToDto( + friendRequest: FriendRequestWithRelations, + ): FriendRequestResponseDto { + return { + id: friendRequest.id, + sender: { + id: friendRequest.sender.id, + name: friendRequest.sender.name, + username: friendRequest.sender.username ?? undefined, + picture: friendRequest.sender.picture ?? undefined, + }, + receiver: { + id: friendRequest.receiver.id, + name: friendRequest.receiver.name, + username: friendRequest.receiver.username ?? undefined, + picture: friendRequest.receiver.picture ?? undefined, + }, + status: friendRequest.status, + createdAt: friendRequest.createdAt, + updatedAt: friendRequest.updatedAt, + }; + } +} diff --git a/src/friends/friends.module.ts b/src/friends/friends.module.ts new file mode 100644 index 0000000..d558230 --- /dev/null +++ b/src/friends/friends.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common'; +import { FriendsController } from './friends.controller'; +import { FriendsService } from './friends.service'; +import { DatabaseModule } from '../database/database.module'; +import { AuthModule } from '../auth/auth.module'; +import { UsersModule } from '../users/users.module'; +import { WsModule } from '../ws/ws.module'; + +@Module({ + imports: [DatabaseModule, AuthModule, UsersModule, WsModule], + controllers: [FriendsController], + providers: [FriendsService], + exports: [FriendsService], +}) +export class FriendsModule {} diff --git a/src/friends/friends.service.spec.ts b/src/friends/friends.service.spec.ts new file mode 100644 index 0000000..4757fb4 --- /dev/null +++ b/src/friends/friends.service.spec.ts @@ -0,0 +1,435 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { FriendsService } from './friends.service'; +import { PrismaService } from '../database/prisma.service'; +import { + NotFoundException, + BadRequestException, + ConflictException, +} from '@nestjs/common'; + +enum FriendRequestStatus { + PENDING = 'PENDING', + ACCEPTED = 'ACCEPTED', + DENIED = 'DENIED', +} + +describe('FriendsService', () => { + let service: FriendsService; + + const mockUser1 = { + id: 'user-1', + keycloakSub: 'f:realm:user1', + email: 'user1@example.com', + name: 'User One', + username: 'user1', + picture: null, + roles: [], + lastLoginAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockUser2 = { + id: 'user-2', + keycloakSub: 'f:realm:user2', + email: 'user2@example.com', + name: 'User Two', + username: 'user2', + picture: null, + roles: [], + lastLoginAt: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }; + + const mockFriendRequest = { + id: 'request-1', + senderId: 'user-1', + receiverId: 'user-2', + status: FriendRequestStatus.PENDING, + createdAt: new Date(), + updatedAt: new Date(), + sender: mockUser1, + receiver: mockUser2, + }; + + const mockFriendship = { + id: 'friendship-1', + userId: 'user-1', + friendId: 'user-2', + createdAt: new Date(), + }; + + const mockPrismaService = { + user: { + findUnique: jest.fn(), + }, + friendRequest: { + create: jest.fn(), + findFirst: jest.fn(), + findUnique: jest.fn(), + findMany: jest.fn(), + update: jest.fn(), + updateMany: jest.fn(), + }, + friendship: { + create: jest.fn(), + findFirst: jest.fn(), + findMany: jest.fn(), + deleteMany: jest.fn(), + }, + $transaction: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + FriendsService, + { + provide: PrismaService, + useValue: mockPrismaService, + }, + ], + }).compile(); + + service = module.get(FriendsService); + + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('sendFriendRequest', () => { + it('should send a friend request successfully', async () => { + mockPrismaService.user.findUnique.mockResolvedValue(mockUser2); + mockPrismaService.friendship.findFirst.mockResolvedValue(null); + mockPrismaService.friendRequest.findFirst.mockResolvedValue(null); + mockPrismaService.friendRequest.create.mockResolvedValue( + mockFriendRequest, + ); + + const result = await service.sendFriendRequest('user-1', 'user-2'); + + expect(result).toEqual(mockFriendRequest); + expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({ + where: { id: 'user-2' }, + }); + expect(mockPrismaService.friendRequest.create).toHaveBeenCalledWith({ + data: { + senderId: 'user-1', + receiverId: 'user-2', + status: FriendRequestStatus.PENDING, + }, + include: { + sender: true, + receiver: true, + }, + }); + }); + + it('should throw BadRequestException when trying to send request to self', async () => { + await expect( + service.sendFriendRequest('user-1', 'user-1'), + ).rejects.toThrow(BadRequestException); + await expect( + service.sendFriendRequest('user-1', 'user-1'), + ).rejects.toThrow('Cannot send friend request to yourself'); + }); + + it('should throw NotFoundException when receiver does not exist', async () => { + mockPrismaService.user.findUnique.mockResolvedValue(null); + + await expect( + service.sendFriendRequest('user-1', 'nonexistent'), + ).rejects.toThrow(NotFoundException); + await expect( + service.sendFriendRequest('user-1', 'nonexistent'), + ).rejects.toThrow('User not found'); + }); + + it('should throw ConflictException when users are already friends', async () => { + mockPrismaService.user.findUnique.mockResolvedValue(mockUser2); + mockPrismaService.friendship.findFirst.mockResolvedValue(mockFriendship); + + await expect( + service.sendFriendRequest('user-1', 'user-2'), + ).rejects.toThrow(ConflictException); + await expect( + service.sendFriendRequest('user-1', 'user-2'), + ).rejects.toThrow('You are already friends with this user'); + }); + + it('should throw ConflictException when request already exists', async () => { + mockPrismaService.user.findUnique.mockResolvedValue(mockUser2); + mockPrismaService.friendship.findFirst.mockResolvedValue(null); + mockPrismaService.friendRequest.findFirst.mockResolvedValue( + mockFriendRequest, + ); + + await expect( + service.sendFriendRequest('user-1', 'user-2'), + ).rejects.toThrow(ConflictException); + await expect( + service.sendFriendRequest('user-1', 'user-2'), + ).rejects.toThrow('You already sent a friend request to this user'); + }); + + it('should throw ConflictException when reverse request exists', async () => { + mockPrismaService.user.findUnique.mockResolvedValue(mockUser1); + mockPrismaService.friendship.findFirst.mockResolvedValue(null); + mockPrismaService.friendRequest.findFirst.mockResolvedValue({ + ...mockFriendRequest, + senderId: 'user-2', + receiverId: 'user-1', + }); + + await expect( + service.sendFriendRequest('user-1', 'user-2'), + ).rejects.toThrow(ConflictException); + await expect( + service.sendFriendRequest('user-1', 'user-2'), + ).rejects.toThrow('This user already sent you a friend request'); + }); + }); + + describe('getPendingReceivedRequests', () => { + it('should return pending received requests', async () => { + const requests = [mockFriendRequest]; + mockPrismaService.friendRequest.findMany.mockResolvedValue(requests); + + const result = await service.getPendingReceivedRequests('user-2'); + + expect(result).toEqual(requests); + expect(mockPrismaService.friendRequest.findMany).toHaveBeenCalledWith({ + where: { + receiverId: 'user-2', + status: FriendRequestStatus.PENDING, + }, + include: { + sender: true, + receiver: true, + }, + orderBy: { + createdAt: 'desc', + }, + }); + }); + }); + + describe('getPendingSentRequests', () => { + it('should return pending sent requests', async () => { + const requests = [mockFriendRequest]; + mockPrismaService.friendRequest.findMany.mockResolvedValue(requests); + + const result = await service.getPendingSentRequests('user-1'); + + expect(result).toEqual(requests); + expect(mockPrismaService.friendRequest.findMany).toHaveBeenCalledWith({ + where: { + senderId: 'user-1', + status: FriendRequestStatus.PENDING, + }, + include: { + sender: true, + receiver: true, + }, + orderBy: { + createdAt: 'desc', + }, + }); + }); + }); + + describe('acceptFriendRequest', () => { + it('should accept a friend request and create friendship', async () => { + const acceptedRequest = { + ...mockFriendRequest, + status: FriendRequestStatus.ACCEPTED, + }; + mockPrismaService.friendRequest.findUnique.mockResolvedValue( + mockFriendRequest, + ); + mockPrismaService.$transaction.mockResolvedValue([acceptedRequest]); + + const result = await service.acceptFriendRequest('request-1', 'user-2'); + + expect(result).toEqual(acceptedRequest); + expect(mockPrismaService.$transaction).toHaveBeenCalled(); + }); + + it('should throw NotFoundException when request does not exist', async () => { + mockPrismaService.friendRequest.findUnique.mockResolvedValue(null); + + await expect( + service.acceptFriendRequest('nonexistent', 'user-2'), + ).rejects.toThrow(NotFoundException); + await expect( + service.acceptFriendRequest('nonexistent', 'user-2'), + ).rejects.toThrow('Friend request not found'); + }); + + it('should throw BadRequestException when user is not the receiver', async () => { + mockPrismaService.friendRequest.findUnique.mockResolvedValue( + mockFriendRequest, + ); + + await expect( + service.acceptFriendRequest('request-1', 'user-3'), + ).rejects.toThrow(BadRequestException); + await expect( + service.acceptFriendRequest('request-1', 'user-3'), + ).rejects.toThrow('You can only accept friend requests sent to you'); + }); + + it('should throw BadRequestException when request is already accepted', async () => { + mockPrismaService.friendRequest.findUnique.mockResolvedValue({ + ...mockFriendRequest, + status: FriendRequestStatus.ACCEPTED, + }); + + await expect( + service.acceptFriendRequest('request-1', 'user-2'), + ).rejects.toThrow(BadRequestException); + await expect( + service.acceptFriendRequest('request-1', 'user-2'), + ).rejects.toThrow('Friend request is already accepted'); + }); + }); + + describe('denyFriendRequest', () => { + it('should deny a friend request', async () => { + const deniedRequest = { + ...mockFriendRequest, + status: FriendRequestStatus.DENIED, + }; + mockPrismaService.friendRequest.findUnique.mockResolvedValue( + mockFriendRequest, + ); + mockPrismaService.friendRequest.update.mockResolvedValue(deniedRequest); + + const result = await service.denyFriendRequest('request-1', 'user-2'); + + expect(result).toEqual(deniedRequest); + expect(mockPrismaService.friendRequest.update).toHaveBeenCalledWith({ + where: { id: 'request-1' }, + data: { status: FriendRequestStatus.DENIED }, + include: { + sender: true, + receiver: true, + }, + }); + }); + + it('should throw NotFoundException when request does not exist', async () => { + mockPrismaService.friendRequest.findUnique.mockResolvedValue(null); + + await expect( + service.denyFriendRequest('nonexistent', 'user-2'), + ).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException when user is not the receiver', async () => { + mockPrismaService.friendRequest.findUnique.mockResolvedValue( + mockFriendRequest, + ); + + await expect( + service.denyFriendRequest('request-1', 'user-3'), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException when request is already denied', async () => { + mockPrismaService.friendRequest.findUnique.mockResolvedValue({ + ...mockFriendRequest, + status: FriendRequestStatus.DENIED, + }); + + await expect( + service.denyFriendRequest('request-1', 'user-2'), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('getFriends', () => { + it('should return list of friends', async () => { + const friendships = [ + { + ...mockFriendship, + friend: mockUser2, + }, + ]; + mockPrismaService.friendship.findMany.mockResolvedValue(friendships); + + const result = await service.getFriends('user-1'); + + expect(result).toEqual(friendships); + expect(mockPrismaService.friendship.findMany).toHaveBeenCalledWith({ + where: { userId: 'user-1' }, + include: { + friend: true, + }, + orderBy: { + createdAt: 'desc', + }, + }); + }); + }); + + describe('unfriend', () => { + it('should unfriend a user successfully', async () => { + mockPrismaService.friendship.findFirst.mockResolvedValue(mockFriendship); + mockPrismaService.friendship.deleteMany.mockResolvedValue({ count: 2 }); + + await service.unfriend('user-1', 'user-2'); + + expect(mockPrismaService.friendship.deleteMany).toHaveBeenCalledWith({ + where: { + OR: [ + { userId: 'user-1', friendId: 'user-2' }, + { userId: 'user-2', friendId: 'user-1' }, + ], + }, + }); + }); + + it('should throw BadRequestException when trying to unfriend self', async () => { + await expect(service.unfriend('user-1', 'user-1')).rejects.toThrow( + BadRequestException, + ); + await expect(service.unfriend('user-1', 'user-1')).rejects.toThrow( + 'Cannot unfriend yourself', + ); + }); + + it('should throw NotFoundException when not friends', async () => { + mockPrismaService.friendship.findFirst.mockResolvedValue(null); + + await expect(service.unfriend('user-1', 'user-2')).rejects.toThrow( + NotFoundException, + ); + await expect(service.unfriend('user-1', 'user-2')).rejects.toThrow( + 'You are not friends with this user', + ); + }); + }); + + describe('areFriends', () => { + it('should return true when users are friends', async () => { + mockPrismaService.friendship.findFirst.mockResolvedValue(mockFriendship); + + const result = await service.areFriends('user-1', 'user-2'); + + expect(result).toBe(true); + }); + + it('should return false when users are not friends', async () => { + mockPrismaService.friendship.findFirst.mockResolvedValue(null); + + const result = await service.areFriends('user-1', 'user-2'); + + expect(result).toBe(false); + }); + }); +}); diff --git a/src/friends/friends.service.ts b/src/friends/friends.service.ts new file mode 100644 index 0000000..fff9979 --- /dev/null +++ b/src/friends/friends.service.ts @@ -0,0 +1,273 @@ +import { + Injectable, + NotFoundException, + BadRequestException, + Logger, + ConflictException, +} from '@nestjs/common'; +import { PrismaService } from '../database/prisma.service'; +import { User, FriendRequest, FriendRequestStatus } from '@prisma/client'; + +export type FriendRequestWithRelations = FriendRequest & { + sender: User; + receiver: User; +}; + +@Injectable() +export class FriendsService { + private readonly logger = new Logger(FriendsService.name); + + constructor(private readonly prisma: PrismaService) {} + + async sendFriendRequest( + senderId: string, + receiverId: string, + ): Promise { + if (senderId === receiverId) { + throw new BadRequestException('Cannot send friend request to yourself'); + } + + const receiver = await this.prisma.user.findUnique({ + where: { id: receiverId }, + }); + + if (!receiver) { + throw new NotFoundException('User not found'); + } + + const existingFriendship = await this.areFriends(senderId, receiverId); + if (existingFriendship) { + throw new ConflictException('You are already friends with this user'); + } + + const existingRequest = await this.prisma.friendRequest.findFirst({ + where: { + OR: [ + { senderId, receiverId, status: FriendRequestStatus.PENDING }, + { + senderId: receiverId, + receiverId: senderId, + status: FriendRequestStatus.PENDING, + }, + ], + }, + }); + + if (existingRequest) { + if (existingRequest.senderId === senderId) { + throw new ConflictException( + 'You already sent a friend request to this user', + ); + } else { + throw new ConflictException( + 'This user already sent you a friend request', + ); + } + } + + const friendRequest = await this.prisma.friendRequest.create({ + data: { + senderId, + receiverId, + status: FriendRequestStatus.PENDING, + }, + include: { + sender: true, + receiver: true, + }, + }); + + this.logger.log( + `Friend request sent from ${senderId} to ${receiverId} (ID: ${friendRequest.id})`, + ); + + return friendRequest; + } + + async getPendingReceivedRequests( + userId: string, + ): Promise { + return this.prisma.friendRequest.findMany({ + where: { + receiverId: userId, + status: FriendRequestStatus.PENDING, + }, + include: { + sender: true, + receiver: true, + }, + orderBy: { + createdAt: 'desc', + }, + }); + } + + async getPendingSentRequests( + userId: string, + ): Promise { + return this.prisma.friendRequest.findMany({ + where: { + senderId: userId, + status: FriendRequestStatus.PENDING, + }, + include: { + sender: true, + receiver: true, + }, + orderBy: { + createdAt: 'desc', + }, + }); + } + + async acceptFriendRequest( + requestId: string, + userId: string, + ): Promise { + const friendRequest = await this.prisma.friendRequest.findUnique({ + where: { id: requestId }, + include: { + sender: true, + receiver: true, + }, + }); + + if (!friendRequest) { + throw new NotFoundException('Friend request not found'); + } + + if (friendRequest.receiverId !== userId) { + throw new BadRequestException( + 'You can only accept friend requests sent to you', + ); + } + + if (friendRequest.status !== FriendRequestStatus.PENDING) { + throw new BadRequestException( + `Friend request is already ${friendRequest.status.toLowerCase()}`, + ); + } + + const [updatedRequest] = await this.prisma.$transaction([ + this.prisma.friendRequest.update({ + where: { id: requestId }, + data: { status: FriendRequestStatus.ACCEPTED }, + include: { + sender: true, + receiver: true, + }, + }), + this.prisma.friendship.create({ + data: { + userId: friendRequest.senderId, + friendId: friendRequest.receiverId, + }, + }), + this.prisma.friendship.create({ + data: { + userId: friendRequest.receiverId, + friendId: friendRequest.senderId, + }, + }), + ]); + + this.logger.log( + `Friend request ${requestId} accepted. Users ${friendRequest.senderId} and ${friendRequest.receiverId} are now friends`, + ); + + return updatedRequest; + } + + async denyFriendRequest( + requestId: string, + userId: string, + ): Promise { + const friendRequest = await this.prisma.friendRequest.findUnique({ + where: { id: requestId }, + include: { + sender: true, + receiver: true, + }, + }); + + if (!friendRequest) { + throw new NotFoundException('Friend request not found'); + } + + if (friendRequest.receiverId !== userId) { + throw new BadRequestException( + 'You can only deny friend requests sent to you', + ); + } + + if (friendRequest.status !== FriendRequestStatus.PENDING) { + throw new BadRequestException( + `Friend request is already ${friendRequest.status.toLowerCase()}`, + ); + } + + const updatedRequest = await this.prisma.friendRequest.update({ + where: { id: requestId }, + data: { status: FriendRequestStatus.DENIED }, + include: { + sender: true, + receiver: true, + }, + }); + + this.logger.log(`Friend request ${requestId} denied by user ${userId}`); + + return updatedRequest; + } + + async getFriends(userId: string) { + return this.prisma.friendship.findMany({ + where: { userId }, + include: { + friend: true, + }, + orderBy: { + createdAt: 'desc', + }, + }); + } + + async unfriend(userId: string, friendId: string): Promise { + if (userId === friendId) { + throw new BadRequestException('Cannot unfriend yourself'); + } + + const friendship = await this.prisma.friendship.findFirst({ + where: { + userId, + friendId, + }, + }); + + if (!friendship) { + throw new NotFoundException('You are not friends with this user'); + } + + await this.prisma.friendship.deleteMany({ + where: { + OR: [ + { userId, friendId }, + { userId: friendId, friendId: userId }, + ], + }, + }); + + this.logger.log(`User ${userId} unfriended user ${friendId}`); + } + + async areFriends(userId: string, friendId: string): Promise { + const friendship = await this.prisma.friendship.findFirst({ + where: { + userId, + friendId, + }, + }); + + return !!friendship; + } +} diff --git a/src/types/prisma.ts b/src/types/prisma.ts new file mode 100644 index 0000000..b2e2a65 --- /dev/null +++ b/src/types/prisma.ts @@ -0,0 +1,27 @@ +import { User } from '@prisma/client'; + +export enum FriendRequestStatus { + PENDING = 'PENDING', + ACCEPTED = 'ACCEPTED', + DENIED = 'DENIED', +} + +export interface FriendRequest { + id: string; + senderId: string; + receiverId: string; + status: FriendRequestStatus; + createdAt: Date; + updatedAt: Date; + sender: User; + receiver: User; +} + +export interface Friendship { + id: string; + userId: string; + friendId: string; + createdAt: Date; + user: User; + friend: User; +} diff --git a/src/users/users.service.spec.ts b/src/users/users.service.spec.ts index cca2121..2d12c04 100644 --- a/src/users/users.service.spec.ts +++ b/src/users/users.service.spec.ts @@ -4,10 +4,10 @@ import { PrismaService } from '../database/prisma.service'; import { NotFoundException, ForbiddenException } from '@nestjs/common'; import { User } from '@prisma/client'; import { UpdateUserDto } from './dto/update-user.dto'; +import { PrismaClientKnownRequestError } from '@prisma/client/runtime/client'; describe('UsersService', () => { let service: UsersService; - let prismaService: PrismaService; const mockUser: User = { id: '550e8400-e29b-41d4-a716-446655440000', @@ -26,6 +26,7 @@ describe('UsersService', () => { user: { create: jest.fn(), findUnique: jest.fn(), + findMany: jest.fn(), update: jest.fn(), delete: jest.fn(), upsert: jest.fn(), @@ -44,7 +45,6 @@ describe('UsersService', () => { }).compile(); service = module.get(UsersService); - prismaService = module.get(PrismaService); jest.clearAllMocks(); }); @@ -509,10 +509,15 @@ describe('UsersService', () => { name: 'Test User', }; - mockPrismaService.user.update.mockRejectedValue({ - code: 'P2025', - message: 'Record not found', - }); + const prismaError = new PrismaClientKnownRequestError( + 'Record not found', + { + code: 'P2025', + clientVersion: '5.0.0', + }, + ); + + mockPrismaService.user.update.mockRejectedValue(prismaError); await expect( service.syncProfileFromToken('nonexistent', profileData), @@ -587,4 +592,134 @@ describe('UsersService', () => { ).rejects.toThrow('Database connection failed'); }); }); + + describe('searchUsers', () => { + const users: User[] = [ + { ...mockUser, id: 'user1', username: 'alice' }, + { ...mockUser, id: 'user2', username: 'bob' }, + { ...mockUser, id: 'user3', username: 'charlie' }, + ]; + + it('should search users by username (case-insensitive, partial match)', async () => { + mockPrismaService.user.findMany.mockResolvedValue([users[0]]); + + const result = await service.searchUsers('ALI'); + + expect(result).toEqual([users[0]]); + expect(mockPrismaService.user.findMany).toHaveBeenCalledWith({ + where: { + username: { + contains: 'ALI', + mode: 'insensitive', + }, + }, + take: 20, + orderBy: { + username: 'asc', + }, + }); + }); + + it('should exclude specified user from results', async () => { + mockPrismaService.user.findMany.mockResolvedValue([users[1], users[2]]); + + const result = await service.searchUsers(undefined, 'user1'); + + expect(result).toEqual([users[1], users[2]]); + expect(mockPrismaService.user.findMany).toHaveBeenCalledWith({ + where: { + id: { + not: 'user1', + }, + }, + take: 20, + orderBy: { + username: 'asc', + }, + }); + }); + + it('should combine username search with user exclusion', async () => { + mockPrismaService.user.findMany.mockResolvedValue([users[1]]); + + const result = await service.searchUsers('b', 'user1'); + + expect(result).toEqual([users[1]]); + expect(mockPrismaService.user.findMany).toHaveBeenCalledWith({ + where: { + username: { + contains: 'b', + mode: 'insensitive', + }, + id: { + not: 'user1', + }, + }, + take: 20, + orderBy: { + username: 'asc', + }, + }); + }); + + it('should limit results to 20 users', async () => { + const manyUsers = Array.from({ length: 25 }, (_, i) => ({ + ...mockUser, + id: `user${i}`, + username: `user${i}`, + })); + const limitedUsers = manyUsers.slice(0, 20); + + mockPrismaService.user.findMany.mockResolvedValue(limitedUsers); + + const result = await service.searchUsers(); + + expect(result).toHaveLength(20); + expect(mockPrismaService.user.findMany).toHaveBeenCalledWith({ + where: {}, + take: 20, + orderBy: { + username: 'asc', + }, + }); + }); + + it('should return all users when no filters provided', async () => { + mockPrismaService.user.findMany.mockResolvedValue(users); + + const result = await service.searchUsers(); + + expect(result).toEqual(users); + expect(mockPrismaService.user.findMany).toHaveBeenCalledWith({ + where: {}, + take: 20, + orderBy: { + username: 'asc', + }, + }); + }); + + it('should return empty array when no matches found', async () => { + mockPrismaService.user.findMany.mockResolvedValue([]); + + const result = await service.searchUsers('nonexistent'); + + expect(result).toEqual([]); + }); + + it('should order results by username ascending', async () => { + const unorderedUsers = [users[2], users[0], users[1]]; + mockPrismaService.user.findMany.mockResolvedValue(unorderedUsers); + + await service.searchUsers(); + + expect(mockPrismaService.user.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + orderBy: { + username: 'asc', + }, + }), + ); + }); + }); }); diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 2323403..59af3b4 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -5,7 +5,7 @@ import { Logger, } from '@nestjs/common'; import { PrismaService } from '../database/prisma.service'; -import { User } from '@prisma/client'; +import { User, Prisma } from '@prisma/client'; import type { UpdateUserDto } from './dto/update-user.dto'; import { PrismaClientKnownRequestError } from '@prisma/client/runtime/client'; @@ -288,7 +288,6 @@ export class UsersService { async delete(id: string, requestingUserKeycloakSub: string): Promise { const user = await this.findOne(id); - // Verify the user is deleting their own account if (user.keycloakSub !== requestingUserKeycloakSub) { this.logger.warn( `User ${requestingUserKeycloakSub} attempted to delete user ${id}`, @@ -304,4 +303,34 @@ export class UsersService { `User ${id} deleted their account (Keycloak: ${requestingUserKeycloakSub})`, ); } + + async searchUsers( + username?: string, + excludeUserId?: string, + ): Promise { + const where: Prisma.UserWhereInput = {}; + + if (username) { + where.username = { + contains: username, + mode: 'insensitive', + }; + } + + if (excludeUserId) { + where.id = { + not: excludeUserId, + }; + } + + const users = await this.prisma.user.findMany({ + where, + take: 20, + orderBy: { + username: 'asc', + }, + }); + + return users; + } } diff --git a/src/ws/state/state.gateway.ts b/src/ws/state/state.gateway.ts index 7d54ac1..27f2fde 100644 --- a/src/ws/state/state.gateway.ts +++ b/src/ws/state/state.gateway.ts @@ -14,9 +14,14 @@ import type { AuthenticatedSocket } from '../../types/socket'; import { AuthService } from '../../auth/auth.service'; import { JwtVerificationService } from '../../auth/services/jwt-verification.service'; import { CursorPositionDto } from '../dto/cursor-position.dto'; +import { FriendRequestWithRelations } from '../../friends/friends.service'; const WS_EVENT = { CURSOR_REPORT_POSITION: 'cursor-report-position', + FRIEND_REQUEST_RECEIVED: 'friend-request-received', + FRIEND_REQUEST_ACCEPTED: 'friend-request-accepted', + FRIEND_REQUEST_DENIED: 'friend-request-denied', + UNFRIENDED: 'unfriended', } as const; @WebSocketGateway({ @@ -29,6 +34,7 @@ export class StateGateway implements OnGatewayInit, OnGatewayConnection, OnGatewayDisconnect { private readonly logger = new Logger(StateGateway.name); + private userSocketMap: Map = new Map(); @WebSocketServer() io: Server; @@ -74,7 +80,8 @@ export class StateGateway this.logger.log(`WebSocket authenticated: ${payload.sub}`); - await this.authService.syncUserFromToken(client.data.user); + const user = await this.authService.syncUserFromToken(client.data.user); + this.userSocketMap.set(user.id, client.id); const { sockets } = this.io.sockets; this.logger.log( @@ -91,6 +98,16 @@ export class StateGateway handleDisconnect(client: AuthenticatedSocket) { const user = client.data.user; + + if (user) { + for (const [userId, socketId] of this.userSocketMap.entries()) { + if (socketId === client.id) { + this.userSocketMap.delete(userId); + break; + } + } + } + this.logger.log( `Client id: ${client.id} disconnected (user: ${user?.keycloakSub || 'unknown'})`, ); @@ -112,4 +129,80 @@ export class StateGateway ); this.logger.debug(`Payload: ${JSON.stringify(data, null, 0)}`); } + + emitFriendRequestReceived( + userId: string, + friendRequest: FriendRequestWithRelations, + ) { + const socketId = this.userSocketMap.get(userId); + if (socketId) { + this.io.to(socketId).emit(WS_EVENT.FRIEND_REQUEST_RECEIVED, { + id: friendRequest.id, + sender: { + id: friendRequest.sender.id, + name: friendRequest.sender.name, + username: friendRequest.sender.username, + picture: friendRequest.sender.picture, + }, + createdAt: friendRequest.createdAt, + }); + this.logger.debug( + `Emitted friend request notification to user ${userId}`, + ); + } + } + + emitFriendRequestAccepted( + userId: string, + friendRequest: FriendRequestWithRelations, + ) { + const socketId = this.userSocketMap.get(userId); + if (socketId) { + this.io.to(socketId).emit(WS_EVENT.FRIEND_REQUEST_ACCEPTED, { + id: friendRequest.id, + friend: { + id: friendRequest.receiver.id, + name: friendRequest.receiver.name, + username: friendRequest.receiver.username, + picture: friendRequest.receiver.picture, + }, + acceptedAt: friendRequest.updatedAt, + }); + this.logger.debug( + `Emitted friend request accepted notification to user ${userId}`, + ); + } + } + + emitFriendRequestDenied( + userId: string, + friendRequest: FriendRequestWithRelations, + ) { + const socketId = this.userSocketMap.get(userId); + if (socketId) { + this.io.to(socketId).emit(WS_EVENT.FRIEND_REQUEST_DENIED, { + id: friendRequest.id, + denier: { + id: friendRequest.receiver.id, + name: friendRequest.receiver.name, + username: friendRequest.receiver.username, + picture: friendRequest.receiver.picture, + }, + deniedAt: friendRequest.updatedAt, + }); + this.logger.debug( + `Emitted friend request denied notification to user ${userId}`, + ); + } + } + + emitUnfriended(userId: string, friendId: string) { + const socketId = this.userSocketMap.get(userId); + if (socketId) { + this.io.to(socketId).emit(WS_EVENT.UNFRIENDED, { + friendId, + }); + this.logger.debug(`Emitted unfriended notification to user ${userId}`); + } + } } diff --git a/src/ws/ws.module.ts b/src/ws/ws.module.ts index 62d8171..8d1f410 100644 --- a/src/ws/ws.module.ts +++ b/src/ws/ws.module.ts @@ -5,5 +5,6 @@ import { AuthModule } from '../auth/auth.module'; @Module({ imports: [AuthModule], providers: [StateGateway], + exports: [StateGateway], }) export class WsModule {}