From bde4b561f34a0f257954958c6a85344998adb222 Mon Sep 17 00:00:00 2001 From: ttipo Date: Fri, 26 Jun 2026 16:15:31 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=99=94=EC=83=81=ED=9A=8C=EC=9D=98=20?= =?UTF-8?q?=EB=82=B4=EA=B0=80=20=EB=A7=8C=EB=93=A0=20=EB=B0=A9=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 진행자(생성자)가 방의 이름·예약 시각·참여 제한(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) --- .../meeting/dto/update-meeting-room.dto.ts | 5 ++ .../src/modules/meeting/meeting.controller.ts | 13 ++++ backend/src/modules/meeting/meeting.module.ts | 4 +- .../src/modules/meeting/meeting.service.ts | 71 +++++++++++++++++-- .../components/meeting/MeetingCreateForm.vue | 70 ++++++++++++------ .../src/components/meeting/MeetingIcon.vue | 4 ++ .../src/components/meeting/MeetingLobby.vue | 43 +++++++++-- frontend/src/composables/useMeeting.ts | 14 +++- frontend/src/types/meeting.ts | 6 ++ 9 files changed, 199 insertions(+), 31 deletions(-) create mode 100644 backend/src/modules/meeting/dto/update-meeting-room.dto.ts diff --git a/backend/src/modules/meeting/dto/update-meeting-room.dto.ts b/backend/src/modules/meeting/dto/update-meeting-room.dto.ts new file mode 100644 index 0000000..6b366f2 --- /dev/null +++ b/backend/src/modules/meeting/dto/update-meeting-room.dto.ts @@ -0,0 +1,5 @@ +import { CreateMeetingRoomDto } from './create-meeting-room.dto'; + +// 화상회의 방 수정 요청 — 생성과 동일한 필드(진행자가 이름·예약 시각·참여 제한을 수정). +// 폼이 현재 값을 그대로 채워 전체 상태를 보내므로 별도 부분 갱신(Partial) 의미는 두지 않는다. +export class UpdateMeetingRoomDto extends CreateMeetingRoomDto {} diff --git a/backend/src/modules/meeting/meeting.controller.ts b/backend/src/modules/meeting/meeting.controller.ts index c805eb3..e6b46a5 100644 --- a/backend/src/modules/meeting/meeting.controller.ts +++ b/backend/src/modules/meeting/meeting.controller.ts @@ -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 { + return this.meetingService.updateRoom(user.id, roomId, dto); + } + @Delete('rooms/:roomId') @HttpCode(HttpStatus.NO_CONTENT) @ApiOperation({ summary: '회의 방 삭제(진행자만)' }) diff --git a/backend/src/modules/meeting/meeting.module.ts b/backend/src/modules/meeting/meeting.module.ts index 8958160..43ab351 100644 --- a/backend/src/modules/meeting/meeting.module.ts +++ b/backend/src/modules/meeting/meeting.module.ts @@ -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], diff --git a/backend/src/modules/meeting/meeting.service.ts b/backend/src/modules/meeting/meeting.service.ts index 1e0e465..52f794c 100644 --- a/backend/src/modules/meeting/meeting.service.ts +++ b/backend/src/modules/meeting/meeting.service.ts @@ -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, + @InjectRepository(User) + private readonly userRepo: Repository, ) { this.apiKey = (config.get('LIVEKIT_API_KEY') ?? '').trim(); this.apiSecret = (config.get('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 { + 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 { 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 { + 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)); + } } diff --git a/frontend/src/components/meeting/MeetingCreateForm.vue b/frontend/src/components/meeting/MeetingCreateForm.vue index ca4d7c8..8b6ebdd 100644 --- a/frontend/src/components/meeting/MeetingCreateForm.vue +++ b/frontend/src/components/meeting/MeetingCreateForm.vue @@ -14,10 +14,12 @@ import type { ApiMeetingRoom } from '@/types/meeting' interface Props { // 이름 중복 즉시 검사용 현재 방 목록 rooms: ApiMeetingRoom[] + // 편집 모드 — 전달되면 해당 방의 현재 값으로 채우고 수정한다(없으면 생성 모드). + editRoom?: ApiMeetingRoom | null } const props = defineProps() const emit = defineEmits<{ - // 생성 성공 → 부모가 목록 갱신 + 모달 닫기 + // 생성/수정 성공 → 부모가 목록 갱신 + 모달 닫기 (e: 'created'): void (e: 'cancel'): void }>() @@ -27,14 +29,26 @@ const NAME_RE = /^[A-Za-z0-9가-힣ㄱ-ㅎㅏ-ㅣ_-]{1,64}$/ const userApi = useUser() const meetingApi = useMeeting() -const name = ref('') -const scheduledAt = ref('') -const restrict = ref(false) +const isEdit = computed(() => !!props.editRoom) + +// ISO(UTC) → datetime-local 입력값('YYYY-MM-DDTHH:mm', 로컬 시간) +function toLocalInput(iso: string): string { + const d = new Date(iso) + const pad = (n: number) => String(n).padStart(2, '0') + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}` +} + +const name = ref(props.editRoom?.name ?? '') +const scheduledAt = ref( + props.editRoom?.scheduledAt ? toLocalInput(props.editRoom.scheduledAt) : '', +) +const restrict = ref(props.editRoom?.restricted ?? false) const error = ref('') const submitting = ref(false) // --- 참여 허용 멤버 선택 (업무 담당자 할당과 동일한 형태) --- -const selected = ref([]) // 선택된 멤버(입력칸 아래 칩) +// 편집 모드면 현재 허용 멤버로 칩을 미리 채운다(백엔드가 진행자에게 allowedUsers 제공). +const selected = ref([...(props.editRoom?.allowedUsers ?? [])]) // 선택된 멤버(입력칸 아래 칩) const query = ref('') const found = ref([]) // 검색 결과 const showDropdown = ref(false) @@ -75,26 +89,34 @@ async function submit() { error.value = '방 이름은 한글·영문·숫자·-·_ 1~64자만 가능합니다.' return } - if (props.rooms.some((r) => r.name === n)) { + // 이름 중복 검사 — 편집 중인 방 자신은 제외한다. + if (props.rooms.some((r) => r.name === n && r.id !== props.editRoom?.id)) { error.value = '이미 같은 이름의 방이 있습니다.' return } error.value = '' submitting.value = true - try { - await meetingApi.createRoom({ - name: n, - scheduledAt: scheduledAt.value - ? new Date(scheduledAt.value).toISOString() + const payload = { + name: n, + scheduledAt: scheduledAt.value + ? new Date(scheduledAt.value).toISOString() + : undefined, + allowedUserIds: + restrict.value && selected.value.length + ? selected.value.map((u) => u.id) : undefined, - allowedUserIds: - restrict.value && selected.value.length - ? selected.value.map((u) => u.id) - : undefined, - }) + } + try { + if (props.editRoom) { + await meetingApi.updateRoom(props.editRoom.id, payload) + } else { + await meetingApi.createRoom(payload) + } emit('created') } catch { - error.value = '방 생성에 실패했습니다. (이름이 이미 사용 중일 수 있습니다)' + error.value = isEdit.value + ? '방 수정에 실패했습니다. (이름이 이미 사용 중일 수 있습니다)' + : '방 생성에 실패했습니다. (이름이 이미 사용 중일 수 있습니다)' submitting.value = false } } @@ -102,11 +124,11 @@ async function submit() { diff --git a/frontend/src/components/meeting/MeetingIcon.vue b/frontend/src/components/meeting/MeetingIcon.vue index 6174990..947b103 100644 --- a/frontend/src/components/meeting/MeetingIcon.vue +++ b/frontend/src/components/meeting/MeetingIcon.vue @@ -23,6 +23,7 @@ export type MeetingIconName = | 'warn' | 'lock' | 'trash' + | 'edit'