Merge feature/meeting-room-edit: 화상회의 방 편집 기능

This commit is contained in:
2026-06-26 16:15:31 +09:00
9 changed files with 199 additions and 31 deletions
@@ -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],
+67 -4
View File
@@ -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));
}
}
@@ -14,10 +14,12 @@ import type { ApiMeetingRoom } from '@/types/meeting'
interface Props {
// 이름 중복 즉시 검사용 현재 방 목록
rooms: ApiMeetingRoom[]
// 편집 모드 — 전달되면 해당 방의 현재 값으로 채우고 수정한다(없으면 생성 모드).
editRoom?: ApiMeetingRoom | null
}
const props = defineProps<Props>()
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<ApiMember[]>([]) // 선택된 멤버(입력칸 아래 칩)
// 편집 모드면 현재 허용 멤버로 칩을 미리 채운다(백엔드가 진행자에게 allowedUsers 제공).
const selected = ref<ApiMember[]>([...(props.editRoom?.allowedUsers ?? [])]) // 선택된 멤버(입력칸 아래 칩)
const query = ref('')
const found = ref<ApiMember[]>([]) // 검색 결과
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() {
<template>
<BaseModal
title="새 회의 만들기"
:title="isEdit ? '회의 편집' : '새 회의 만들기'"
@close="emit('cancel')"
>
<template #subtitle>
방을 만들면 내가 진행자가 됩니다.
{{ isEdit ? '내가 만든 방의 이름·예약·참여 제한을 수정합니다.' : '방을 만들면 내가 진행자가 됩니다.' }}
</template>
<form
@@ -243,7 +265,15 @@ async function submit() {
:disabled="submitting"
@click="submit"
>
<MeetingIcon name="plus" />{{ submitting ? '생성 중…' : '방 만들기' }}
<MeetingIcon :name="isEdit ? 'edit' : 'plus'" />{{
submitting
? isEdit
? '저장 중…'
: '생성 중…'
: isEdit
? '저장'
: '방 만들기'
}}
</button>
</template>
</BaseModal>
@@ -23,6 +23,7 @@ export type MeetingIconName =
| 'warn'
| 'lock'
| 'trash'
| 'edit'
</script>
<script setup lang="ts">
@@ -173,5 +174,8 @@ defineProps<{ name: MeetingIconName }>()
<template v-else-if="name === 'trash'">
<path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m2 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M10 11v6M14 11v6" />
</template>
<template v-else-if="name === 'edit'">
<path d="M12 20h9" /><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
</template>
</svg>
</template>
@@ -21,6 +21,8 @@ const emit = defineEmits<{
}>()
const showForm = ref(false)
// 편집 대상 방 — 설정되면 생성 폼이 편집 모드로 열린다(null 이면 생성 모드).
const editRoom = ref<ApiMeetingRoom | null>(null)
const keyword = ref('')
// 방 이름·진행자 이름으로 목록 검색(클라이언트 필터)
@@ -37,8 +39,23 @@ const filteredRooms = computed(() => {
function onSearchInput() {
if (props.joinError) emit('clear-error')
}
function onCreated() {
// 새 회의 만들기(생성 모드)
function openCreate() {
editRoom.value = null
showForm.value = true
}
// 방 편집(편집 모드) — 진행자만 노출됨
function openEdit(room: ApiMeetingRoom) {
showForm.value = false
editRoom.value = room
}
function closeForm() {
showForm.value = false
editRoom.value = null
}
// 생성/수정 완료 → 폼 닫고 부모에 목록 갱신 요청
function onFormDone() {
closeForm()
emit('created')
}
@@ -78,7 +95,7 @@ function fmtSchedule(iso: string): string {
<button
type="button"
class="btn primary new-room"
@click="showForm = true"
@click="openCreate"
>
<MeetingIcon name="plus" /> 회의 만들기
</button>
@@ -92,10 +109,11 @@ function fmtSchedule(iso: string): string {
</div>
<MeetingCreateForm
v-if="showForm"
v-if="showForm || editRoom"
:rooms="rooms"
@created="onCreated"
@cancel="showForm = false"
:edit-room="editRoom"
@created="onFormDone"
@cancel="closeForm"
/>
<!-- 목록 -->
@@ -152,6 +170,15 @@ function fmtSchedule(iso: string): string {
>
<MeetingIcon name="lock" />권한 없음
</button>
<button
v-if="r.isHost"
type="button"
class="btn rr-edit"
title="방 편집"
@click="openEdit(r)"
>
<MeetingIcon name="edit" />
</button>
<button
v-if="r.isHost"
type="button"
@@ -332,15 +359,21 @@ function fmtSchedule(iso: string): string {
.rr-noaccess {
color: var(--text-3);
}
.rr-edit,
.rr-del {
width: 2.25rem;
padding: 0;
justify-content: center;
}
.rr-edit svg,
.rr-del svg {
width: 0.9375rem;
height: 0.9375rem;
}
.rr-edit:hover {
border-color: var(--border-strong);
color: var(--text);
}
/* 버튼 기본/primary 는 전역 .btn 사용. 여기선 danger·비활성·사이즈 변형만 보강. */
.btn.danger {
+13 -1
View File
@@ -2,6 +2,7 @@ import { useApi } from './useApi'
import type {
ApiMeetingRoom,
CreateMeetingRoomPayload,
UpdateMeetingRoomPayload,
} from '@/types/meeting'
// 회의 내 역할 — 진행자(방 생성자) / 일반 참여자
@@ -41,10 +42,21 @@ export function useMeeting() {
)) as unknown as ApiMeetingRoom
}
// 방 수정(진행자만) — 이름·예약·참여 제한 변경
async function updateRoom(
roomId: string,
payload: UpdateMeetingRoomPayload,
): Promise<ApiMeetingRoom> {
return (await api.patch(
`/meetings/rooms/${roomId}`,
payload,
)) as unknown as ApiMeetingRoom
}
// 방 삭제(진행자만)
async function removeRoom(roomId: string): Promise<void> {
await api.delete(`/meetings/rooms/${roomId}`)
}
return { getToken, listRooms, createRoom, removeRoom }
return { getToken, listRooms, createRoom, updateRoom, removeRoom }
}
+6
View File
@@ -1,4 +1,5 @@
// 화상회의 도메인 타입 — 백엔드 MeetingService 응답/DTO 와 1:1
import type { ApiMember } from '@/types/repo'
// 회의 내 역할
export type MeetingRole = 'host' | 'participant'
@@ -12,6 +13,8 @@ export interface ApiMeetingRoom {
restricted: boolean // 참여 제한(allow-list) 여부
isHost: boolean // 현재 사용자가 진행자
canJoin: boolean // 현재 사용자가 입장 가능
// 참여 허용 멤버 — 진행자에게만 내려옴(편집 폼 사전 채움용). 공개 방/비진행자는 null.
allowedUsers: ApiMember[] | null
createdAt: string
}
@@ -22,6 +25,9 @@ export interface CreateMeetingRoomPayload {
allowedUserIds?: string[] // 비우면 전체 공개
}
// 방 수정 요청 — 생성과 동일 필드(진행자가 이름·예약·참여 제한 변경)
export type UpdateMeetingRoomPayload = CreateMeetingRoomPayload
// 회의실 채팅 메시지 — LiveKit 데이터 채널로 주고받는 인메모리 메시지(서버 저장 없음)
export interface ChatMessage {
id: string