feat: 화상회의 내가 만든 방 편집 기능
진행자(생성자)가 방의 이름·예약 시각·참여 제한(allow-list)을 수정할 수 있다. - 백엔드: PATCH /meetings/rooms/:roomId (updateRoom, 진행자만, 이름 중복 검사). UpdateMeetingRoomDto(생성과 동일 필드). MeetingRoomResponse 에 allowedUsers 추가 (진행자에게만 노출 — 편집 폼 사전 채움용). User repo 주입해 ID→PublicUser 해석. - 프론트: useMeeting.updateRoom, 로비 각 방에 편집 버튼(진행자), MeetingCreateForm 를 편집 모드로 재사용(현재 값 프리필·중복검사 자기제외·저장 분기), MeetingIcon edit 추가. 검증: 백엔드 build, 프론트 type-check·lint 통과. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
import { CreateMeetingRoomDto } from './create-meeting-room.dto';
|
||||
|
||||
// 화상회의 방 수정 요청 — 생성과 동일한 필드(진행자가 이름·예약 시각·참여 제한을 수정).
|
||||
// 폼이 현재 값을 그대로 채워 전체 상태를 보내므로 별도 부분 갱신(Partial) 의미는 두지 않는다.
|
||||
export class UpdateMeetingRoomDto extends CreateMeetingRoomDto {}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
} from './meeting.service';
|
||||
import { CreateMeetingTokenDto } from './dto/create-meeting-token.dto';
|
||||
import { CreateMeetingRoomDto } from './dto/create-meeting-room.dto';
|
||||
import { UpdateMeetingRoomDto } from './dto/update-meeting-room.dto';
|
||||
|
||||
// 화상회의 컨트롤러 — 방 관리 + LiveKit 입장 토큰 발급(인증 필요).
|
||||
@ApiTags('화상회의')
|
||||
@@ -52,6 +54,17 @@ export class MeetingController {
|
||||
return this.meetingService.createRoom(user.id, dto);
|
||||
}
|
||||
|
||||
@Patch('rooms/:roomId')
|
||||
@ApiOperation({ summary: '회의 방 수정(진행자만)' })
|
||||
@ApiResponse({ status: 200, description: '수정 성공' })
|
||||
updateRoom(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Param('roomId', ParseUUIDPipe) roomId: string,
|
||||
@Body() dto: UpdateMeetingRoomDto,
|
||||
): Promise<MeetingRoomResponse> {
|
||||
return this.meetingService.updateRoom(user.id, roomId, dto);
|
||||
}
|
||||
|
||||
@Delete('rooms/:roomId')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: '회의 방 삭제(진행자만)' })
|
||||
|
||||
@@ -3,11 +3,13 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { MeetingController } from './meeting.controller';
|
||||
import { MeetingService } from './meeting.service';
|
||||
import { MeetingRoom } from './entities/meeting-room.entity';
|
||||
import { User } from '../user/entities/user.entity';
|
||||
|
||||
// 화상회의 모듈 — LiveKit 입장 토큰 발급 + 방/진행자(생성자) 보관.
|
||||
// ConfigModule 은 전역(app.module)에서 등록되어 있어 별도 import 불필요.
|
||||
// User repo 는 참여 제한(allow-list) 멤버를 진행자 편집 폼용으로 조회하는 데 사용한다.
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([MeetingRoom])],
|
||||
imports: [TypeOrmModule.forFeature([MeetingRoom, User])],
|
||||
controllers: [MeetingController],
|
||||
providers: [MeetingService],
|
||||
exports: [MeetingService],
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { HttpStatus, Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { AccessToken } from 'livekit-server-sdk';
|
||||
import { BusinessException } from '../../common/exceptions/business.exception';
|
||||
import { UserService, type PublicUser } from '../user/user.service';
|
||||
import { User } from '../user/entities/user.entity';
|
||||
import { MeetingRoom } from './entities/meeting-room.entity';
|
||||
import { CreateMeetingRoomDto } from './dto/create-meeting-room.dto';
|
||||
import { UpdateMeetingRoomDto } from './dto/update-meeting-room.dto';
|
||||
|
||||
// 회의 내 역할 — 진행자(방 생성자) / 일반 참여자
|
||||
export type MeetingRole = 'host' | 'participant';
|
||||
@@ -21,6 +22,8 @@ export interface MeetingRoomResponse {
|
||||
restricted: boolean; // 참여 제한(allow-list) 적용 여부
|
||||
isHost: boolean; // 현재 사용자가 진행자인지
|
||||
canJoin: boolean; // 현재 사용자가 입장 가능한지(공개 OR 진행자 OR 허용목록 포함)
|
||||
// 참여 허용 멤버 — 진행자에게만 노출(편집 폼 사전 채움용). 공개 방/비진행자는 null.
|
||||
allowedUsers: PublicUser[] | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -45,6 +48,8 @@ export class MeetingService {
|
||||
config: ConfigService,
|
||||
@InjectRepository(MeetingRoom)
|
||||
private readonly roomRepo: Repository<MeetingRoom>,
|
||||
@InjectRepository(User)
|
||||
private readonly userRepo: Repository<User>,
|
||||
) {
|
||||
this.apiKey = (config.get<string>('LIVEKIT_API_KEY') ?? '').trim();
|
||||
this.apiSecret = (config.get<string>('LIVEKIT_API_SECRET') ?? '').trim();
|
||||
@@ -180,7 +185,49 @@ export class MeetingService {
|
||||
relations: ['host'],
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
return rows.map((r) => this.toRoomResponse(r, userId));
|
||||
return Promise.all(rows.map((r) => this.toRoomResponse(r, userId)));
|
||||
}
|
||||
|
||||
// 방 수정 — 진행자(생성자)만 가능. 이름/예약 시각/참여 제한을 변경한다.
|
||||
async updateRoom(
|
||||
userId: string,
|
||||
roomId: string,
|
||||
dto: UpdateMeetingRoomDto,
|
||||
): Promise<MeetingRoomResponse> {
|
||||
const room = await this.roomRepo.findOne({
|
||||
where: { id: roomId },
|
||||
relations: ['host'],
|
||||
});
|
||||
if (!room) {
|
||||
throw new BusinessException(
|
||||
'RES_001',
|
||||
'방을 찾을 수 없습니다.',
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
if (room.host?.id !== userId) {
|
||||
throw new BusinessException(
|
||||
'AUTH_003',
|
||||
'방 수정 권한이 없습니다.',
|
||||
HttpStatus.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
// 이름 변경 시 다른 방과의 중복을 검사한다(자기 자신 제외).
|
||||
if (dto.name && dto.name !== room.name) {
|
||||
const dup = await this.roomRepo.findOne({ where: { name: dto.name } });
|
||||
if (dup && dup.id !== room.id) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'이미 존재하는 방 이름입니다. 다른 이름을 사용해 주세요.',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
room.name = dto.name;
|
||||
}
|
||||
room.scheduledAt = dto.scheduledAt ? new Date(dto.scheduledAt) : null;
|
||||
room.allowedUserIds = dto.allowedUserIds?.length ? dto.allowedUserIds : null;
|
||||
await this.roomRepo.save(room);
|
||||
return this.toRoomResponse(room, userId);
|
||||
}
|
||||
|
||||
// 방 삭제 — 진행자(생성자)만 가능.
|
||||
@@ -210,10 +257,10 @@ export class MeetingService {
|
||||
return !!(room.allowedUserIds && room.allowedUserIds.length > 0);
|
||||
}
|
||||
|
||||
private toRoomResponse(
|
||||
private async toRoomResponse(
|
||||
room: MeetingRoom,
|
||||
userId: string,
|
||||
): MeetingRoomResponse {
|
||||
): Promise<MeetingRoomResponse> {
|
||||
const isHost = room.host?.id === userId;
|
||||
const restricted = this.isRestricted(room);
|
||||
const canJoin =
|
||||
@@ -226,7 +273,23 @@ export class MeetingService {
|
||||
restricted,
|
||||
isHost,
|
||||
canJoin,
|
||||
// 편집 폼 사전 채움을 위해 진행자에게만 허용 멤버를 풀어서 내려준다(저장 순서 보존).
|
||||
allowedUsers:
|
||||
isHost && restricted
|
||||
? await this.resolveAllowedUsers(room.allowedUserIds!)
|
||||
: null,
|
||||
createdAt: room.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// 허용 사용자 ID 목록을 PublicUser 로 해석(저장된 순서 유지, 삭제된 사용자는 제외).
|
||||
private async resolveAllowedUsers(ids: string[]): Promise<PublicUser[]> {
|
||||
if (!ids.length) return [];
|
||||
const users = await this.userRepo.findBy({ id: In(ids) });
|
||||
const byId = new Map(users.map((u) => [u.id, u]));
|
||||
return ids
|
||||
.map((id) => byId.get(id))
|
||||
.filter((u): u is User => !!u)
|
||||
.map((u) => UserService.toPublic(u));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user