friends system

This commit is contained in:
2025-12-08 01:42:39 +08:00
parent c8cfca8de8
commit 15a1551eb6
16 changed files with 1905 additions and 15 deletions

View File

@@ -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;

View File

@@ -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
}

View File

@@ -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<string, any>): Record<string, any> {
*/
@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],

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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>(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',
);
});
});
});

View File

@@ -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<UserBasicDto[]> {
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<FriendRequestResponseDto> {
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<FriendRequestResponseDto[]> {
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<FriendRequestResponseDto[]> {
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<FriendRequestResponseDto> {
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<FriendRequestResponseDto> {
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<FriendshipResponseDto[]> {
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<void> {
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,
};
}
}

View File

@@ -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 {}

View File

@@ -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>(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);
});
});
});

View File

@@ -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<FriendRequestWithRelations> {
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<FriendRequestWithRelations[]> {
return this.prisma.friendRequest.findMany({
where: {
receiverId: userId,
status: FriendRequestStatus.PENDING,
},
include: {
sender: true,
receiver: true,
},
orderBy: {
createdAt: 'desc',
},
});
}
async getPendingSentRequests(
userId: string,
): Promise<FriendRequestWithRelations[]> {
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<FriendRequestWithRelations> {
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<FriendRequestWithRelations> {
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<void> {
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<boolean> {
const friendship = await this.prisma.friendship.findFirst({
where: {
userId,
friendId,
},
});
return !!friendship;
}
}

27
src/types/prisma.ts Normal file
View File

@@ -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;
}

View File

@@ -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>(UsersService);
prismaService = module.get<PrismaService>(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',
},
}),
);
});
});
});

View File

@@ -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<void> {
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<User[]> {
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;
}
}

View File

@@ -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<string, string> = 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}`);
}
}
}

View File

@@ -5,5 +5,6 @@ import { AuthModule } from '../auth/auth.module';
@Module({
imports: [AuthModule],
providers: [StateGateway],
exports: [StateGateway],
})
export class WsModule {}