efficiency & performance fine tuning
This commit is contained in:
@@ -30,10 +30,12 @@
|
|||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
"@nestjs/config": "^4.0.2",
|
"@nestjs/config": "^4.0.2",
|
||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
|
"@nestjs/event-emitter": "^3.0.1",
|
||||||
"@nestjs/passport": "^11.0.5",
|
"@nestjs/passport": "^11.0.5",
|
||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/platform-socket.io": "^11.1.9",
|
"@nestjs/platform-socket.io": "^11.1.9",
|
||||||
"@nestjs/swagger": "^11.2.3",
|
"@nestjs/swagger": "^11.2.3",
|
||||||
|
"@nestjs/throttler": "^6.5.0",
|
||||||
"@nestjs/websockets": "^11.1.9",
|
"@nestjs/websockets": "^11.1.9",
|
||||||
"@prisma/adapter-pg": "^7.0.0",
|
"@prisma/adapter-pg": "^7.0.0",
|
||||||
"@prisma/client": "^7.0.0",
|
"@prisma/client": "^7.0.0",
|
||||||
|
|||||||
36
pnpm-lock.yaml
generated
36
pnpm-lock.yaml
generated
@@ -17,6 +17,9 @@ importers:
|
|||||||
'@nestjs/core':
|
'@nestjs/core':
|
||||||
specifier: ^11.0.1
|
specifier: ^11.0.1
|
||||||
version: 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(@nestjs/websockets@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
version: 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(@nestjs/websockets@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
'@nestjs/event-emitter':
|
||||||
|
specifier: ^3.0.1
|
||||||
|
version: 3.0.1(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(@nestjs/websockets@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2))
|
||||||
'@nestjs/passport':
|
'@nestjs/passport':
|
||||||
specifier: ^11.0.5
|
specifier: ^11.0.5
|
||||||
version: 11.0.5(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
|
version: 11.0.5(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
|
||||||
@@ -29,6 +32,9 @@ importers:
|
|||||||
'@nestjs/swagger':
|
'@nestjs/swagger':
|
||||||
specifier: ^11.2.3
|
specifier: ^11.2.3
|
||||||
version: 11.2.3(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(@nestjs/websockets@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)
|
version: 11.2.3(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(@nestjs/websockets@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)
|
||||||
|
'@nestjs/throttler':
|
||||||
|
specifier: ^6.5.0
|
||||||
|
version: 6.5.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(@nestjs/websockets@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)
|
||||||
'@nestjs/websockets':
|
'@nestjs/websockets':
|
||||||
specifier: ^11.1.9
|
specifier: ^11.1.9
|
||||||
version: 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(@nestjs/platform-socket.io@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
version: 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(@nestjs/platform-socket.io@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
@@ -789,6 +795,12 @@ packages:
|
|||||||
'@nestjs/websockets':
|
'@nestjs/websockets':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@nestjs/event-emitter@3.0.1':
|
||||||
|
resolution: {integrity: sha512-0Ln/x+7xkU6AJFOcQI9tIhUMXVF7D5itiaQGOyJbXtlAfAIt8gzDdJm+Im7cFzKoWkiW5nCXCPh6GSvdQd/3Dw==}
|
||||||
|
peerDependencies:
|
||||||
|
'@nestjs/common': ^10.0.0 || ^11.0.0
|
||||||
|
'@nestjs/core': ^10.0.0 || ^11.0.0
|
||||||
|
|
||||||
'@nestjs/mapped-types@2.1.0':
|
'@nestjs/mapped-types@2.1.0':
|
||||||
resolution: {integrity: sha512-W+n+rM69XsFdwORF11UqJahn4J3xi4g/ZEOlJNL6KoW5ygWSmBB2p0S2BZ4FQeS/NDH72e6xIcu35SfJnE8bXw==}
|
resolution: {integrity: sha512-W+n+rM69XsFdwORF11UqJahn4J3xi4g/ZEOlJNL6KoW5ygWSmBB2p0S2BZ4FQeS/NDH72e6xIcu35SfJnE8bXw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -856,6 +868,13 @@ packages:
|
|||||||
'@nestjs/platform-express':
|
'@nestjs/platform-express':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@nestjs/throttler@6.5.0':
|
||||||
|
resolution: {integrity: sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==}
|
||||||
|
peerDependencies:
|
||||||
|
'@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
|
||||||
|
'@nestjs/core': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
|
||||||
|
reflect-metadata: ^0.1.13 || ^0.2.0
|
||||||
|
|
||||||
'@nestjs/websockets@11.1.9':
|
'@nestjs/websockets@11.1.9':
|
||||||
resolution: {integrity: sha512-kkkdeTVcc3X7ZzvVqUVpOAJoh49kTRUjWNUXo5jmG+27OvZoHfs/vuSiqxidrrbIgydSqN15HUsf1wZwQUrxCQ==}
|
resolution: {integrity: sha512-kkkdeTVcc3X7ZzvVqUVpOAJoh49kTRUjWNUXo5jmG+27OvZoHfs/vuSiqxidrrbIgydSqN15HUsf1wZwQUrxCQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1975,6 +1994,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
eventemitter2@6.4.9:
|
||||||
|
resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==}
|
||||||
|
|
||||||
events@3.3.0:
|
events@3.3.0:
|
||||||
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
|
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
|
||||||
engines: {node: '>=0.8.x'}
|
engines: {node: '>=0.8.x'}
|
||||||
@@ -4490,6 +4512,12 @@ snapshots:
|
|||||||
'@nestjs/platform-express': 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)
|
'@nestjs/platform-express': 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)
|
||||||
'@nestjs/websockets': 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(@nestjs/platform-socket.io@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/websockets': 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(@nestjs/platform-socket.io@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
|
||||||
|
'@nestjs/event-emitter@3.0.1(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(@nestjs/websockets@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2))':
|
||||||
|
dependencies:
|
||||||
|
'@nestjs/common': 11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
'@nestjs/core': 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(@nestjs/websockets@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
eventemitter2: 6.4.9
|
||||||
|
|
||||||
'@nestjs/mapped-types@2.1.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)':
|
'@nestjs/mapped-types@2.1.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nestjs/common': 11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/common': 11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
@@ -4561,6 +4589,12 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@nestjs/platform-express': 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)
|
'@nestjs/platform-express': 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)
|
||||||
|
|
||||||
|
'@nestjs/throttler@6.5.0(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(@nestjs/websockets@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)':
|
||||||
|
dependencies:
|
||||||
|
'@nestjs/common': 11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
'@nestjs/core': 11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.9)(@nestjs/websockets@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
|
reflect-metadata: 0.2.2
|
||||||
|
|
||||||
'@nestjs/websockets@11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(@nestjs/platform-socket.io@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
|
'@nestjs/websockets@11.1.9(@nestjs/common@11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.9)(@nestjs/platform-socket.io@11.1.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nestjs/common': 11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
'@nestjs/common': 11.1.9(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||||
@@ -5753,6 +5787,8 @@ snapshots:
|
|||||||
|
|
||||||
etag@1.8.1: {}
|
etag@1.8.1: {}
|
||||||
|
|
||||||
|
eventemitter2@6.4.9: {}
|
||||||
|
|
||||||
events@3.3.0: {}
|
events@3.3.0: {}
|
||||||
|
|
||||||
execa@5.1.1:
|
execa@5.1.1:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
|
previewFeatures = ["driverAdapters"]
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||||
|
import { ThrottlerModule } from '@nestjs/throttler';
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { AppService } from './app.service';
|
import { AppService } from './app.service';
|
||||||
import { UsersModule } from './users/users.module';
|
import { UsersModule } from './users/users.module';
|
||||||
@@ -49,6 +51,17 @@ function validateEnvironment(config: Record<string, any>): Record<string, any> {
|
|||||||
envFilePath: '.env',
|
envFilePath: '.env',
|
||||||
validate: validateEnvironment,
|
validate: validateEnvironment,
|
||||||
}),
|
}),
|
||||||
|
ThrottlerModule.forRootAsync({
|
||||||
|
imports: [ConfigModule],
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (config: ConfigService) => [
|
||||||
|
{
|
||||||
|
ttl: config.get('THROTTLE_TTL', 60000),
|
||||||
|
limit: config.get('THROTTLE_LIMIT', 10),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
EventEmitterModule.forRoot(),
|
||||||
DatabaseModule,
|
DatabaseModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ describe('AuthService', () => {
|
|||||||
|
|
||||||
const mockCreateFromToken = jest.fn();
|
const mockCreateFromToken = jest.fn();
|
||||||
const mockFindByKeycloakSub = jest.fn();
|
const mockFindByKeycloakSub = jest.fn();
|
||||||
|
const mockFindOrCreate = jest.fn();
|
||||||
|
|
||||||
const mockUsersService = {
|
const mockUsersService = {
|
||||||
createFromToken: mockCreateFromToken,
|
createFromToken: mockCreateFromToken,
|
||||||
findByKeycloakSub: mockFindByKeycloakSub,
|
findByKeycloakSub: mockFindByKeycloakSub,
|
||||||
|
findOrCreate: mockFindOrCreate,
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockAuthUser: AuthenticatedUser = {
|
const mockAuthUser: AuthenticatedUser = {
|
||||||
@@ -205,25 +207,13 @@ describe('AuthService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('ensureUserExists', () => {
|
describe('ensureUserExists', () => {
|
||||||
it('should return existing user without updating', async () => {
|
it('should call findOrCreate with correct params', async () => {
|
||||||
mockFindByKeycloakSub.mockResolvedValue(mockUser);
|
mockFindOrCreate.mockResolvedValue(mockUser);
|
||||||
|
|
||||||
const result = await service.ensureUserExists(mockAuthUser);
|
const result = await service.ensureUserExists(mockAuthUser);
|
||||||
|
|
||||||
expect(result).toEqual(mockUser);
|
expect(result).toEqual(mockUser);
|
||||||
expect(mockFindByKeycloakSub).toHaveBeenCalledWith('f:realm:user123');
|
expect(mockFindOrCreate).toHaveBeenCalledWith({
|
||||||
expect(mockCreateFromToken).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create new user if user does not exist', async () => {
|
|
||||||
mockFindByKeycloakSub.mockResolvedValue(null);
|
|
||||||
mockCreateFromToken.mockResolvedValue(mockUser);
|
|
||||||
|
|
||||||
const result = await service.ensureUserExists(mockAuthUser);
|
|
||||||
|
|
||||||
expect(result).toEqual(mockUser);
|
|
||||||
expect(mockFindByKeycloakSub).toHaveBeenCalledWith('f:realm:user123');
|
|
||||||
expect(mockCreateFromToken).toHaveBeenCalledWith({
|
|
||||||
keycloakSub: 'f:realm:user123',
|
keycloakSub: 'f:realm:user123',
|
||||||
email: 'test@example.com',
|
email: 'test@example.com',
|
||||||
name: 'Test User',
|
name: 'Test User',
|
||||||
@@ -233,14 +223,13 @@ describe('AuthService', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle user with no email when creating new user', async () => {
|
it('should handle user with no email', async () => {
|
||||||
const authUserNoEmail: AuthenticatedUser = {
|
const authUserNoEmail: AuthenticatedUser = {
|
||||||
keycloakSub: 'f:realm:user456',
|
keycloakSub: 'f:realm:user456',
|
||||||
name: 'No Email User',
|
name: 'No Email User',
|
||||||
};
|
};
|
||||||
|
|
||||||
mockFindByKeycloakSub.mockResolvedValue(null);
|
mockFindOrCreate.mockResolvedValue({
|
||||||
mockCreateFromToken.mockResolvedValue({
|
|
||||||
...mockUser,
|
...mockUser,
|
||||||
email: '',
|
email: '',
|
||||||
name: 'No Email User',
|
name: 'No Email User',
|
||||||
@@ -248,8 +237,7 @@ describe('AuthService', () => {
|
|||||||
|
|
||||||
await service.ensureUserExists(authUserNoEmail);
|
await service.ensureUserExists(authUserNoEmail);
|
||||||
|
|
||||||
expect(mockFindByKeycloakSub).toHaveBeenCalledWith('f:realm:user456');
|
expect(mockFindOrCreate).toHaveBeenCalledWith(
|
||||||
expect(mockCreateFromToken).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
email: '',
|
email: '',
|
||||||
name: 'No Email User',
|
name: 'No Email User',
|
||||||
@@ -262,16 +250,14 @@ describe('AuthService', () => {
|
|||||||
keycloakSub: 'f:realm:minimal',
|
keycloakSub: 'f:realm:minimal',
|
||||||
};
|
};
|
||||||
|
|
||||||
mockFindByKeycloakSub.mockResolvedValue(null);
|
mockFindOrCreate.mockResolvedValue({
|
||||||
mockCreateFromToken.mockResolvedValue({
|
|
||||||
...mockUser,
|
...mockUser,
|
||||||
name: 'Unknown User',
|
name: 'Unknown User',
|
||||||
});
|
});
|
||||||
|
|
||||||
await service.ensureUserExists(authUserMinimal);
|
await service.ensureUserExists(authUserMinimal);
|
||||||
|
|
||||||
expect(mockFindByKeycloakSub).toHaveBeenCalledWith('f:realm:minimal');
|
expect(mockFindOrCreate).toHaveBeenCalledWith(
|
||||||
expect(mockCreateFromToken).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
name: 'Unknown User',
|
name: 'Unknown User',
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export class AuthService {
|
|||||||
* need to sync profile data from Keycloak on every request.
|
* need to sync profile data from Keycloak on every request.
|
||||||
*
|
*
|
||||||
* - For new users: Creates with full profile data
|
* - For new users: Creates with full profile data
|
||||||
* - For existing users: Returns existing record WITHOUT updating
|
* - For existing users: Returns existing record WITHOUT updating (read-only)
|
||||||
*
|
*
|
||||||
* Use this for most API endpoints. Only use syncUserFromToken() for actual
|
* Use this for most API endpoints. Only use syncUserFromToken() for actual
|
||||||
* login events (WebSocket connections, /users/me endpoint).
|
* login events (WebSocket connections, /users/me endpoint).
|
||||||
@@ -84,20 +84,10 @@ export class AuthService {
|
|||||||
const { keycloakSub, email, name, username, picture, roles } =
|
const { keycloakSub, email, name, username, picture, roles } =
|
||||||
authenticatedUser;
|
authenticatedUser;
|
||||||
|
|
||||||
// Check if user exists
|
// Use optimized findOrCreate method
|
||||||
const existingUser = await this.usersService.findByKeycloakSub(keycloakSub);
|
// This returns existing users immediately (1 read)
|
||||||
|
// And creates new users if needed (1 read + 1 write)
|
||||||
if (existingUser) {
|
return await this.usersService.findOrCreate({
|
||||||
// User exists - return without updating
|
|
||||||
return existingUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
// New user - create with full profile data
|
|
||||||
this.logger.log(
|
|
||||||
`Creating new user from token: ${keycloakSub} (via ensureUserExists)`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const user = await this.usersService.createFromToken({
|
|
||||||
keycloakSub,
|
keycloakSub,
|
||||||
email: email || '',
|
email: email || '',
|
||||||
name: name || username || 'Unknown User',
|
name: name || username || 'Unknown User',
|
||||||
@@ -105,8 +95,6 @@ export class AuthService {
|
|||||||
picture,
|
picture,
|
||||||
roles,
|
roles,
|
||||||
});
|
});
|
||||||
|
|
||||||
return user;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
33
src/friends/events/friend.events.ts
Normal file
33
src/friends/events/friend.events.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { FriendRequest, User } from '@prisma/client';
|
||||||
|
|
||||||
|
export enum FriendEvents {
|
||||||
|
REQUEST_RECEIVED = 'friend.request.received',
|
||||||
|
REQUEST_ACCEPTED = 'friend.request.accepted',
|
||||||
|
REQUEST_DENIED = 'friend.request.denied',
|
||||||
|
UNFRIENDED = 'friend.unfriended',
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FriendRequestWithRelations = FriendRequest & {
|
||||||
|
sender: User;
|
||||||
|
receiver: User;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface FriendRequestReceivedEvent {
|
||||||
|
userId: string;
|
||||||
|
friendRequest: FriendRequestWithRelations;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FriendRequestAcceptedEvent {
|
||||||
|
userId: string;
|
||||||
|
friendRequest: FriendRequestWithRelations;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FriendRequestDeniedEvent {
|
||||||
|
userId: string;
|
||||||
|
friendRequest: FriendRequestWithRelations;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnfriendedEvent {
|
||||||
|
userId: string;
|
||||||
|
friendId: string;
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { ThrottlerModule } from '@nestjs/throttler';
|
||||||
import { FriendsController } from './friends.controller';
|
import { FriendsController } from './friends.controller';
|
||||||
import { FriendsService } from './friends.service';
|
import { FriendsService } from './friends.service';
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
import { AuthService } from '../auth/auth.service';
|
import { AuthService } from '../auth/auth.service';
|
||||||
import { StateGateway } from '../ws/state/state.gateway';
|
// StateGateway removed
|
||||||
|
|
||||||
enum FriendRequestStatus {
|
enum FriendRequestStatus {
|
||||||
PENDING = 'PENDING',
|
PENDING = 'PENDING',
|
||||||
@@ -85,21 +86,21 @@ describe('FriendsController', () => {
|
|||||||
ensureUserExists: jest.fn(),
|
ensureUserExists: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockStateGateway = {
|
|
||||||
emitFriendRequestReceived: jest.fn(),
|
|
||||||
emitFriendRequestAccepted: jest.fn(),
|
|
||||||
emitFriendRequestDenied: jest.fn(),
|
|
||||||
emitUnfriended: jest.fn(),
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
imports: [
|
||||||
|
ThrottlerModule.forRoot([
|
||||||
|
{
|
||||||
|
ttl: 60000,
|
||||||
|
limit: 10,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
],
|
||||||
controllers: [FriendsController],
|
controllers: [FriendsController],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: FriendsService, useValue: mockFriendsService },
|
{ provide: FriendsService, useValue: mockFriendsService },
|
||||||
{ provide: UsersService, useValue: mockUsersService },
|
{ provide: UsersService, useValue: mockUsersService },
|
||||||
{ provide: AuthService, useValue: mockAuthService },
|
{ provide: AuthService, useValue: mockAuthService },
|
||||||
{ provide: StateGateway, useValue: mockStateGateway },
|
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
@@ -140,7 +141,7 @@ describe('FriendsController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('sendFriendRequest', () => {
|
describe('sendFriendRequest', () => {
|
||||||
it('should send friend request and emit WebSocket event', async () => {
|
it('should send friend request', async () => {
|
||||||
mockFriendsService.sendFriendRequest.mockResolvedValue(mockFriendRequest);
|
mockFriendsService.sendFriendRequest.mockResolvedValue(mockFriendRequest);
|
||||||
|
|
||||||
const result = await controller.sendFriendRequest(
|
const result = await controller.sendFriendRequest(
|
||||||
@@ -170,10 +171,6 @@ describe('FriendsController', () => {
|
|||||||
'user-1',
|
'user-1',
|
||||||
'user-2',
|
'user-2',
|
||||||
);
|
);
|
||||||
expect(mockStateGateway.emitFriendRequestReceived).toHaveBeenCalledWith(
|
|
||||||
'user-2',
|
|
||||||
mockFriendRequest,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -210,7 +207,7 @@ describe('FriendsController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('acceptFriendRequest', () => {
|
describe('acceptFriendRequest', () => {
|
||||||
it('should accept friend request and emit WebSocket event', async () => {
|
it('should accept friend request', async () => {
|
||||||
const acceptedRequest = {
|
const acceptedRequest = {
|
||||||
...mockFriendRequest,
|
...mockFriendRequest,
|
||||||
status: FriendRequestStatus.ACCEPTED,
|
status: FriendRequestStatus.ACCEPTED,
|
||||||
@@ -227,15 +224,11 @@ describe('FriendsController', () => {
|
|||||||
'request-1',
|
'request-1',
|
||||||
'user-1',
|
'user-1',
|
||||||
);
|
);
|
||||||
expect(mockStateGateway.emitFriendRequestAccepted).toHaveBeenCalledWith(
|
|
||||||
'user-1',
|
|
||||||
acceptedRequest,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('denyFriendRequest', () => {
|
describe('denyFriendRequest', () => {
|
||||||
it('should deny friend request and emit WebSocket event', async () => {
|
it('should deny friend request', async () => {
|
||||||
const deniedRequest = {
|
const deniedRequest = {
|
||||||
...mockFriendRequest,
|
...mockFriendRequest,
|
||||||
status: FriendRequestStatus.DENIED,
|
status: FriendRequestStatus.DENIED,
|
||||||
@@ -252,10 +245,6 @@ describe('FriendsController', () => {
|
|||||||
'request-1',
|
'request-1',
|
||||||
'user-1',
|
'user-1',
|
||||||
);
|
);
|
||||||
expect(mockStateGateway.emitFriendRequestDenied).toHaveBeenCalledWith(
|
|
||||||
'user-1',
|
|
||||||
deniedRequest,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -282,7 +271,7 @@ describe('FriendsController', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('unfriend', () => {
|
describe('unfriend', () => {
|
||||||
it('should unfriend user and emit WebSocket event', async () => {
|
it('should unfriend user', async () => {
|
||||||
mockFriendsService.unfriend.mockResolvedValue(undefined);
|
mockFriendsService.unfriend.mockResolvedValue(undefined);
|
||||||
|
|
||||||
await controller.unfriend('user-2', mockAuthUser);
|
await controller.unfriend('user-2', mockAuthUser);
|
||||||
@@ -291,10 +280,6 @@ describe('FriendsController', () => {
|
|||||||
'user-1',
|
'user-1',
|
||||||
'user-2',
|
'user-2',
|
||||||
);
|
);
|
||||||
expect(mockStateGateway.emitUnfriended).toHaveBeenCalledWith(
|
|
||||||
'user-2',
|
|
||||||
'user-1',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
ApiUnauthorizedResponse,
|
ApiUnauthorizedResponse,
|
||||||
ApiQuery,
|
ApiQuery,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
|
import { ThrottlerGuard, Throttle } from '@nestjs/throttler';
|
||||||
import { User, FriendRequest } from '@prisma/client';
|
import { User, FriendRequest } from '@prisma/client';
|
||||||
import { FriendsService } from './friends.service';
|
import { FriendsService } from './friends.service';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
@@ -40,7 +41,6 @@ type FriendRequestWithRelations = FriendRequest & {
|
|||||||
receiver: User;
|
receiver: User;
|
||||||
};
|
};
|
||||||
import { UsersService } from '../users/users.service';
|
import { UsersService } from '../users/users.service';
|
||||||
import { StateGateway } from '../ws/state/state.gateway';
|
|
||||||
|
|
||||||
@ApiTags('friends')
|
@ApiTags('friends')
|
||||||
@Controller('friends')
|
@Controller('friends')
|
||||||
@@ -53,10 +53,11 @@ export class FriendsController {
|
|||||||
private readonly friendsService: FriendsService,
|
private readonly friendsService: FriendsService,
|
||||||
private readonly usersService: UsersService,
|
private readonly usersService: UsersService,
|
||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
private readonly stateGateway: StateGateway,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('search')
|
@Get('search')
|
||||||
|
@UseGuards(ThrottlerGuard)
|
||||||
|
@Throttle({ default: { limit: 10, ttl: 60000 } })
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: 'Search users by username',
|
summary: 'Search users by username',
|
||||||
description: 'Search for users by username to send friend requests',
|
description: 'Search for users by username to send friend requests',
|
||||||
@@ -137,11 +138,6 @@ export class FriendsController {
|
|||||||
sendRequestDto.receiverId,
|
sendRequestDto.receiverId,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.stateGateway.emitFriendRequestReceived(
|
|
||||||
sendRequestDto.receiverId,
|
|
||||||
friendRequest,
|
|
||||||
);
|
|
||||||
|
|
||||||
return this.mapFriendRequestToDto(friendRequest);
|
return this.mapFriendRequestToDto(friendRequest);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,11 +232,6 @@ export class FriendsController {
|
|||||||
user.id,
|
user.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.stateGateway.emitFriendRequestAccepted(
|
|
||||||
friendRequest.senderId,
|
|
||||||
friendRequest,
|
|
||||||
);
|
|
||||||
|
|
||||||
return this.mapFriendRequestToDto(friendRequest);
|
return this.mapFriendRequestToDto(friendRequest);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,11 +274,6 @@ export class FriendsController {
|
|||||||
user.id,
|
user.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
this.stateGateway.emitFriendRequestDenied(
|
|
||||||
friendRequest.senderId,
|
|
||||||
friendRequest,
|
|
||||||
);
|
|
||||||
|
|
||||||
return this.mapFriendRequestToDto(friendRequest);
|
return this.mapFriendRequestToDto(friendRequest);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,8 +346,6 @@ export class FriendsController {
|
|||||||
this.logger.log(`User ${user.id} unfriending user ${friendId}`);
|
this.logger.log(`User ${user.id} unfriending user ${friendId}`);
|
||||||
|
|
||||||
await this.friendsService.unfriend(user.id, friendId);
|
await this.friendsService.unfriend(user.id, friendId);
|
||||||
|
|
||||||
this.stateGateway.emitUnfriended(friendId, user.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapFriendRequestToDto(
|
private mapFriendRequestToDto(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { FriendsService } from './friends.service';
|
import { FriendsService } from './friends.service';
|
||||||
import { PrismaService } from '../database/prisma.service';
|
import { PrismaService } from '../database/prisma.service';
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +16,7 @@ enum FriendRequestStatus {
|
|||||||
|
|
||||||
describe('FriendsService', () => {
|
describe('FriendsService', () => {
|
||||||
let service: FriendsService;
|
let service: FriendsService;
|
||||||
|
let eventEmitter: EventEmitter2;
|
||||||
|
|
||||||
const mockUser1 = {
|
const mockUser1 = {
|
||||||
id: 'user-1',
|
id: 'user-1',
|
||||||
@@ -82,6 +84,10 @@ describe('FriendsService', () => {
|
|||||||
$transaction: jest.fn(),
|
$transaction: jest.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockEventEmitter = {
|
||||||
|
emit: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
@@ -90,10 +96,15 @@ describe('FriendsService', () => {
|
|||||||
provide: PrismaService,
|
provide: PrismaService,
|
||||||
useValue: mockPrismaService,
|
useValue: mockPrismaService,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: EventEmitter2,
|
||||||
|
useValue: mockEventEmitter,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
service = module.get<FriendsService>(FriendsService);
|
service = module.get<FriendsService>(FriendsService);
|
||||||
|
eventEmitter = module.get<EventEmitter2>(EventEmitter2);
|
||||||
|
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
@@ -110,6 +121,12 @@ describe('FriendsService', () => {
|
|||||||
mockPrismaService.friendRequest.create.mockResolvedValue(
|
mockPrismaService.friendRequest.create.mockResolvedValue(
|
||||||
mockFriendRequest,
|
mockFriendRequest,
|
||||||
);
|
);
|
||||||
|
// Mock transaction implementation
|
||||||
|
mockPrismaService.$transaction.mockImplementation(
|
||||||
|
async (callback: (prisma: any) => Promise<any>) => {
|
||||||
|
return (await callback(mockPrismaService)) as unknown;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const result = await service.sendFriendRequest('user-1', 'user-2');
|
const result = await service.sendFriendRequest('user-1', 'user-2');
|
||||||
|
|
||||||
@@ -128,6 +145,7 @@ describe('FriendsService', () => {
|
|||||||
receiver: true,
|
receiver: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
expect(mockEventEmitter.emit).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw BadRequestException when trying to send request to self', async () => {
|
it('should throw BadRequestException when trying to send request to self', async () => {
|
||||||
@@ -141,6 +159,11 @@ describe('FriendsService', () => {
|
|||||||
|
|
||||||
it('should throw NotFoundException when receiver does not exist', async () => {
|
it('should throw NotFoundException when receiver does not exist', async () => {
|
||||||
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||||
|
mockPrismaService.$transaction.mockImplementation(
|
||||||
|
async (callback: (prisma: any) => Promise<any>) => {
|
||||||
|
return (await callback(mockPrismaService)) as unknown;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.sendFriendRequest('user-1', 'nonexistent'),
|
service.sendFriendRequest('user-1', 'nonexistent'),
|
||||||
@@ -153,6 +176,11 @@ describe('FriendsService', () => {
|
|||||||
it('should throw ConflictException when users are already friends', async () => {
|
it('should throw ConflictException when users are already friends', async () => {
|
||||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser2);
|
mockPrismaService.user.findUnique.mockResolvedValue(mockUser2);
|
||||||
mockPrismaService.friendship.findFirst.mockResolvedValue(mockFriendship);
|
mockPrismaService.friendship.findFirst.mockResolvedValue(mockFriendship);
|
||||||
|
mockPrismaService.$transaction.mockImplementation(
|
||||||
|
async (callback: (prisma: any) => Promise<any>) => {
|
||||||
|
return (await callback(mockPrismaService)) as unknown;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.sendFriendRequest('user-1', 'user-2'),
|
service.sendFriendRequest('user-1', 'user-2'),
|
||||||
@@ -168,6 +196,11 @@ describe('FriendsService', () => {
|
|||||||
mockPrismaService.friendRequest.findFirst.mockResolvedValue(
|
mockPrismaService.friendRequest.findFirst.mockResolvedValue(
|
||||||
mockFriendRequest,
|
mockFriendRequest,
|
||||||
);
|
);
|
||||||
|
mockPrismaService.$transaction.mockImplementation(
|
||||||
|
async (callback: (prisma: any) => Promise<any>) => {
|
||||||
|
return (await callback(mockPrismaService)) as unknown;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.sendFriendRequest('user-1', 'user-2'),
|
service.sendFriendRequest('user-1', 'user-2'),
|
||||||
@@ -185,6 +218,11 @@ describe('FriendsService', () => {
|
|||||||
senderId: 'user-2',
|
senderId: 'user-2',
|
||||||
receiverId: 'user-1',
|
receiverId: 'user-1',
|
||||||
});
|
});
|
||||||
|
mockPrismaService.$transaction.mockImplementation(
|
||||||
|
async (callback: (prisma: any) => Promise<any>) => {
|
||||||
|
return (await callback(mockPrismaService)) as unknown;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.sendFriendRequest('user-1', 'user-2'),
|
service.sendFriendRequest('user-1', 'user-2'),
|
||||||
@@ -259,6 +297,7 @@ describe('FriendsService', () => {
|
|||||||
|
|
||||||
expect(result).toEqual(acceptedRequest);
|
expect(result).toEqual(acceptedRequest);
|
||||||
expect(mockPrismaService.$transaction).toHaveBeenCalled();
|
expect(mockPrismaService.$transaction).toHaveBeenCalled();
|
||||||
|
expect(mockEventEmitter.emit).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw NotFoundException when request does not exist', async () => {
|
it('should throw NotFoundException when request does not exist', async () => {
|
||||||
@@ -320,6 +359,7 @@ describe('FriendsService', () => {
|
|||||||
expect(mockPrismaService.friendRequest.delete).toHaveBeenCalledWith({
|
expect(mockPrismaService.friendRequest.delete).toHaveBeenCalledWith({
|
||||||
where: { id: 'request-1' },
|
where: { id: 'request-1' },
|
||||||
});
|
});
|
||||||
|
expect(mockEventEmitter.emit).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw NotFoundException when request does not exist', async () => {
|
it('should throw NotFoundException when request does not exist', async () => {
|
||||||
@@ -392,6 +432,7 @@ describe('FriendsService', () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
expect(mockEventEmitter.emit).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw BadRequestException when trying to unfriend self', async () => {
|
it('should throw BadRequestException when trying to unfriend self', async () => {
|
||||||
|
|||||||
@@ -5,8 +5,16 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { PrismaService } from '../database/prisma.service';
|
import { PrismaService } from '../database/prisma.service';
|
||||||
import { User, FriendRequest, FriendRequestStatus } from '@prisma/client';
|
import { User, FriendRequest, FriendRequestStatus } from '@prisma/client';
|
||||||
|
import {
|
||||||
|
FriendEvents,
|
||||||
|
FriendRequestReceivedEvent,
|
||||||
|
FriendRequestAcceptedEvent,
|
||||||
|
FriendRequestDeniedEvent,
|
||||||
|
UnfriendedEvent,
|
||||||
|
} from './events/friend.events';
|
||||||
|
|
||||||
export type FriendRequestWithRelations = FriendRequest & {
|
export type FriendRequestWithRelations = FriendRequest & {
|
||||||
sender: User;
|
sender: User;
|
||||||
@@ -17,7 +25,10 @@ export type FriendRequestWithRelations = FriendRequest & {
|
|||||||
export class FriendsService {
|
export class FriendsService {
|
||||||
private readonly logger = new Logger(FriendsService.name);
|
private readonly logger = new Logger(FriendsService.name);
|
||||||
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly eventEmitter: EventEmitter2,
|
||||||
|
) {}
|
||||||
|
|
||||||
async sendFriendRequest(
|
async sendFriendRequest(
|
||||||
senderId: string,
|
senderId: string,
|
||||||
@@ -27,7 +38,8 @@ export class FriendsService {
|
|||||||
throw new BadRequestException('Cannot send friend request to yourself');
|
throw new BadRequestException('Cannot send friend request to yourself');
|
||||||
}
|
}
|
||||||
|
|
||||||
const receiver = await this.prisma.user.findUnique({
|
const friendRequest = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const receiver = await tx.user.findUnique({
|
||||||
where: { id: receiverId },
|
where: { id: receiverId },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -35,12 +47,19 @@ export class FriendsService {
|
|||||||
throw new NotFoundException('User not found');
|
throw new NotFoundException('User not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingFriendship = await this.areFriends(senderId, receiverId);
|
// Check for existing friendship using the transaction client
|
||||||
|
const existingFriendship = await tx.friendship.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: senderId,
|
||||||
|
friendId: receiverId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (existingFriendship) {
|
if (existingFriendship) {
|
||||||
throw new ConflictException('You are already friends with this user');
|
throw new ConflictException('You are already friends with this user');
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingRequest = await this.prisma.friendRequest.findFirst({
|
const existingRequest = await tx.friendRequest.findFirst({
|
||||||
where: {
|
where: {
|
||||||
OR: [
|
OR: [
|
||||||
{ senderId, receiverId },
|
{ senderId, receiverId },
|
||||||
@@ -65,13 +84,13 @@ export class FriendsService {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// If there's an existing request that is not pending (accepted or denied), delete it so a new one can be created
|
// If there's an existing request that is not pending (accepted or denied), delete it so a new one can be created
|
||||||
await this.prisma.friendRequest.delete({
|
await tx.friendRequest.delete({
|
||||||
where: { id: existingRequest.id },
|
where: { id: existingRequest.id },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const friendRequest = await this.prisma.friendRequest.create({
|
return await tx.friendRequest.create({
|
||||||
data: {
|
data: {
|
||||||
senderId,
|
senderId,
|
||||||
receiverId,
|
receiverId,
|
||||||
@@ -82,11 +101,19 @@ export class FriendsService {
|
|||||||
receiver: true,
|
receiver: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Friend request sent from ${senderId} to ${receiverId} (ID: ${friendRequest.id})`,
|
`Friend request sent from ${senderId} to ${receiverId} (ID: ${friendRequest.id})`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Emit event
|
||||||
|
const event: FriendRequestReceivedEvent = {
|
||||||
|
userId: receiverId,
|
||||||
|
friendRequest,
|
||||||
|
};
|
||||||
|
this.eventEmitter.emit(FriendEvents.REQUEST_RECEIVED, event);
|
||||||
|
|
||||||
return friendRequest;
|
return friendRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +210,13 @@ export class FriendsService {
|
|||||||
`Friend request ${requestId} accepted. Users ${friendRequest.senderId} and ${friendRequest.receiverId} are now friends`,
|
`Friend request ${requestId} accepted. Users ${friendRequest.senderId} and ${friendRequest.receiverId} are now friends`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Emit event
|
||||||
|
const event: FriendRequestAcceptedEvent = {
|
||||||
|
userId: friendRequest.senderId,
|
||||||
|
friendRequest: result,
|
||||||
|
};
|
||||||
|
this.eventEmitter.emit(FriendEvents.REQUEST_ACCEPTED, event);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,6 +261,13 @@ export class FriendsService {
|
|||||||
|
|
||||||
this.logger.log(`Friend request ${requestId} denied by user ${userId}`);
|
this.logger.log(`Friend request ${requestId} denied by user ${userId}`);
|
||||||
|
|
||||||
|
// Emit event
|
||||||
|
const event: FriendRequestDeniedEvent = {
|
||||||
|
userId: friendRequest.senderId,
|
||||||
|
friendRequest: result,
|
||||||
|
};
|
||||||
|
this.eventEmitter.emit(FriendEvents.REQUEST_DENIED, event);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,6 +309,13 @@ export class FriendsService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`User ${userId} unfriended user ${friendId}`);
|
this.logger.log(`User ${userId} unfriended user ${friendId}`);
|
||||||
|
|
||||||
|
// Emit event
|
||||||
|
const event: UnfriendedEvent = {
|
||||||
|
userId: friendId,
|
||||||
|
friendId: userId,
|
||||||
|
};
|
||||||
|
this.eventEmitter.emit(FriendEvents.UNFRIENDED, event);
|
||||||
}
|
}
|
||||||
|
|
||||||
async areFriends(userId: string, friendId: string): Promise<boolean> {
|
async areFriends(userId: string, friendId: string): Promise<boolean> {
|
||||||
|
|||||||
@@ -58,6 +58,58 @@ export class UsersService {
|
|||||||
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a user or creates one if they don't exist.
|
||||||
|
* Optimized to minimize database operations:
|
||||||
|
* - If user exists: Returns immediately (1 read, 0 writes)
|
||||||
|
* - If user missing: Creates user (1 read, 1 write)
|
||||||
|
*
|
||||||
|
* Unlike createFromToken, this method does NOT update lastLoginAt for existing users.
|
||||||
|
*
|
||||||
|
* @param createDto - User data
|
||||||
|
* @returns The user entity
|
||||||
|
*/
|
||||||
|
async findOrCreate(createDto: CreateUserFromTokenDto): Promise<User> {
|
||||||
|
// 1. Try to find the user (Read)
|
||||||
|
const existingUser = await this.prisma.user.findUnique({
|
||||||
|
where: { keycloakSub: createDto.keycloakSub },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. If found, return immediately without update
|
||||||
|
if (existingUser) {
|
||||||
|
return existingUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. If not found, create (Write)
|
||||||
|
// We handle creation directly here to avoid a second read that createFromToken would do
|
||||||
|
const roles = createDto.roles || [];
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
this.logger.log(`Creating new user from token: ${createDto.keycloakSub}`);
|
||||||
|
|
||||||
|
// Use upsert to handle race conditions safely
|
||||||
|
return await this.prisma.user.upsert({
|
||||||
|
where: { keycloakSub: createDto.keycloakSub },
|
||||||
|
update: {
|
||||||
|
email: createDto.email,
|
||||||
|
name: createDto.name,
|
||||||
|
username: createDto.username,
|
||||||
|
picture: createDto.picture,
|
||||||
|
roles,
|
||||||
|
lastLoginAt: now,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
keycloakSub: createDto.keycloakSub,
|
||||||
|
email: createDto.email,
|
||||||
|
name: createDto.name,
|
||||||
|
username: createDto.username,
|
||||||
|
picture: createDto.picture,
|
||||||
|
roles,
|
||||||
|
lastLoginAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new user or syncs/tracks login for existing users.
|
* Creates a new user or syncs/tracks login for existing users.
|
||||||
* This method is called automatically during authentication flow.
|
* This method is called automatically during authentication flow.
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
|
import { CursorPositionDto } from '../dto/cursor-position.dto';
|
||||||
import { Test, TestingModule } from '@nestjs/testing';
|
import { Test, TestingModule } from '@nestjs/testing';
|
||||||
import { StateGateway } from './state.gateway';
|
import { StateGateway } from './state.gateway';
|
||||||
import { AuthenticatedSocket } from '../../types/socket';
|
import { AuthenticatedSocket } from '../../types/socket';
|
||||||
import { AuthService } from '../../auth/auth.service';
|
import { AuthService } from '../../auth/auth.service';
|
||||||
import { JwtVerificationService } from '../../auth/services/jwt-verification.service';
|
import { JwtVerificationService } from '../../auth/services/jwt-verification.service';
|
||||||
|
import { PrismaService } from '../../database/prisma.service';
|
||||||
import { FriendsService } from '../../friends/friends.service';
|
|
||||||
|
|
||||||
interface MockSocket extends Partial<AuthenticatedSocket> {
|
interface MockSocket extends Partial<AuthenticatedSocket> {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -25,24 +25,25 @@ describe('StateGateway', () => {
|
|||||||
let mockLoggerDebug: jest.SpyInstance;
|
let mockLoggerDebug: jest.SpyInstance;
|
||||||
let mockLoggerWarn: jest.SpyInstance;
|
let mockLoggerWarn: jest.SpyInstance;
|
||||||
let mockServer: {
|
let mockServer: {
|
||||||
sockets: { sockets: { size: number } };
|
sockets: { sockets: { size: number; get: jest.Mock } };
|
||||||
to: jest.Mock;
|
to: jest.Mock;
|
||||||
};
|
};
|
||||||
let mockAuthService: Partial<AuthService>;
|
let mockAuthService: Partial<AuthService>;
|
||||||
let mockJwtVerificationService: Partial<JwtVerificationService>;
|
let mockJwtVerificationService: Partial<JwtVerificationService>;
|
||||||
let mockFriendsService: Partial<FriendsService>;
|
let mockPrismaService: Partial<PrismaService>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
mockServer = {
|
mockServer = {
|
||||||
sockets: {
|
sockets: {
|
||||||
sockets: {
|
sockets: {
|
||||||
size: 5,
|
size: 5,
|
||||||
|
get: jest.fn(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
to: jest.fn().mockReturnValue({
|
to: jest.fn().mockReturnValue({
|
||||||
emit: jest.fn(),
|
emit: jest.fn(),
|
||||||
}),
|
}),
|
||||||
} as any;
|
};
|
||||||
|
|
||||||
mockAuthService = {
|
mockAuthService = {
|
||||||
syncUserFromToken: jest.fn().mockResolvedValue({
|
syncUserFromToken: jest.fn().mockResolvedValue({
|
||||||
@@ -59,8 +60,10 @@ describe('StateGateway', () => {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
mockFriendsService = {
|
mockPrismaService = {
|
||||||
getFriends: jest.fn().mockResolvedValue([]),
|
friendship: {
|
||||||
|
findMany: jest.fn().mockResolvedValue([]),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
@@ -71,7 +74,7 @@ describe('StateGateway', () => {
|
|||||||
provide: JwtVerificationService,
|
provide: JwtVerificationService,
|
||||||
useValue: mockJwtVerificationService,
|
useValue: mockJwtVerificationService,
|
||||||
},
|
},
|
||||||
{ provide: FriendsService, useValue: mockFriendsService },
|
{ provide: PrismaService, useValue: mockPrismaService },
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
@@ -204,7 +207,7 @@ describe('StateGateway', () => {
|
|||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||||
(gateway as any).userSocketMap.set('friend-1', 'friend-socket-id');
|
(gateway as any).userSocketMap.set('friend-1', 'friend-socket-id');
|
||||||
|
|
||||||
const data = { x: 100, y: 200, isDrawing: false };
|
const data: CursorPositionDto = { x: 100, y: 200 };
|
||||||
|
|
||||||
gateway.handleCursorReportPosition(
|
gateway.handleCursorReportPosition(
|
||||||
mockClient as unknown as AuthenticatedSocket,
|
mockClient as unknown as AuthenticatedSocket,
|
||||||
@@ -232,7 +235,7 @@ describe('StateGateway', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Don't set up userSocketMap - friend is not online
|
// Don't set up userSocketMap - friend is not online
|
||||||
const data = { x: 100, y: 200, isDrawing: false };
|
const data: CursorPositionDto = { x: 100, y: 200 };
|
||||||
|
|
||||||
gateway.handleCursorReportPosition(
|
gateway.handleCursorReportPosition(
|
||||||
mockClient as unknown as AuthenticatedSocket,
|
mockClient as unknown as AuthenticatedSocket,
|
||||||
@@ -253,7 +256,7 @@ describe('StateGateway', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const data = { x: 100, y: 200, isDrawing: false };
|
const data: CursorPositionDto = { x: 100, y: 200 };
|
||||||
|
|
||||||
gateway.handleCursorReportPosition(
|
gateway.handleCursorReportPosition(
|
||||||
mockClient as unknown as AuthenticatedSocket,
|
mockClient as unknown as AuthenticatedSocket,
|
||||||
@@ -273,7 +276,7 @@ describe('StateGateway', () => {
|
|||||||
id: 'client1',
|
id: 'client1',
|
||||||
data: {},
|
data: {},
|
||||||
};
|
};
|
||||||
const data = { x: 100, y: 200 };
|
const data: CursorPositionDto = { x: 100, y: 200 };
|
||||||
|
|
||||||
expect(() => {
|
expect(() => {
|
||||||
gateway.handleCursorReportPosition(
|
gateway.handleCursorReportPosition(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Logger, Inject, forwardRef } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
OnGatewayConnection,
|
OnGatewayConnection,
|
||||||
OnGatewayDisconnect,
|
OnGatewayDisconnect,
|
||||||
@@ -8,16 +8,22 @@ import {
|
|||||||
WebSocketServer,
|
WebSocketServer,
|
||||||
WsException,
|
WsException,
|
||||||
} from '@nestjs/websockets';
|
} from '@nestjs/websockets';
|
||||||
|
import { OnEvent } from '@nestjs/event-emitter';
|
||||||
|
|
||||||
import type { Server } from 'socket.io';
|
import type { Server } from 'socket.io';
|
||||||
import type { AuthenticatedSocket } from '../../types/socket';
|
import type { AuthenticatedSocket } from '../../types/socket';
|
||||||
import { AuthService } from '../../auth/auth.service';
|
import { AuthService } from '../../auth/auth.service';
|
||||||
import { JwtVerificationService } from '../../auth/services/jwt-verification.service';
|
import { JwtVerificationService } from '../../auth/services/jwt-verification.service';
|
||||||
import { CursorPositionDto } from '../dto/cursor-position.dto';
|
import { CursorPositionDto } from '../dto/cursor-position.dto';
|
||||||
import {
|
import { PrismaService } from '../../database/prisma.service';
|
||||||
FriendRequestWithRelations,
|
|
||||||
FriendsService,
|
import { FriendEvents } from '../../friends/events/friend.events';
|
||||||
} from '../../friends/friends.service';
|
import type {
|
||||||
|
FriendRequestReceivedEvent,
|
||||||
|
FriendRequestAcceptedEvent,
|
||||||
|
FriendRequestDeniedEvent,
|
||||||
|
UnfriendedEvent,
|
||||||
|
} from '../../friends/events/friend.events';
|
||||||
|
|
||||||
const WS_EVENT = {
|
const WS_EVENT = {
|
||||||
CURSOR_REPORT_POSITION: 'cursor-report-position',
|
CURSOR_REPORT_POSITION: 'cursor-report-position',
|
||||||
@@ -40,14 +46,14 @@ export class StateGateway
|
|||||||
{
|
{
|
||||||
private readonly logger = new Logger(StateGateway.name);
|
private readonly logger = new Logger(StateGateway.name);
|
||||||
private userSocketMap: Map<string, string> = new Map();
|
private userSocketMap: Map<string, string> = new Map();
|
||||||
|
private lastBroadcastMap: Map<string, number> = new Map();
|
||||||
|
|
||||||
@WebSocketServer() io: Server;
|
@WebSocketServer() io: Server;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
private readonly jwtVerificationService: JwtVerificationService,
|
private readonly jwtVerificationService: JwtVerificationService,
|
||||||
@Inject(forwardRef(() => FriendsService))
|
private readonly prisma: PrismaService,
|
||||||
private readonly friendsService: FriendsService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
afterInit() {
|
afterInit() {
|
||||||
@@ -91,8 +97,11 @@ export class StateGateway
|
|||||||
this.userSocketMap.set(user.id, client.id);
|
this.userSocketMap.set(user.id, client.id);
|
||||||
client.data.userId = user.id;
|
client.data.userId = user.id;
|
||||||
|
|
||||||
// Initialize friends cache
|
// Initialize friends cache using Prisma directly
|
||||||
const friends = await this.friendsService.getFriends(user.id);
|
const friends = await this.prisma.friendship.findMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
select: { friendId: true },
|
||||||
|
});
|
||||||
client.data.friends = new Set(friends.map((f) => f.friendId));
|
client.data.friends = new Set(friends.map((f) => f.friendId));
|
||||||
|
|
||||||
const { sockets } = this.io.sockets;
|
const { sockets } = this.io.sockets;
|
||||||
@@ -119,6 +128,7 @@ export class StateGateway
|
|||||||
const currentSocketId = this.userSocketMap.get(userId);
|
const currentSocketId = this.userSocketMap.get(userId);
|
||||||
if (currentSocketId === client.id) {
|
if (currentSocketId === client.id) {
|
||||||
this.userSocketMap.delete(userId);
|
this.userSocketMap.delete(userId);
|
||||||
|
this.lastBroadcastMap.delete(userId);
|
||||||
|
|
||||||
// Notify friends that this user has disconnected
|
// Notify friends that this user has disconnected
|
||||||
const friends = client.data.friends;
|
const friends = client.data.friends;
|
||||||
@@ -138,6 +148,7 @@ export class StateGateway
|
|||||||
for (const [uid, socketId] of this.userSocketMap.entries()) {
|
for (const [uid, socketId] of this.userSocketMap.entries()) {
|
||||||
if (socketId === client.id) {
|
if (socketId === client.id) {
|
||||||
this.userSocketMap.delete(uid);
|
this.userSocketMap.delete(uid);
|
||||||
|
this.lastBroadcastMap.delete(uid);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -171,6 +182,13 @@ export class StateGateway
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const lastBroadcast = this.lastBroadcastMap.get(currentUserId) || 0;
|
||||||
|
if (now - lastBroadcast < 100) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.lastBroadcastMap.set(currentUserId, now);
|
||||||
|
|
||||||
// Broadcast to online friends
|
// Broadcast to online friends
|
||||||
const friends = client.data.friends;
|
const friends = client.data.friends;
|
||||||
if (friends) {
|
if (friends) {
|
||||||
@@ -189,10 +207,9 @@ export class StateGateway
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
emitFriendRequestReceived(
|
@OnEvent(FriendEvents.REQUEST_RECEIVED)
|
||||||
userId: string,
|
handleFriendRequestReceived(payload: FriendRequestReceivedEvent) {
|
||||||
friendRequest: FriendRequestWithRelations,
|
const { userId, friendRequest } = payload;
|
||||||
) {
|
|
||||||
const socketId = this.userSocketMap.get(userId);
|
const socketId = this.userSocketMap.get(userId);
|
||||||
if (socketId) {
|
if (socketId) {
|
||||||
this.io.to(socketId).emit(WS_EVENT.FRIEND_REQUEST_RECEIVED, {
|
this.io.to(socketId).emit(WS_EVENT.FRIEND_REQUEST_RECEIVED, {
|
||||||
@@ -211,20 +228,14 @@ export class StateGateway
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
emitFriendRequestAccepted(
|
@OnEvent(FriendEvents.REQUEST_ACCEPTED)
|
||||||
userId: string,
|
handleFriendRequestAccepted(payload: FriendRequestAcceptedEvent) {
|
||||||
friendRequest: FriendRequestWithRelations,
|
const { userId, friendRequest } = payload;
|
||||||
) {
|
|
||||||
const socketId = this.userSocketMap.get(userId);
|
|
||||||
if (socketId) {
|
|
||||||
// Update cache for the user accepting (userId here is the sender of the original request)
|
|
||||||
// Wait, in friends.controller: acceptFriendRequest returns the request.
|
|
||||||
// emitFriendRequestAccepted is called with friendRequest.senderId (the one who sent the request).
|
|
||||||
// The one who accepted is friendRequest.receiverId.
|
|
||||||
|
|
||||||
// We need to update cache for BOTH users if they are online.
|
const socketId = this.userSocketMap.get(userId);
|
||||||
|
|
||||||
// 1. Update cache for the user who sent the request (userId / friendRequest.senderId)
|
// 1. Update cache for the user who sent the request (userId / friendRequest.senderId)
|
||||||
|
if (socketId) {
|
||||||
const senderSocket = this.io.sockets.sockets.get(
|
const senderSocket = this.io.sockets.sockets.get(
|
||||||
socketId,
|
socketId,
|
||||||
) as AuthenticatedSocket;
|
) as AuthenticatedSocket;
|
||||||
@@ -232,17 +243,6 @@ export class StateGateway
|
|||||||
senderSocket.data.friends.add(friendRequest.receiverId);
|
senderSocket.data.friends.add(friendRequest.receiverId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Update cache for the user who accepted the request (friendRequest.receiverId)
|
|
||||||
const receiverSocketId = this.userSocketMap.get(friendRequest.receiverId);
|
|
||||||
if (receiverSocketId) {
|
|
||||||
const receiverSocket = this.io.sockets.sockets.get(
|
|
||||||
receiverSocketId,
|
|
||||||
) as AuthenticatedSocket;
|
|
||||||
if (receiverSocket && receiverSocket.data.friends) {
|
|
||||||
receiverSocket.data.friends.add(friendRequest.senderId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.io.to(socketId).emit(WS_EVENT.FRIEND_REQUEST_ACCEPTED, {
|
this.io.to(socketId).emit(WS_EVENT.FRIEND_REQUEST_ACCEPTED, {
|
||||||
id: friendRequest.id,
|
id: friendRequest.id,
|
||||||
friend: {
|
friend: {
|
||||||
@@ -257,12 +257,22 @@ export class StateGateway
|
|||||||
`Emitted friend request accepted notification to user ${userId}`,
|
`Emitted friend request accepted notification to user ${userId}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Update cache for the user who accepted the request (friendRequest.receiverId)
|
||||||
|
const receiverSocketId = this.userSocketMap.get(friendRequest.receiverId);
|
||||||
|
if (receiverSocketId) {
|
||||||
|
const receiverSocket = this.io.sockets.sockets.get(
|
||||||
|
receiverSocketId,
|
||||||
|
) as AuthenticatedSocket;
|
||||||
|
if (receiverSocket && receiverSocket.data.friends) {
|
||||||
|
receiverSocket.data.friends.add(friendRequest.senderId);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
emitFriendRequestDenied(
|
@OnEvent(FriendEvents.REQUEST_DENIED)
|
||||||
userId: string,
|
handleFriendRequestDenied(payload: FriendRequestDeniedEvent) {
|
||||||
friendRequest: FriendRequestWithRelations,
|
const { userId, friendRequest } = payload;
|
||||||
) {
|
|
||||||
const socketId = this.userSocketMap.get(userId);
|
const socketId = this.userSocketMap.get(userId);
|
||||||
if (socketId) {
|
if (socketId) {
|
||||||
this.io.to(socketId).emit(WS_EVENT.FRIEND_REQUEST_DENIED, {
|
this.io.to(socketId).emit(WS_EVENT.FRIEND_REQUEST_DENIED, {
|
||||||
@@ -281,17 +291,14 @@ export class StateGateway
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
emitUnfriended(userId: string, friendId: string) {
|
@OnEvent(FriendEvents.UNFRIENDED)
|
||||||
const socketId = this.userSocketMap.get(userId);
|
handleUnfriended(payload: UnfriendedEvent) {
|
||||||
if (socketId) {
|
const { userId, friendId } = payload;
|
||||||
// Update cache for the user being unfriended (userId)
|
|
||||||
// Wait, emitUnfriended is called with (friendId, user.id) in controller.
|
|
||||||
// So userId here is the friendId (the one being removed from friend list of the initiator).
|
|
||||||
// friendId here is the initiator (user.id).
|
|
||||||
|
|
||||||
// We need to update cache for BOTH users.
|
const socketId = this.userSocketMap.get(userId);
|
||||||
|
|
||||||
// 1. Update cache for the user receiving the notification (userId)
|
// 1. Update cache for the user receiving the notification (userId)
|
||||||
|
if (socketId) {
|
||||||
const socket = this.io.sockets.sockets.get(
|
const socket = this.io.sockets.sockets.get(
|
||||||
socketId,
|
socketId,
|
||||||
) as AuthenticatedSocket;
|
) as AuthenticatedSocket;
|
||||||
@@ -299,6 +306,12 @@ export class StateGateway
|
|||||||
socket.data.friends.delete(friendId);
|
socket.data.friends.delete(friendId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.io.to(socketId).emit(WS_EVENT.UNFRIENDED, {
|
||||||
|
friendId,
|
||||||
|
});
|
||||||
|
this.logger.debug(`Emitted unfriended notification to user ${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Update cache for the user initiating the unfriend (friendId)
|
// 2. Update cache for the user initiating the unfriend (friendId)
|
||||||
const initiatorSocketId = this.userSocketMap.get(friendId);
|
const initiatorSocketId = this.userSocketMap.get(friendId);
|
||||||
if (initiatorSocketId) {
|
if (initiatorSocketId) {
|
||||||
@@ -309,22 +322,5 @@ export class StateGateway
|
|||||||
initiatorSocket.data.friends.delete(userId);
|
initiatorSocket.data.friends.delete(userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.io.to(socketId).emit(WS_EVENT.UNFRIENDED, {
|
|
||||||
friendId,
|
|
||||||
});
|
|
||||||
this.logger.debug(`Emitted unfriended notification to user ${userId}`);
|
|
||||||
} else {
|
|
||||||
// If the notified user is offline, we still need to update the initiator's cache if they are online
|
|
||||||
const initiatorSocketId = this.userSocketMap.get(friendId);
|
|
||||||
if (initiatorSocketId) {
|
|
||||||
const initiatorSocket = this.io.sockets.sockets.get(
|
|
||||||
initiatorSocketId,
|
|
||||||
) as AuthenticatedSocket;
|
|
||||||
if (initiatorSocket && initiatorSocket.data.friends) {
|
|
||||||
initiatorSocket.data.friends.delete(userId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user