diff --git a/apps/backend/apps/client/src/app.module.ts b/apps/backend/apps/client/src/app.module.ts index e7fe311671..259ff8b3e4 100644 --- a/apps/backend/apps/client/src/app.module.ts +++ b/apps/backend/apps/client/src/app.module.ts @@ -1,7 +1,8 @@ import { MailerModule } from '@nestjs-modules/mailer' +import { BullModule } from '@nestjs/bullmq' import { CacheModule } from '@nestjs/cache-manager' import { Module, type OnApplicationBootstrap } from '@nestjs/common' -import { ConfigModule } from '@nestjs/config' +import { ConfigModule, ConfigService } from '@nestjs/config' import { APP_FILTER, APP_GUARD, HttpAdapterHost } from '@nestjs/core' import type { Server } from 'http' import { OpenTelemetryModule } from 'nestjs-otel' @@ -12,6 +13,7 @@ import { ClientExceptionFilter } from '@libs/exception' import { openTelemetryModuleOption } from '@libs/instrumentation' import { pinoLoggerModuleOption } from '@libs/logger' import { PrismaModule } from '@libs/prisma' +import { RedisModule } from '@libs/redis' import { AnnouncementModule } from './announcement/announcement.module' import { AppController } from './app.controller' import { AppService } from './app.service' @@ -55,7 +57,20 @@ import { WorkbookModule } from './workbook/workbook.module' NotificationModule, LoggerModule.forRoot(pinoLoggerModuleOption), OpenTelemetryModule.forRoot(openTelemetryModuleOption), - StudyModule + StudyModule, + RedisModule, + BullModule.forRootAsync({ + inject: [ConfigService], + useFactory: (configService: ConfigService) => ({ + connection: { + host: configService.get('REDIS_HOST'), + port: configService.get('REDIS_PORT'), + password: configService.get('REDIS_PASSWORD'), + db: 1 + }, + prefix: 'bull-client' + }) + }) ], controllers: [AppController], providers: [ diff --git a/apps/backend/apps/client/src/main.ts b/apps/backend/apps/client/src/main.ts index 5655d3ec21..92ff5eeb5d 100644 --- a/apps/backend/apps/client/src/main.ts +++ b/apps/backend/apps/client/src/main.ts @@ -2,11 +2,13 @@ import { ValidationPipe } from '@nestjs/common' import { NestFactory } from '@nestjs/core' import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger' import cookieParser from 'cookie-parser' +import type Redis from 'ioredis' import Instrumentation from '@libs/instrumentation' +import { REDIS_CLIENT, RedisIoAdapter } from '@libs/redis' /** - TODO: 가능하면 `app.module.ts`에서 PrismaModule 처럼 IoC 리팩터링 필요 - 지금은 Instrumentation.start가 AppModule 보다 먼저 실행되어야 함 + TODO: 가능하면 `app.module.ts`에서 PrismaModule 처럼 IoC 리팩터링 필요 + 지금은 Instrumentation.start가 AppModule 보다 먼저 실행되어야 함 자세한 이유는 [이 comment](https://github.com/skkuding/codedang/pull/2705#discussion_r2072945663)를 참고해주세요. */ const bootstrap = async () => { @@ -29,6 +31,13 @@ const bootstrap = async () => { app.useGlobalInterceptors(new LoggerErrorInterceptor()) app.useGlobalPipes(new ValidationPipe({ whitelist: true })) app.use(cookieParser()) + + // Redis WebSocket adapter 연결 + const redisClient = app.get(REDIS_CLIENT) + const redisIoAdapter = new RedisIoAdapter(app, redisClient) + await redisIoAdapter.connectToRedis() // redis 연결 + app.useWebSocketAdapter(redisIoAdapter) // adapter 등록 + if (process.env.APP_ENV !== 'production' && process.env.APP_ENV !== 'stage') { app.enableCors({ origin: 'http://localhost:5525', diff --git a/apps/backend/apps/client/src/study/interface/study-socket.interface.ts b/apps/backend/apps/client/src/study/interface/study-socket.interface.ts new file mode 100644 index 0000000000..0d8b7c631e --- /dev/null +++ b/apps/backend/apps/client/src/study/interface/study-socket.interface.ts @@ -0,0 +1,31 @@ +// Room +export interface RoomState { + endAt: number + leaderId: number | null +} + +export interface RoomMember { + userId: number + userName: string + joinedAt: number +} + +// Socket Response +export interface SocketResponse { + success: boolean + message?: string + data?: T + code?: string +} + +export interface JoinResponse { + members: RoomMember[] + endAt: number + remainMs: number + leaderId: number | null +} + +// PayLoad +export interface JoinPayload { + groupId: number +} diff --git a/apps/backend/apps/client/src/study/study-room.processor.ts b/apps/backend/apps/client/src/study/study-room.processor.ts new file mode 100644 index 0000000000..baf14ee61b --- /dev/null +++ b/apps/backend/apps/client/src/study/study-room.processor.ts @@ -0,0 +1,41 @@ +import { Processor, WorkerHost } from '@nestjs/bullmq' +import { Logger } from '@nestjs/common' +import { Job } from 'bullmq' +import { + STUDY_ROOM_QUEUE, + JOB_RECONNECT_EXPIRE, + JOB_ROOM_REMINDER, + JOB_ROOM_END +} from '@libs/constants' +import { StudyRoomService } from './study-room.service' + +@Processor(STUDY_ROOM_QUEUE) +export class StudyRoomProcessor extends WorkerHost { + private readonly logger = new Logger(StudyRoomProcessor.name) + + constructor(private readonly studyRoomService: StudyRoomService) { + super() + } + + async process(job: Job): Promise { + switch (job.name) { + case JOB_ROOM_REMINDER: + await this.studyRoomService.reminderRoom(job.data.groupId) + break + + case JOB_ROOM_END: + await this.studyRoomService.endRoom(job.data.groupId) + break + + case JOB_RECONNECT_EXPIRE: + await this.studyRoomService.handleReconnectExpiry(job.data) + break + + default: + this.logger.warn( + `Unknown job name: ${job.name}`, + JSON.stringify(job.data) + ) + } + } +} diff --git a/apps/backend/apps/client/src/study/study-room.service.ts b/apps/backend/apps/client/src/study/study-room.service.ts new file mode 100644 index 0000000000..df1afb41a7 --- /dev/null +++ b/apps/backend/apps/client/src/study/study-room.service.ts @@ -0,0 +1,605 @@ +import { InjectQueue } from '@nestjs/bullmq' +import { Inject, Injectable, Logger } from '@nestjs/common' +import type { Queue } from 'bullmq' +import type Redis from 'ioredis' +import type { Server, Socket } from 'socket.io' +import { + roomKey, + reconnectKey, + membersKey, + STUDY_ROOM_QUEUE, + JOB_RECONNECT_EXPIRE, + JOB_ROOM_REMINDER, + JOB_ROOM_END, + RECONNECT_GRACE_SEC, + REMINDER_BEFORE_END_MS, + BUFFER_SEC +} from '@libs/constants' +import { REDIS_CLIENT } from '@libs/redis' +import type { + RoomState, + RoomMember, + JoinResponse, + SocketResponse +} from './interface/study-socket.interface' +import { StudyService } from './study.service' + +@Injectable() +export class StudyRoomService { + private readonly logger = new Logger(StudyRoomService.name) + private server!: Server + + constructor( + @Inject(REDIS_CLIENT) private readonly redis: Redis, + @InjectQueue(STUDY_ROOM_QUEUE) private readonly queue: Queue, + private readonly studyService: StudyService + ) {} + + setServer(server: Server): void { + this.server = server + } + + // Room + /** + * 사용자 WebSocket을 스터디 룸에 입장시킵니다. + * + * 1. Redis 기준으로 사용자의 그룹 입장 가능 여부 확인 + * 2. 사용자의 스터디 그룹 참여 권한 검증 + * 3. Redis에서 룸 상태 생성 또는 기존 상태 조회하고 유효성 검증 + * 4. Socket.IO 룸 입장 및 Redis 멤버 정보 등록 + * - 신규: HSETNX로 중복 입장 차단 + * - 재연결: reconnectKey DEL 성공 여부로 중복 재연결 차단 + * 5. 입장 케이스에 따라 이벤트 emit + * - 첫 번째 입장이면 room:started 이벤트 브로드캐스트 + * - 재연결 입장이면 room:participantReconnected 이벤트 emit + * - 그 외 입장이면 room:participantsChanged 이벤트 emit + * 6. 클라이언트에 현재 룸 상태 반환 + * + * @param {Socket} client 연결된 Socket 인스턴스 + * @param {number} groupId 스터디 그룹 ID + * @returns 입장 처리 결과와 현재 룸 상태 + */ + async join( + client: Socket, + groupId: number + ): Promise> { + const userId: number = client.data.user.id + + const check = await this.checkJoinable(groupId, userId) + if (!check.ok) { + this.logger.warn( + `입장 거부: userId=${userId}, groupId=${groupId}, reason=${check.message}` + ) + return { success: false, message: check.message } + } + + let membership + try { + membership = await this.studyService.validateJoinableStudyGroup( + groupId, + userId + ) + } catch { + return { success: false, message: '입장 권한이 없습니다.' } + } + + const now = Date.now() + + if (membership.endTime.getTime() <= now) { + return { success: false, message: '세션이 이미 종료되었습니다.' } + } + + const { state, isFirst } = await this.initOrGetRoom( + groupId, + userId, + membership.isLeader, + now, + membership.endTime + ) + + if (!state) + return { success: false, message: '룸 상태 가져오지 못했습니다.' } + + const isRecovered = check.isRecovered ?? false + const entered = await this.enterRoom( + client, + groupId, + userId, + membership.userName, + now, + isRecovered + ) + if (!entered) + return { success: false, message: '이미 이 룸에 참여 중입니다.' } + + if (isFirst) + await this.redis.expireat( + membersKey(groupId), + Math.ceil((membership.endTime.getTime() + 60_000) / 1000) + ) + + const members = await this.getMembers(groupId) + + if (isFirst) await this.onFirstJoin(groupId, state, members) + else if (isRecovered) this.onReconnectJoin(client, groupId, members) + else this.onSubsequentJoin(client, groupId, members) + + return { + success: true, + data: { + members, + endAt: state.endAt, + remainMs: Math.max(0, state.endAt - Date.now()), + leaderId: state.leaderId + } + } + } + + /** + * 사용자가 해당 스터디 그룹 룸에 입장 가능한지 확인합니다. + * + * Redis의 멤버 목록과 reconnectKey 존재 여부를 조회하여 + * 이미 멤버로 등록되어 있으면서 재연결 대기 상태가 아니면 중복 입장으로 판단합니다. + * + * @param {number} groupId 스터디 그룹 ID + * @param {number} userId 사용자 ID + * @returns 입장 가능 여부와 재연결 여부 + */ + private async checkJoinable( + groupId: number, + userId: number + ): Promise<{ + ok: boolean + isRecovered?: boolean + code?: string + message?: string + }> { + const [memberExists, reconnectRaw] = await Promise.all([ + this.redis.hexists(membersKey(groupId), String(userId)), + this.redis.get(reconnectKey(groupId, userId)) + ]) + + const isRecovered = !!reconnectRaw + + if (memberExists && !isRecovered) + return { ok: false, message: '이미 이 룸에 참여 중입니다.' } + + return { ok: true, isRecovered } + } + + /** + * 스터디 룸을 생성하거나 기존 상태를 조회합니다. + * + * 1. 현재 시간을 기준으로 RoomState 상태를 생성 + * 2. Redis에 SET NX로 roomKey 저장 시도 + * 3. 2의 결과 여부에 따라 State를 설정하여 isFirst와 함께 반환 + * - 성공이면, 첫 번째 입장자로 -> 생성한 activeState 사용 + * - 실패이면, 이후 입장자로 -> Redis에서 기존 상태를 조회하여 사용 + * - 리더 입장이고 leaderId가 미설정 상태이면 KEEPTTL로 업데이트 + * + * @param {number} groupId 스터디 그룹 ID + * @param {number} userId 현재 사용자 ID + * @param {number} now 현재 시간 + * @returns 룸 상태값, 사용자의 입장이 첫번째인지 + */ + private async initOrGetRoom( + groupId: number, + userId: number, + isLeader: boolean, + now: number, + endTime: Date + ): Promise<{ state: RoomState | null; isFirst: boolean }> { + const activeState: RoomState = { + endAt: endTime.getTime(), + leaderId: isLeader ? userId : null + } + + const isFirst = !!(await this.redis.set( + roomKey(groupId), + JSON.stringify(activeState), + 'PX', + endTime.getTime() - now + 60_000, + 'NX' + )) + + if (isFirst) return { state: activeState, isFirst } + + const state = await this.getRoomState(groupId) + + if (isLeader && state && state.leaderId === null) { + const updated = { ...state, leaderId: userId } + await this.redis.set(roomKey(groupId), JSON.stringify(updated), 'KEEPTTL') + return { state: updated, isFirst } + } + + return { state, isFirst } + } + + /** + * 검증이 완료된 사용자를 실제 스터디 룸에 입장 처리합니다. + * + * 1. 재연결 입장인 경우 reconnectKey를 원자적으로 삭제 + * - 삭제 실패(0)이면 다른 요청이 이미 재연결 처리 중으로 보고 false 반환 + * - 삭제 성공(1)이면 예약된 재연결 만료 작업 취소 + * 2. socket.data에 사용자 메타데이터를 저장 + * 3. 신규 입장인 경우 HSETNX로 원자적으로 멤버 등록 + * - 이미 존재하면(0) 중복 입장으로 판단 → false 반환 + * - 재연결 입장인 경우 HSET으로 멤버 정보 갱신 + * 4. Redis 멤버 목록에 현재 소켓 정보 등록 + * + * @param {Socket} client 연결된 Socket 인스턴스 + * @param {number} groupId 스터디 그룹 ID + * @param {number} userId 사용자 ID + * @param {string} userName 사용자 이름과 리더 여부 + * @param {number} now 입장 시각(ms) + * @param {boolean} isRecovered 재연결 복구 여부 + * @returns 입장 성공 여부 (false면 중복 입장 또는 재연결 경쟁 패배) + */ + private async enterRoom( + client: Socket, + groupId: number, + userId: number, + userName: string, + now: number, + isRecovered: boolean + ): Promise { + if (isRecovered) { + const deleted = await this.redis.del(reconnectKey(groupId, userId)) + if (!deleted) return false + + const job = await this.queue.getJob( + `${JOB_RECONNECT_EXPIRE}:${groupId}:${userId}` + ) + await job?.remove() + } + + client.data.room = { + userName, + groupId + } + + const member: RoomMember = { + userId, + userName, + joinedAt: now + } + + if (isRecovered) { + const existing = await this.redis.hget( + membersKey(groupId), + String(userId) + ) + + if (!existing) return false + + const { joinedAt: originalJoinedAt } = JSON.parse(existing) as RoomMember + + await this.redis.hset( + membersKey(groupId), + String(userId), + JSON.stringify({ ...member, joinedAt: originalJoinedAt }) + ) + } else { + const added = await this.redis.hsetnx( + membersKey(groupId), + String(userId), + JSON.stringify(member) + ) + if (!added) return false + } + + client.join(roomKey(groupId)) + return true + } + + /** + * 첫 번째 사용자가 룸을 생성했을 때 타이머를 시작하고 룸 전체에 room:started 브로드캐스트합니다. + * + * @param {number} groupId 스터디 그룹 ID + * @param {RoomState} state 룸 상태 + * @param {RoomMember[]} members 현재 참여자 목록 + */ + private async onFirstJoin( + groupId: number, + state: RoomState, + members: RoomMember[] + ): Promise { + await this.scheduleJobs(groupId, state.endAt) + + this.logger.log( + `룸 시작: groupId=${groupId}, endAt=${new Date(state.endAt).toISOString()}` + ) + + this.server.to(roomKey(groupId)).emit('room:started', { + endAt: state.endAt, + members + }) + } + + /** + * 두 번째 이후 사용자가 들어왔을 때 기존 참여자에게 새로운 참여자 입장을 알립니다. + * + * @param {Socket} client 연결된 Socket 인스턴스 + * @param {number} groupId 스터디 그룹 ID + * @param {RoomMember[]} members 최신 참여자 목록 + */ + private onSubsequentJoin( + client: Socket, + groupId: number, + members: RoomMember[] + ): void { + client.to(roomKey(groupId)).emit('room:participantsChanged', { members }) + } + + /** + * 재연결된 사용자가 들어왔을 때, 기존 참여자들에게 재연결 완료 사실을 알립니다. + * + * @param {Socket} client 연결된 Socket 인스턴스 + * @param {number} groupId 스터디 그룹 ID + * @param {RoomMember[]} members 최신 참여자 목록 + */ + private onReconnectJoin( + client: Socket, + groupId: number, + members: RoomMember[] + ): void { + this.server.to(roomKey(groupId)).emit('room:participantReconnected', { + userId: client.data.user.id, + userName: client.data.room.userName, + members + }) + } + + /** + * 세션 시작 시 종료 리마인드 작업과 종료 작업을 BullMQ에 예약합니다. + * + * - delayToEnd <= 0이면 즉시 endRoom 호출 (이미 종료됐어야 하는 룸 처리) + * - room-reminder:{groupId}: 세션 종료 REMINDER_BEFORE_END_MS 전 실행 + * - room-end:{groupId}: 세션 종료 시점에 실행 + * + * @param {number} groupId 스터디 그룹 ID + * @param {number} endAt + */ + private async scheduleJobs(groupId: number, endAt: number): Promise { + const now = Date.now() + const delayToEnd = endAt - now + const delayToReminder = delayToEnd - REMINDER_BEFORE_END_MS + + const reminderJobId = `${JOB_ROOM_REMINDER}:${groupId}` + const endJobId = `${JOB_ROOM_END}:${groupId}` + + const [existingReminder, existingEnd] = await Promise.all([ + this.queue.getJob(reminderJobId), + this.queue.getJob(endJobId) + ]) + await Promise.all([existingReminder?.remove(), existingEnd?.remove()]) + + if (delayToEnd <= 0) { + await this.endRoom(groupId) + return + } + + if (delayToReminder > 0) { + await this.queue.add( + JOB_ROOM_REMINDER, + { groupId }, + { + jobId: reminderJobId, + delay: delayToReminder, + removeOnComplete: true, + removeOnFail: true + } + ) + } + + await this.queue.add( + JOB_ROOM_END, + { groupId }, + { + jobId: endJobId, + delay: delayToEnd, + removeOnComplete: true, + removeOnFail: true + } + ) + } + + /** + * 사용자가 스터디 룸을 정상적으로 퇴장합니다. + * + * 1. client.data.room을 초기화하여 룸 데이터 제거 + * 2. roomKey 존재 여부 확인 (없으면 이미 처리한 것으로 판단하고 중단) + * 3. Redis Hash에서 해당 사용자의 멤버 정보 삭제 + * 4. Socket.io 룸에서 클라이언트를 제외(leave) + * 5. 룸에 남아있는 참여자들에게 업데이트된 멤버 목록 브로드캐스트 + * + * @param {Socket} client 연결된 Socket 인스턴스 + * @returns 퇴장 성공 여부 + */ + async leave(client: Socket): Promise { + const userId = client.data.user.id + const groupId = client.data.room.groupId + + client.data.room = undefined + + const roomExists = await this.redis.exists(roomKey(groupId)) + if (!roomExists) return { success: true } + + await this.removeMember(groupId, userId) + client.leave(roomKey(groupId)) + + const members = await this.getMembers(groupId) + + this.server + .to(roomKey(groupId)) + .emit('room:participantsChanged', { members }) + + return { success: true } + } + + /** + * 웹소켓 연결이 끊어졌을 때 호출되며, 재연결 유예 기간(Grace Period)을 부여합니다. + * + * 1. roomKey 존재 여부 확인 (없으면 이미 처리한 것으로 판단하고 중단) + * 2. Redis에 재연결 대기 상태 키 저장 + * 3. BullMQ 큐에 예약된 기존 만료 작업(Job)이 있다면 제거 후, 새로운 만료 작업 예약 + * 4. 동일한 룸에 있는 다른 참여자들에게 해당 사용자가 재연결 중임을 알림 + * + * @param {Socket} client 연결된 Socket 인스턴스 + */ + async handleDisconnect(client: Socket): Promise { + const userId = client.data.user?.id + + const groupId = client.data.room?.groupId + if (!userId || !groupId) return + + const roomExists = await this.redis.exists(roomKey(groupId)) + if (!roomExists) return + + const userName = client.data.room.userName + client.data.room = undefined + + await this.redis.set( + reconnectKey(groupId, userId), + '1', + 'EX', + RECONNECT_GRACE_SEC + BUFFER_SEC + ) + + const jobId = `${JOB_RECONNECT_EXPIRE}:${groupId}:${userId}` + const existingJob = await this.queue.getJob(jobId) + await existingJob?.remove() + + await this.queue.add( + JOB_RECONNECT_EXPIRE, + { userId, groupId }, + { + jobId, + delay: RECONNECT_GRACE_SEC * 1000, + removeOnComplete: true, + removeOnFail: true + } + ) + + this.server + .to(roomKey(groupId)) + .emit('room:participantReconnecting', { userId, userName }) + } + + // BullMQ Processor 호출 + /** + * 세션 종료 전 리마인드 이벤트를 룸 전체에 전송합니다. + * BullMQ Processor에서 예약된 room-reminder 작업이 실행될 때 호출됩니다. + * + * @param {number} groupId 스터디 그룹 ID + */ + async reminderRoom(groupId: number): Promise { + const state = await this.getRoomState(groupId) + if (!state) return + + this.server.to(roomKey(groupId)).emit('room:reminder', { + groupId, + endAt: state.endAt, + remainMs: Math.max(0, state.endAt - Date.now()) + }) + } + + /** + * 세션 종료 시 룸을 종료하고 서버 상태를 정리합니다. + * + * 1. 룸 전체에 room:ended 이벤트를 전송 + * 2. 재연결 대기 중인 모든 멤버의 reconnectKey를 Redis에서 삭제 + * 3. Redis의 룸 상태(roomKey)와 멤버 목록(membersKey)을 삭제 + * 4. 룸에 남아 있는 모든 소켓의 WebSocket 연결을 끊음 (disconnectSockets) + * — 멀티 노드 환경에서도 각 노드가 자신의 소켓을 직접 disconnect 처리함 + * + * @param {number} groupId 스터디 그룹 ID + */ + async endRoom(groupId: number): Promise { + this.server.to(roomKey(groupId)).emit('room:ended', { groupId }) + + const members = await this.getMembers(groupId) + await Promise.all( + members.map((m) => this.redis.del(reconnectKey(groupId, m.userId))) + ) + + await Promise.all([ + this.redis.del(roomKey(groupId)), + this.redis.del(membersKey(groupId)) + ]) + + await this.server.in(roomKey(groupId)).disconnectSockets() + } + + /** + * 재연결 유예 시간이 만료되었을 때 사용자를 완전히 퇴장 처리합니다. + * BullMQ Processor에서 호출됩니다. + * + * 1. reconnectKey를 원자적으로 삭제 (DEL 반환값으로 복구 여부 판단) + * - 반환값이 0이면 이미 재연결로 복구된 것 → 중단 + * - 반환값이 1이면 유예 시간 만료 → 완전 퇴장 처리 + * 2. Redis 멤버 목록에서 해당 사용자 삭제 + * 3. 룸에 남아있는 참여자들에게 최신 멤버 목록 브로드캐스트 + * + * @param payload + * @param {number} payload.userId 사용자 ID + * @param {number} payload.groupId 스터디 그룹 ID + */ + async handleReconnectExpiry(payload: { + userId: number + groupId: number + }): Promise { + const { userId, groupId } = payload + + const deleted = await this.redis.del(reconnectKey(groupId, userId)) + if (!deleted) return + + await this.removeMember(groupId, userId) + + const members = await this.getMembers(groupId) + + this.server + .to(roomKey(groupId)) + .emit('room:participantsChanged', { members }) + } + + // Redis + /** + * Redis에서 룸 상태를 조회합니다. + * + * @param {number} groupId 스터디 그룹 ID + * @returns 룸 상태값 | null + */ + private async getRoomState(groupId: number): Promise { + const raw = await this.redis.get(roomKey(groupId)) + return raw ? (JSON.parse(raw) as RoomState) : null + } + + /** + * Redis Hash에서 특정 사용자의 멤버 정보를 삭제합니다. + * + * @param {number} groupId 스터디 그룹 ID + * @param {number} userId 사용자 ID + */ + private async removeMember(groupId: number, userId: number): Promise { + await this.redis.hdel(membersKey(groupId), String(userId)) + } + + /** + * Redis Hash에 저장된 해당 스터디 룸의 모든 멤버를 조회합니다. + * + * @param {number} groupId 스터디 그룹 ID + * @returns 스터디 룸에 있는 모든 멤버들 + */ + async getMembers(groupId: number): Promise { + const raw = await this.redis.hgetall(membersKey(groupId)) + return Object.values(raw).flatMap((v) => { + try { + return [JSON.parse(v) as RoomMember] + } catch { + this.logger.error(`멤버 데이터 파싱 실패`) + return [] + } + }) + } +} diff --git a/apps/backend/apps/client/src/study/study.gateway.ts b/apps/backend/apps/client/src/study/study.gateway.ts new file mode 100644 index 0000000000..2d67dad190 --- /dev/null +++ b/apps/backend/apps/client/src/study/study.gateway.ts @@ -0,0 +1,77 @@ +import { Logger, UseGuards } from '@nestjs/common' +import { + WebSocketGateway, + WebSocketServer, + OnGatewayInit, + OnGatewayDisconnect, + SubscribeMessage, + ConnectedSocket, + MessageBody, + WsException +} from '@nestjs/websockets' +import type { Server, Socket } from 'socket.io' +import { JwtAuthGuard } from '@libs/auth' +import type { JoinPayload } from './interface/study-socket.interface' +import { StudyRoomService } from './study-room.service' + +@UseGuards(JwtAuthGuard) +@WebSocketGateway({ + namespace: 'study', + cors: { + origin: + process.env.APP_ENV === 'production' || process.env.APP_ENV === 'stage' + ? true + : [ + /^https:\/\/\d+\.preview\.codedang\.com$/, // preview + /^http:\/\/localhost(:\d+)?$/ // local + ], + credentials: true + } +}) +export class StudyGateway implements OnGatewayInit, OnGatewayDisconnect { + @WebSocketServer() + server: Server + + private readonly logger = new Logger(StudyGateway.name) + + constructor(private readonly studyRoomService: StudyRoomService) {} + + afterInit(server: Server) { + this.studyRoomService.setServer(server) + this.logger.log('StudyGateway initialized') + } + + async handleDisconnect(client: Socket) { + await this.studyRoomService.handleDisconnect(client) + } + + // Room + @SubscribeMessage('room:join') + async handleJoin( + @ConnectedSocket() client: Socket, + @MessageBody() payload: JoinPayload + ) { + const groupId = this.parseGroupId(payload?.groupId) + return this.studyRoomService.join(client, groupId) + } + + @SubscribeMessage('room:leave') + async handleLeave(@ConnectedSocket() client: Socket) { + this.assertInRoom(client) + return this.studyRoomService.leave(client) + } + + private parseGroupId(rawGroupId: unknown): number { + const groupId = Number(rawGroupId) + + if (!Number.isInteger(groupId) || groupId <= 0) + throw new WsException('groupId must be a positive integer') + + return groupId + } + + private assertInRoom(client: Socket): void { + if (!client.data.room?.groupId) + throw new WsException('룸에 참여 중이 아닙니다.') + } +} diff --git a/apps/backend/apps/client/src/study/study.module.ts b/apps/backend/apps/client/src/study/study.module.ts index a926826c84..6a56bd44c4 100644 --- a/apps/backend/apps/client/src/study/study.module.ts +++ b/apps/backend/apps/client/src/study/study.module.ts @@ -1,11 +1,19 @@ +import { BullModule } from '@nestjs/bullmq' import { Module } from '@nestjs/common' import { RolesModule } from '@libs/auth' +import { StudyRoomProcessor } from './study-room.processor' +import { STUDY_ROOM_QUEUE } from './study-room.service' +import { StudyRoomService } from './study-room.service' import { StudyController } from './study.controller' +import { StudyGateway } from './study.gateway' import { StudyService } from './study.service' @Module({ controllers: [StudyController], - providers: [StudyService], - imports: [RolesModule] + providers: [StudyService, StudyGateway, StudyRoomService, StudyRoomProcessor], + imports: [ + RolesModule, + BullModule.registerQueue({ name: STUDY_ROOM_QUEUE }) // 'study-room + ] }) export class StudyModule {} diff --git a/apps/backend/apps/client/src/study/study.service.ts b/apps/backend/apps/client/src/study/study.service.ts index 823ff038b5..e26af381d6 100644 --- a/apps/backend/apps/client/src/study/study.service.ts +++ b/apps/backend/apps/client/src/study/study.service.ts @@ -441,4 +441,55 @@ export class StudyService { } }) } + + /** + * 사용자가 해당 스터디 그룹 소속인지 검증하고, 회원 정보 반환합니다. + * + * @param {number} groupId 그룹ID + * @param {number} userId 유저ID + * @returns 사용자의 ID, 이름, 리더 여부, 만료 시간을 포함한 객체 + * @throws {ForbiddenAccessException} 아래와 같은 경우 발생합니다. + * - 사용자가 해당 그룹에 속해있지 않을 경우 발생 + * - 해당 스터디 그룹의 endTime이 없을 경우 발생 + */ + async validateJoinableStudyGroup(groupId: number, userId: number) { + const membership = await this.prisma.userGroup.findUnique({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + userId_groupId: { + userId, + groupId + } + }, + select: { + userId: true, + isGroupLeader: true, + user: { + select: { username: true } + }, + group: { + select: { + studyInfo: { + select: { endTime: true } + } + } + } + } + }) + + if (!membership) { + throw new ForbiddenAccessException('User ID is not in the group') + } + + if (!membership.group.studyInfo?.endTime) { + throw new ForbiddenAccessException('Study group session is not available') + } + + return { + userId: membership.userId, + userName: membership.user.username, + isLeader: membership.isGroupLeader, + endTime: membership.group.studyInfo.endTime + } + } } diff --git a/apps/backend/libs/auth/src/jwt/jwt-auth.guard.ts b/apps/backend/libs/auth/src/jwt/jwt-auth.guard.ts index ab8cb253f7..cef98384f0 100644 --- a/apps/backend/libs/auth/src/jwt/jwt-auth.guard.ts +++ b/apps/backend/libs/auth/src/jwt/jwt-auth.guard.ts @@ -6,6 +6,7 @@ import { import { Reflector } from '@nestjs/core' import { type GqlContextType, GqlExecutionContext } from '@nestjs/graphql' import { AuthGuard } from '@nestjs/passport' +import { Socket } from 'socket.io' import { AUTH_NOT_NEEDED_KEY, USER_NULL_WHEN_AUTH_FAILED @@ -18,12 +19,19 @@ export class JwtAuthGuard extends AuthGuard('jwt') { } canActivate(context: ExecutionContext) { - const request = this.getRequest(context) const isAuthNotNeeded = this.reflector.getAllAndOverride( AUTH_NOT_NEEDED_KEY, [context.getHandler(), context.getClass()] ) - if (isAuthNotNeeded && !request.query.groupId) { + const request = this.getRequest(context) + let groupId = request.query?.groupId + + if (context.getType() === 'ws') { + const data = context.switchToWs().getData() + groupId = data?.groupId ?? groupId + } + + if (isAuthNotNeeded && !groupId) { return true } return super.canActivate(context) @@ -33,12 +41,38 @@ export class JwtAuthGuard extends AuthGuard('jwt') { if (context.getType() === 'graphql') { return GqlExecutionContext.create(context).getContext().req } + + // ws 컨텍스트 — handshake에서 JWT 토큰 추출 + if (context.getType() === 'ws') { + const client = context.switchToWs().getClient() + const handshake = client.handshake + + if (handshake.auth?.token) { + handshake.headers.authorization = `Bearer ${handshake.auth.token}` + } + + return handshake + } return super.getRequest(context) } handleRequest(err, user, info, context: ExecutionContext) { const request = this.getRequest(context) - const groupId = request.query.groupId + let groupId = request.query?.groupId + + if (context.getType() === 'ws') { + const client = context.switchToWs().getClient() + const data = context.switchToWs().getData() + groupId = data?.groupId ?? groupId + + if (err || !user) { + throw err || new UnauthorizedException() + } + client.data.user = user + + return user + } + const userNullWhenAuthFailed = this.reflector.getAllAndOverride( USER_NULL_WHEN_AUTH_FAILED, [context.getHandler(), context.getClass()] diff --git a/apps/backend/libs/constants/src/index.ts b/apps/backend/libs/constants/src/index.ts index 825dbb8be4..a2c24164ce 100644 --- a/apps/backend/libs/constants/src/index.ts +++ b/apps/backend/libs/constants/src/index.ts @@ -5,3 +5,4 @@ export * from './check.constants' export * from './submission.constants' export * from './argon2.constants' export * from './score-calculation.constants' +export * from './study-room.constants' diff --git a/apps/backend/libs/constants/src/study-room.constants.ts b/apps/backend/libs/constants/src/study-room.constants.ts new file mode 100644 index 0000000000..6d4b605cb9 --- /dev/null +++ b/apps/backend/libs/constants/src/study-room.constants.ts @@ -0,0 +1,20 @@ +// Redis Key +export const STUDY_KEY_PREFIX = 'study' +export const STUDY_ROOM_PREFIX = `${STUDY_KEY_PREFIX}:room` + +export const roomKey = (groupId: number) => `${STUDY_ROOM_PREFIX}:${groupId}` +export const membersKey = (groupId: number) => + `${STUDY_ROOM_PREFIX}:${groupId}:members` +export const reconnectKey = (groupId: number, userId: number) => + `${STUDY_ROOM_PREFIX}:${groupId}:reconnect:${userId}` + +// BullMQ Queue Job Names +export const STUDY_ROOM_QUEUE = 'study-room' +export const JOB_RECONNECT_EXPIRE = 'reconnect-expire' +export const JOB_ROOM_REMINDER = 'room-reminder' +export const JOB_ROOM_END = 'room-end' + +// 상수 +export const RECONNECT_GRACE_SEC = 30 // 30초 +export const REMINDER_BEFORE_END_MS = 10 * 60 * 1000 // 10분 +export const BUFFER_SEC = 10 // 10초 diff --git a/apps/backend/libs/redis/src/index.ts b/apps/backend/libs/redis/src/index.ts new file mode 100644 index 0000000000..3ae8bfff33 --- /dev/null +++ b/apps/backend/libs/redis/src/index.ts @@ -0,0 +1,2 @@ +export * from './redis.module' +export * from './redis-io.adapter' diff --git a/apps/backend/libs/redis/src/redis-io.adapter.ts b/apps/backend/libs/redis/src/redis-io.adapter.ts new file mode 100644 index 0000000000..a5893af92d --- /dev/null +++ b/apps/backend/libs/redis/src/redis-io.adapter.ts @@ -0,0 +1,30 @@ +import type { INestApplicationContext } from '@nestjs/common' +import { IoAdapter } from '@nestjs/platform-socket.io' +import { createAdapter } from '@socket.io/redis-adapter' +import type Redis from 'ioredis' +import type { ServerOptions } from 'socket.io' + +export class RedisIoAdapter extends IoAdapter { + private adapterConstructor: ReturnType + + constructor( + app: INestApplicationContext, + private readonly redisClient: Redis + ) { + super(app) + } + + async connectToRedis() { + const pubClient = this.redisClient + const subClient = pubClient.duplicate() + this.adapterConstructor = createAdapter(pubClient, subClient) + } + + createIOServer(port: number, options?: ServerOptions) { + const server = super.createIOServer(port, options) + if (this.adapterConstructor) { + server.adapter(this.adapterConstructor) + } + return server + } +} diff --git a/apps/backend/libs/redis/src/redis.module.ts b/apps/backend/libs/redis/src/redis.module.ts new file mode 100644 index 0000000000..b7e3a5fe23 --- /dev/null +++ b/apps/backend/libs/redis/src/redis.module.ts @@ -0,0 +1,41 @@ +// apps/backend/libs/redis/src/redis.module.ts +import { Global, Module } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import Redis from 'ioredis' + +export const REDIS_CLIENT = 'REDIS_CLIENT' + +@Global() +@Module({ + providers: [ + { + provide: REDIS_CLIENT, + useFactory: async (configService: ConfigService) => { + const host = configService.get('REDIS_HOST') + const port = configService.get('REDIS_PORT') + const password = configService.get('REDIS_PASSWORD') + const db = 1 + + if (!host || !port) { + throw new Error('Redis host and port must be configured') + } + + const redis = new Redis({ host, port, password, db }) + + try { + const result = await redis.ping() + if (result !== 'PONG') { + throw new Error('Unexpected ping response') + } + } catch (error) { + throw new Error(`Redis connection failed: ${error.message}`) + } + + return redis + }, + inject: [ConfigService] + } + ], + exports: [REDIS_CLIENT] +}) +export class RedisModule {} diff --git a/apps/backend/libs/redis/tsconfig.lib.json b/apps/backend/libs/redis/tsconfig.lib.json new file mode 100644 index 0000000000..1f4c603050 --- /dev/null +++ b/apps/backend/libs/redis/tsconfig.lib.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "outDir": "../../dist/libs/redis" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] +} diff --git a/apps/backend/package.json b/apps/backend/package.json index 255ef4cc88..84e3a00f61 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -39,7 +39,9 @@ "@nestjs/jwt": "^11.0.0", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.1.6", + "@nestjs/platform-socket.io": "^11.1.17", "@nestjs/swagger": "^11.2.0", + "@nestjs/websockets": "^11.1.17", "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^2.0.1", "@opentelemetry/exporter-logs-otlp-grpc": "^0.203.0", @@ -59,6 +61,7 @@ "@opentelemetry/semantic-conventions": "^1.36.0", "@prisma/client": "^6.13.0", "@prisma/instrumentation": "~6.13.0", + "@socket.io/redis-adapter": "^8.3.0", "archiver": "^7.0.1", "argon2": "^0.44.0", "axios": "^1.11.0", @@ -80,6 +83,7 @@ "graphql-upload": "^17.0.0", "handlebars": "^4.7.8", "he": "^1.2.0", + "ioredis": "^5.10.1", "keyv": "^5.5.0", "nestjs-otel": "^6.2.0", "nestjs-pino": "^4.4.0", @@ -92,9 +96,11 @@ "pino-http": "^10.5.0", "pino-pretty": "^11.3.0", "prisma-graphql-type-decimal": "^3.0.1", + "redis": "^5.11.0", "reflect-metadata": "^0.2.1", "rxjs": "^7.8.2", "sanitize-filename": "^1.6.3", + "socket.io": "^4.8.3", "sql-formatter": "^15.6.6", "unzipper": "^0.12.3", "web-push": "^3.6.7", diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index 9d66c721ea..22d537cdb7 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -31,7 +31,8 @@ "@libs/types": ["./libs/types/src/index.ts"], "@libs/instrumentation": ["./libs/instrumentation.ts"], "@libs/storage": ["./libs/storage/src/index.ts"], - "@libs/amqp": ["./libs/amqp/src/index.ts"] + "@libs/amqp": ["./libs/amqp/src/index.ts"], + "@libs/redis": ["./libs/redis/src/index.ts"] } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 088af18b4f..149a782c01 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -138,7 +138,7 @@ importers: version: 4.0.2(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) '@nestjs/core': specifier: ^11.1.6 - version: 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/event-emitter': specifier: ^3.0.1 version: 3.0.1(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6) @@ -154,9 +154,15 @@ importers: '@nestjs/platform-express': specifier: ^11.1.6 version: 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6) + '@nestjs/platform-socket.io': + specifier: ^11.1.17 + version: 11.1.17(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.17)(rxjs@7.8.2) '@nestjs/swagger': specifier: ^11.2.0 version: 11.2.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2) + '@nestjs/websockets': + specifier: ^11.1.17 + version: 11.1.17(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(@nestjs/platform-socket.io@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@opentelemetry/api': specifier: ^1.9.0 version: 1.9.0 @@ -214,6 +220,9 @@ importers: '@prisma/instrumentation': specifier: ~6.13.0 version: 6.13.0(@opentelemetry/api@1.9.0) + '@socket.io/redis-adapter': + specifier: ^8.3.0 + version: 8.3.0(socket.io-adapter@2.5.6) archiver: specifier: ^7.0.1 version: 7.0.1 @@ -277,6 +286,9 @@ importers: he: specifier: ^1.2.0 version: 1.2.0 + ioredis: + specifier: ^5.10.1 + version: 5.10.1 keyv: specifier: ^5.5.0 version: 5.5.0 @@ -313,6 +325,9 @@ importers: prisma-graphql-type-decimal: specifier: ^3.0.1 version: 3.0.1(@prisma/client@6.13.0(prisma@6.13.0(typescript@5.9.2))(typescript@5.9.2))(graphql@16.11.0) + redis: + specifier: ^5.11.0 + version: 5.11.0 reflect-metadata: specifier: ^0.2.1 version: 0.2.2 @@ -322,6 +337,9 @@ importers: sanitize-filename: specifier: ^1.6.3 version: 1.6.3 + socket.io: + specifier: ^4.8.3 + version: 4.8.3 sql-formatter: specifier: ^15.6.6 version: 15.6.6 @@ -3047,8 +3065,8 @@ packages: '@types/node': optional: true - '@ioredis/commands@1.3.0': - resolution: {integrity: sha512-M/T6Zewn7sDaBQEqIZ8Rb+i9y8qfGmq+5SDFSf9sA2lUZTmdDLVdOiQaeDp+Q4wElZ9HG1GAX5KhDaidp6LQsQ==} + '@ioredis/commands@1.5.1': + resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} '@isaacs/balanced-match@4.0.1': resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} @@ -3506,6 +3524,13 @@ packages: '@nestjs/common': ^11.0.0 '@nestjs/core': ^11.0.0 + '@nestjs/platform-socket.io@11.1.17': + resolution: {integrity: sha512-BSOAsENdmTtsnDL0hb4takbWzPy9WoPybjlM57ab3/rQgm0biMFYUupH2uzmCjmmIXJL/EFbAWznVl8xw2Sa6Q==} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/websockets': ^11.0.0 + rxjs: ^7.1.0 + '@nestjs/schematics@11.0.7': resolution: {integrity: sha512-t8dNYYMwEeEsrlwc2jbkfwCfXczq4AeNEgx1KVQuJ6wYibXk0ZbXbPdfp8scnEAaQv1grpncNV5gWgzi7ZwbvQ==} peerDependencies: @@ -3541,6 +3566,18 @@ packages: '@nestjs/platform-express': optional: true + '@nestjs/websockets@11.1.17': + resolution: {integrity: sha512-YbwQ0QfVj0lxkKQhdIIgk14ZSVWDqGk1J8nNSN6SLjf36sVv58Ma5ro+dtQua8wj3l2Ub7JJCVFixEhKtYc/rQ==} + peerDependencies: + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + '@nestjs/platform-socket.io': ^11.0.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + rxjs: ^7.1.0 + peerDependenciesMeta: + '@nestjs/platform-socket.io': + optional: true + '@next/bundle-analyzer@15.5.18': resolution: {integrity: sha512-v5/UNFwYbBlRQg/Bt+wU65XuxCxPu1AeCOI6s4s6Cludsj7FdVO9E9uzr7GIj8OykSrYtGuEQAUX0Ulje8W2yw==} @@ -4677,10 +4714,43 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + '@redis/bloom@5.11.0': + resolution: {integrity: sha512-KYiVilAhAFN3057afUb/tfYJpsEyTkQB+tQcn5gVVA7DgcNOAj8lLxe4j8ov8BF6I9C1Fe/kwlbuAICcTMX8Lw==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.11.0 + + '@redis/client@5.11.0': + resolution: {integrity: sha512-GHoprlNQD51Xq2Ztd94HHV94MdFZQ3CVrpA04Fz8MVoHM0B7SlbmPEVIjwTbcv58z8QyjnrOuikS0rWF03k5dQ==} + engines: {node: '>= 18'} + peerDependencies: + '@node-rs/xxhash': ^1.1.0 + peerDependenciesMeta: + '@node-rs/xxhash': + optional: true + '@redis/client@5.8.0': resolution: {integrity: sha512-ywZjKGoSSAECGYOd9bJpws6d4867SN686obUWT/sRmo1c/Q8V+jWyInvlqwKa0BOvTHHwYeB2WFUEvd6PADeOQ==} engines: {node: '>= 18'} + '@redis/json@5.11.0': + resolution: {integrity: sha512-1iAy9kAtcD0quB21RbPTbUqqy+T2Uu2JxucwE+B4A+VaDbIRvpZR6DMqV8Iqaws2YxJYB3GC5JVNzPYio2ErUg==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.11.0 + + '@redis/search@5.11.0': + resolution: {integrity: sha512-g1l7f3Rnyk/xI99oGHIgWHSKFl45Re5YTIcO8j/JE8olz389yUFyz2+A6nqVy/Zi031VgPDWscbbgOk8hlhZ3g==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.11.0 + + '@redis/time-series@5.11.0': + resolution: {integrity: sha512-TWFeOcU4xkj0DkndnOyhtxvX1KWD+78UHT3XX3x3XRBUGWeQrKo3jqzDsZwxbggUgf9yLJr/akFHXru66X5UQA==} + engines: {node: '>= 18'} + peerDependencies: + '@redis/client': ^5.11.0 + '@remirror/core-constants@3.0.0': resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==} @@ -5121,6 +5191,15 @@ packages: resolution: {integrity: sha512-mYqtQXPmrwvUljaHyGxYUIIRI3qjBTEb/f5QFi3A6VlxhpmZd5mWXn9W+qUkf2pVE1Hv3SqxefiZOPGdxmO64A==} engines: {node: '>=18.0.0'} + '@socket.io/component-emitter@3.1.2': + resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + + '@socket.io/redis-adapter@8.3.0': + resolution: {integrity: sha512-ly0cra+48hDmChxmIpnESKrc94LjRL80TEmZVscuQ/WWkRP81nNj8W8cCGMqbI4L6NCuAaPRSzZF1a9GlAxxnA==} + engines: {node: '>=10.0.0'} + peerDependencies: + socket.io-adapter: ^2.5.4 + '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} @@ -5778,6 +5857,9 @@ packages: '@types/cookies@0.9.1': resolution: {integrity: sha512-E/DPgzifH4sM1UMadJMWd6mO2jOd4g1Ejwzx8/uRCDpJis1IrlyQEcGAYEomtAqRYmD5ORbNXMeI9U0RiVGZbg==} + '@types/cors@2.8.19': + resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + '@types/d3-array@3.2.1': resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==} @@ -6748,6 +6830,10 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + base64id@2.0.0: + resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} + engines: {node: ^4.5.0 || >= 5.9} + base64url@3.0.1: resolution: {integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==} engines: {node: '>=6.0.0'} @@ -7476,6 +7562,15 @@ packages: supports-color: optional: true + debug@4.3.7: + resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.4.1: resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} engines: {node: '>=6.0'} @@ -7757,6 +7852,14 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + engine.io-parser@5.2.3: + resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} + engines: {node: '>=10.0.0'} + + engine.io@6.6.6: + resolution: {integrity: sha512-U2SN0w3OpjFRVlrc17E6TMDmH58Xl9rai1MblNjAdwWp07Kk+llmzX0hjDpQdrDGzwmvOtgM5yI+meYX6iZ2xA==} + engines: {node: '>=10.2.0'} + enhanced-resolve@5.18.2: resolution: {integrity: sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==} engines: {node: '>=10.13.0'} @@ -8862,8 +8965,8 @@ packages: invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} - ioredis@5.7.0: - resolution: {integrity: sha512-NUcA93i1lukyXU+riqEyPtSEkyFq8tX90uL659J+qpCZ3rEdViB/APC58oAhIh3+bJln2hzdlZbBZsGNrlsR8g==} + ioredis@5.10.1: + resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} engines: {node: '>=12.22.0'} ipaddr.js@1.9.1: @@ -10263,6 +10366,9 @@ packages: resolution: {integrity: sha512-Ee/R3SyN4BuynXcnTaekmaVdbDAEiNrHqjQIA37mHU8G9pf7aaAD4ZX3XjBLo6rsdcxA/gtkcNYZLt30ACgynw==} engines: {node: '>=14.16'} + notepack.io@3.0.1: + resolution: {integrity: sha512-TKC/8zH5pXIAMVQio2TvVDTtPRX+DJPHDqjRbxogtFiByHyzKmy96RA0JtCQJ+WouyyL4A10xomQzgbUT+1jCg==} + npm-run-path@2.0.2: resolution: {integrity: sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==} engines: {node: '>=4'} @@ -10308,6 +10414,10 @@ packages: resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==} engines: {node: '>= 6'} + object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -11225,6 +11335,10 @@ packages: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} + redis@5.11.0: + resolution: {integrity: sha512-YwXjATVDT+AuxcyfOwZn046aml9jMlQPvU1VXIlLDVAExe0u93aTfPYSeRgG4p9Q/Jlkj+LXJ1XEoFV+j2JKcQ==} + engines: {node: '>= 18'} + reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} @@ -11647,6 +11761,17 @@ packages: snake-case@3.0.4: resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} + socket.io-adapter@2.5.6: + resolution: {integrity: sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==} + + socket.io-parser@4.2.6: + resolution: {integrity: sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==} + engines: {node: '>=10.0.0'} + + socket.io@4.8.3: + resolution: {integrity: sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==} + engines: {node: '>=10.2.0'} + sonic-boom@4.2.0: resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} @@ -12332,6 +12457,10 @@ packages: uid2@0.0.4: resolution: {integrity: sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==} + uid2@1.0.0: + resolution: {integrity: sha512-+I6aJUv63YAcY9n4mQreLUt0d4lvwkkopDNmpomkAUz0fAkEMV9pRWxN0EjhW1YfRhcuyHg2v3mwddCDW1+LFQ==} + engines: {node: '>= 4.0.0'} + uid@2.0.2: resolution: {integrity: sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==} engines: {node: '>=8'} @@ -15454,14 +15583,14 @@ snapshots: '@golevelup/nestjs-discovery@4.0.3(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)': dependencies: '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) lodash: 4.17.21 '@golevelup/nestjs-rabbitmq@5.7.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: '@golevelup/nestjs-discovery': 4.0.3(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6) '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) amqp-connection-manager: 4.1.14(amqplib@0.10.8) amqplib: 0.10.8 lodash: 4.17.21 @@ -16255,7 +16384,7 @@ snapshots: optionalDependencies: '@types/node': 22.17.1 - '@ioredis/commands@1.3.0': {} + '@ioredis/commands@1.5.1': {} '@isaacs/balanced-match@4.0.1': {} @@ -16576,7 +16705,7 @@ snapshots: dependencies: '@css-inline/css-inline': 0.14.1 '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) glob: 10.3.12 nodemailer: 6.10.1 optionalDependencies: @@ -16597,7 +16726,7 @@ snapshots: '@apollo/server': 4.12.2(graphql@16.11.0) '@apollo/server-plugin-landing-page-graphql-playground': 4.0.1(@apollo/server@4.12.2(graphql@16.11.0)) '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/graphql': 13.1.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(class-transformer@0.5.1)(class-validator@0.14.2)(graphql@16.11.0)(reflect-metadata@0.2.2) graphql: 16.11.0 iterall: 1.3.0 @@ -16613,21 +16742,21 @@ snapshots: '@nestjs/bull-shared@11.0.3(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)': dependencies: '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 '@nestjs/bullmq@11.0.3(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(bullmq@5.58.0)': dependencies: '@nestjs/bull-shared': 11.0.3(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6) '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) bullmq: 5.58.0 tslib: 2.8.1 '@nestjs/cache-manager@3.0.1(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(cache-manager@7.1.1)(keyv@5.5.0)(rxjs@7.8.2)': dependencies: '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) cache-manager: 7.1.1 keyv: 5.5.0 rxjs: 7.8.2 @@ -16685,7 +16814,7 @@ snapshots: lodash: 4.17.21 rxjs: 7.8.2 - '@nestjs/core@11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/core@11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nuxt/opencollective': 0.4.1 @@ -16698,11 +16827,12 @@ snapshots: uid: 2.0.2 optionalDependencies: '@nestjs/platform-express': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6) + '@nestjs/websockets': 11.1.17(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(@nestjs/platform-socket.io@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/event-emitter@3.0.1(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)': dependencies: '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) eventemitter2: 6.4.9 '@nestjs/graphql@13.1.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(class-transformer@0.5.1)(class-validator@0.14.2)(graphql@16.11.0)(reflect-metadata@0.2.2)': @@ -16711,7 +16841,7 @@ snapshots: '@graphql-tools/schema': 10.0.23(graphql@16.11.0) '@graphql-tools/utils': 10.8.6(graphql@16.11.0) '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/mapped-types': 2.1.0(@nestjs/common@11.1.6(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) chokidar: 4.0.3 fast-glob: 3.3.3 @@ -16756,7 +16886,7 @@ snapshots: '@nestjs/platform-express@11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)': dependencies: '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) cors: 2.8.5 express: 5.1.0 multer: 2.0.2 @@ -16765,6 +16895,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@nestjs/platform-socket.io@11.1.17(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.17)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/websockets': 11.1.17(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(@nestjs/platform-socket.io@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) + rxjs: 7.8.2 + socket.io: 4.8.3 + tslib: 2.8.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + '@nestjs/schematics@11.0.7(chokidar@4.0.3)(typescript@5.8.3)': dependencies: '@angular-devkit/core': 19.2.15(chokidar@4.0.3) @@ -16791,7 +16933,7 @@ snapshots: dependencies: '@microsoft/tsdoc': 0.15.1 '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/mapped-types': 2.1.0(@nestjs/common@11.1.6(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) js-yaml: 4.1.0 lodash: 4.17.21 @@ -16805,11 +16947,23 @@ snapshots: '@nestjs/testing@11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(@nestjs/platform-express@11.1.6)': dependencies: '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 optionalDependencies: '@nestjs/platform-express': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6) + '@nestjs/websockets@11.1.17(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6)(@nestjs/platform-socket.io@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) + iterare: 1.2.1 + object-hash: 3.0.0 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + optionalDependencies: + '@nestjs/platform-socket.io': 11.1.17(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.17)(rxjs@7.8.2) + '@next/bundle-analyzer@15.5.18': dependencies: webpack-bundle-analyzer: 4.10.1 @@ -17978,10 +18132,30 @@ snapshots: dependencies: react: 19.1.1 + '@redis/bloom@5.11.0(@redis/client@5.11.0)': + dependencies: + '@redis/client': 5.11.0 + + '@redis/client@5.11.0': + dependencies: + cluster-key-slot: 1.1.2 + '@redis/client@5.8.0': dependencies: cluster-key-slot: 1.1.2 + '@redis/json@5.11.0(@redis/client@5.11.0)': + dependencies: + '@redis/client': 5.11.0 + + '@redis/search@5.11.0(@redis/client@5.11.0)': + dependencies: + '@redis/client': 5.11.0 + + '@redis/time-series@5.11.0(@redis/client@5.11.0)': + dependencies: + '@redis/client': 5.11.0 + '@remirror/core-constants@3.0.0': {} '@repeaterjs/repeater@3.0.6': {} @@ -18503,6 +18677,17 @@ snapshots: '@smithy/types': 4.3.2 tslib: 2.8.1 + '@socket.io/component-emitter@3.1.2': {} + + '@socket.io/redis-adapter@8.3.0(socket.io-adapter@2.5.6)': + dependencies: + debug: 4.3.7 + notepack.io: 3.0.1 + socket.io-adapter: 2.5.6 + uid2: 1.0.0 + transitivePeerDependencies: + - supports-color + '@standard-schema/spec@1.0.0': {} '@surma/rollup-plugin-off-main-thread@2.2.3': @@ -19197,6 +19382,10 @@ snapshots: '@types/keygrip': 1.0.6 '@types/node': 22.17.1 + '@types/cors@2.8.19': + dependencies: + '@types/node': 22.17.1 + '@types/d3-array@3.2.1': {} '@types/d3-color@3.1.3': {} @@ -20346,6 +20535,8 @@ snapshots: base64-js@1.5.1: {} + base64id@2.0.0: {} + base64url@3.0.1: {} big-integer@1.6.52: {} @@ -20474,7 +20665,7 @@ snapshots: bullmq@5.58.0: dependencies: cron-parser: 4.9.0 - ioredis: 5.7.0 + ioredis: 5.10.1 msgpackr: 1.11.5 node-abort-controller: 3.1.1 semver: 7.7.2 @@ -21183,6 +21374,10 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.3.7: + dependencies: + ms: 2.1.3 + debug@4.4.1(supports-color@8.1.1): dependencies: ms: 2.1.3 @@ -21435,6 +21630,25 @@ snapshots: dependencies: once: 1.4.0 + engine.io-parser@5.2.3: {} + + engine.io@6.6.6: + dependencies: + '@types/cors': 2.8.19 + '@types/node': 22.17.1 + '@types/ws': 8.18.1 + accepts: 1.3.8 + base64id: 2.0.0 + cookie: 0.7.2 + cors: 2.8.5 + debug: 4.4.1(supports-color@8.1.1) + engine.io-parser: 5.2.3 + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + enhanced-resolve@5.18.2: dependencies: graceful-fs: 4.2.11 @@ -22835,9 +23049,9 @@ snapshots: dependencies: loose-envify: 1.4.0 - ioredis@5.7.0: + ioredis@5.10.1: dependencies: - '@ioredis/commands': 1.3.0 + '@ioredis/commands': 1.5.1 cluster-key-slot: 1.1.2 debug: 4.4.1(supports-color@8.1.1) denque: 2.1.0 @@ -24299,7 +24513,7 @@ snapshots: nestjs-otel@6.2.0(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.6): dependencies: '@nestjs/common': 11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.6(@nestjs/common@11.1.6(class-transformer@0.5.1)(class-validator@0.14.2)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.6)(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@opentelemetry/api': 1.9.0 '@opentelemetry/host-metrics': 0.35.5(@opentelemetry/api@1.9.0) response-time: 2.3.4 @@ -24434,6 +24648,8 @@ snapshots: normalize-url@8.0.2: {} + notepack.io@3.0.1: {} + npm-run-path@2.0.2: dependencies: path-key: 2.0.1 @@ -24503,6 +24719,8 @@ snapshots: object-hash@2.2.0: {} + object-hash@3.0.0: {} + object-inspect@1.13.4: {} object-keys@1.1.1: {} @@ -25574,6 +25792,16 @@ snapshots: dependencies: redis-errors: 1.2.0 + redis@5.11.0: + dependencies: + '@redis/bloom': 5.11.0(@redis/client@5.11.0) + '@redis/client': 5.11.0 + '@redis/json': 5.11.0(@redis/client@5.11.0) + '@redis/search': 5.11.0(@redis/client@5.11.0) + '@redis/time-series': 5.11.0(@redis/client@5.11.0) + transitivePeerDependencies: + - '@node-rs/xxhash' + reflect-metadata@0.2.2: {} reflect.getprototypeof@1.0.10: @@ -26091,6 +26319,36 @@ snapshots: dot-case: 3.0.4 tslib: 2.8.1 + socket.io-adapter@2.5.6: + dependencies: + debug: 4.4.1(supports-color@8.1.1) + ws: 8.18.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + socket.io-parser@4.2.6: + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.4.1(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + socket.io@4.8.3: + dependencies: + accepts: 1.3.8 + base64id: 2.0.0 + cors: 2.8.5 + debug: 4.4.1(supports-color@8.1.1) + engine.io: 6.6.6 + socket.io-adapter: 2.5.6 + socket.io-parser: 4.2.6 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + sonic-boom@4.2.0: dependencies: atomic-sleep: 1.0.0 @@ -26794,6 +27052,8 @@ snapshots: uid2@0.0.4: {} + uid2@1.0.0: {} + uid@2.0.2: dependencies: '@lukeed/csprng': 1.1.0