feat: 화상회의 단계 4 — 방 관리(목록·생성·예약·참여 제한)

암묵적 방 생성을 명시적 방 관리로 확장한다. (기능 완성 + 임시 UI)

백엔드
- meeting_rooms 에 scheduled_at(예약), allowed_user_ids(참여 허용 allow-list) 추가 + 마이그레이션
- GET /meetings/rooms(목록: 접근 가능/진행자 여부 포함)
- POST /meetings/rooms(생성: name+예약+허용멤버, 생성자=진행자, 이름 중복 409)
- DELETE /meetings/rooms/:id(진행자만, 아니면 403)
- 토큰 발급 시 참여 제한 enforcement: 비허용자는 AUTH_003(403).
  방이 없으면 자동 생성(공개)으로 직접 링크 입장은 유지.

프론트
- useMeeting: listRooms/createRoom/removeRoom + types/meeting.ts
- MeetingPage 로비: 방 목록(입장/삭제) + 생성 폼(예약시각·참여 제한 멤버 피커, useUser 검색 재사용) + 빠른 입장
- 화상회의_화면명세.md 단계 4 반영(범위/로비/데이터 계약/엔드포인트)

권한 모델은 기존 결정대로 "누구나 생성=진행자" 유지(전역 admin 없음).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-23 20:48:51 +09:00
parent 2416f728ba
commit 54fe3d36e7
9 changed files with 792 additions and 63 deletions
@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 화상회의 방에 예약 시각/참여 허용 사용자 컬럼 추가 (단계 4: 방 관리).
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
export class AddMeetingRoomFields1782800000000 implements MigrationInterface {
name = 'AddMeetingRoomFields1782800000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "meeting_rooms" ADD "scheduled_at" TIMESTAMP`,
);
await queryRunner.query(
`ALTER TABLE "meeting_rooms" ADD "allowed_user_ids" text`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "meeting_rooms" DROP COLUMN "allowed_user_ids"`,
);
await queryRunner.query(
`ALTER TABLE "meeting_rooms" DROP COLUMN "scheduled_at"`,
);
}
}
@@ -0,0 +1,43 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import {
ArrayMaxSize,
IsArray,
IsISO8601,
IsOptional,
IsString,
IsUUID,
Length,
Matches,
} from 'class-validator';
// 화상회의 방 생성 요청
export class CreateMeetingRoomDto {
@ApiProperty({
description: '방 이름(영문/숫자/-/_ 만 허용, 고유)',
example: 'weekly-sync',
})
@IsString()
@Length(1, 64, { message: '방 이름은 1~64자여야 합니다.' })
@Matches(/^[A-Za-z0-9_-]+$/, {
message: '방 이름은 영문, 숫자, 하이픈(-), 밑줄(_)만 사용할 수 있습니다.',
})
name!: string;
@ApiPropertyOptional({
description: '예약 시각(ISO 8601). 미지정 시 즉시 사용 가능한 방',
example: '2026-07-01T10:00:00.000Z',
})
@IsOptional()
@IsISO8601({}, { message: '예약 시각 형식이 올바르지 않습니다.' })
scheduledAt?: string;
@ApiPropertyOptional({
description: '참여 허용 사용자 ID 목록. 비우면 전체 공개',
type: [String],
})
@IsOptional()
@IsArray()
@ArrayMaxSize(160, { message: '참여 허용 인원은 최대 160명입니다.' })
@IsUUID('4', { each: true, message: '사용자 ID 형식이 올바르지 않습니다.' })
allowedUserIds?: string[];
}
@@ -28,6 +28,15 @@ export class MeetingRoom {
@JoinColumn({ name: 'host_id' })
host!: Relation<User> | null;
// 예약 시각(선택) — 표시용. 현재는 시간 전 입장을 막지 않는다.
@Column({ name: 'scheduled_at', type: 'timestamp', nullable: true })
scheduledAt!: Date | null;
// 참여 허용 사용자 ID 목록(선택) — 비어있으면(null) 전체 공개.
// 값이 있으면 진행자 + 목록에 포함된 사용자만 입장 가능.
@Column({ name: 'allowed_user_ids', type: 'simple-array', nullable: true })
allowedUserIds!: string[] | null;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
@@ -1,12 +1,33 @@
import { Body, Controller, Post, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Post,
UseGuards,
} from '@nestjs/common';
import {
ApiOperation,
ApiResponse,
ApiTags,
ApiBearerAuth,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { PublicUser } from '../user/user.service';
import { MeetingService, type MeetingTokenResult } from './meeting.service';
import {
MeetingService,
type MeetingTokenResult,
type MeetingRoomResponse,
} from './meeting.service';
import { CreateMeetingTokenDto } from './dto/create-meeting-token.dto';
import { CreateMeetingRoomDto } from './dto/create-meeting-room.dto';
// 화상회의 컨트롤러 — 인증 사용자에게 LiveKit 입장 토큰 발급한다.
// 화상회의 컨트롤러 — 방 관리 + LiveKit 입장 토큰 발급(인증 필요).
@ApiTags('화상회의')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@@ -14,6 +35,34 @@ import { CreateMeetingTokenDto } from './dto/create-meeting-token.dto';
export class MeetingController {
constructor(private readonly meetingService: MeetingService) {}
@Get('rooms')
@ApiOperation({ summary: '회의 방 목록' })
@ApiResponse({ status: 200, description: '목록 조회 성공' })
listRooms(@CurrentUser() user: PublicUser): Promise<MeetingRoomResponse[]> {
return this.meetingService.listRooms(user.id);
}
@Post('rooms')
@ApiOperation({ summary: '회의 방 생성(생성자=진행자)' })
@ApiResponse({ status: 201, description: '생성 성공' })
createRoom(
@CurrentUser() user: PublicUser,
@Body() dto: CreateMeetingRoomDto,
): Promise<MeetingRoomResponse> {
return this.meetingService.createRoom(user.id, dto);
}
@Delete('rooms/:roomId')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: '회의 방 삭제(진행자만)' })
@ApiResponse({ status: 204, description: '삭제 성공' })
async deleteRoom(
@CurrentUser() user: PublicUser,
@Param('roomId', ParseUUIDPipe) roomId: string,
): Promise<void> {
await this.meetingService.deleteRoom(user.id, roomId);
}
@Post('token')
@ApiOperation({ summary: 'LiveKit 입장 토큰 발급' })
@ApiResponse({ status: 201, description: '토큰 발급 성공' })
+136 -24
View File
@@ -4,13 +4,26 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AccessToken } from 'livekit-server-sdk';
import { BusinessException } from '../../common/exceptions/business.exception';
import type { PublicUser } from '../user/user.service';
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';
// 회의 내 역할 — 진행자(방 생성자) / 일반 참여자
export type MeetingRole = 'host' | 'participant';
// 방 목록/생성 응답 — 진행자·예약·참여제한·접근 가능 여부 포함
export interface MeetingRoomResponse {
id: string;
name: string;
host: PublicUser | null;
scheduledAt: string | null;
restricted: boolean; // 참여 제한(allow-list) 적용 여부
isHost: boolean; // 현재 사용자가 진행자인지
canJoin: boolean; // 현재 사용자가 입장 가능한지(공개 OR 진행자 OR 허용목록 포함)
createdAt: string;
}
// 화상회의 토큰 발급 결과 — 브라우저는 자체 VITE_LIVEKIT_URL 로 접속하므로 URL 은 반환하지 않는다.
export interface MeetingTokenResult {
token: string; // LiveKit 입장 JWT
@@ -62,7 +75,7 @@ export class MeetingService {
);
}
const role = await this.resolveRole(user.id, roomName);
const role = await this.resolveAccess(user.id, roomName);
const isHost = role === 'host';
const at = new AccessToken(this.apiKey, this.apiSecret, {
@@ -84,35 +97,134 @@ export class MeetingService {
return { token, roomName, identity: user.id, role };
}
// 방의 역할 결정 — 방이 없으면 이 사용자를 진행자로 생성, 있으면 생성자 일치 여부로 판정.
private async resolveRole(
// 입장 권한 판정 + 역할 결정.
// · 방이 없으면 이 사용자를 진행자로 생성(공개 방) — 직접 링크 입장 호환.
// · 방이 있으면 참여 제한(allow-list)을 검사하고, 생성자 일치로 역할을 판정.
private async resolveAccess(
userId: string,
roomName: string,
): Promise<MeetingRole> {
const existing = await this.roomRepo.findOne({
let room = await this.roomRepo.findOne({
where: { name: roomName },
relations: ['host'],
});
if (existing) {
return existing.host?.id === userId ? 'host' : 'participant';
}
// 방이 없으면 생성 시도 — 이 사용자가 진행자가 된다.
try {
await this.roomRepo.save(
this.roomRepo.create({
name: roomName,
host: { id: userId } as User,
}),
);
return 'host';
} catch {
// 동시 생성 경쟁(name unique 충돌) — 먼저 만든 사람이 진행자. 재조회로 판정.
const room = await this.roomRepo.findOne({
where: { name: roomName },
relations: ['host'],
});
return room?.host?.id === userId ? 'host' : 'participant';
if (!room) {
// 방이 없으면 생성 시도 — 이 사용자가 진행자가 된다.
try {
await this.roomRepo.save(
this.roomRepo.create({
name: roomName,
host: { id: userId } as User,
}),
);
return 'host';
} catch {
// 동시 생성 경쟁(name unique 충돌) — 재조회로 판정.
room = await this.roomRepo.findOne({
where: { name: roomName },
relations: ['host'],
});
}
}
if (!room) return 'participant';
const isHost = room.host?.id === userId;
if (
!isHost &&
this.isRestricted(room) &&
!room.allowedUserIds!.includes(userId)
) {
throw new BusinessException(
'AUTH_003',
'이 회의에 참여할 권한이 없습니다.',
HttpStatus.FORBIDDEN,
);
}
return isHost ? 'host' : 'participant';
}
// 명시적 방 생성 — 생성자가 진행자. 예약 시각/참여 제한을 함께 설정.
async createRoom(
userId: string,
dto: CreateMeetingRoomDto,
): Promise<MeetingRoomResponse> {
const existing = await this.roomRepo.findOne({ where: { name: dto.name } });
if (existing) {
throw new BusinessException(
'BIZ_001',
'이미 존재하는 방 이름입니다. 다른 이름을 사용해 주세요.',
HttpStatus.CONFLICT,
);
}
const saved = await this.roomRepo.save(
this.roomRepo.create({
name: dto.name,
host: { id: userId } as User,
scheduledAt: dto.scheduledAt ? new Date(dto.scheduledAt) : null,
allowedUserIds: dto.allowedUserIds?.length ? dto.allowedUserIds : null,
}),
);
const room = await this.roomRepo.findOne({
where: { id: saved.id },
relations: ['host'],
});
return this.toRoomResponse(room!, userId);
}
// 방 목록 — 전체 방(최근 생성순). 각 방의 접근 가능 여부/진행자 여부를 포함.
async listRooms(userId: string): Promise<MeetingRoomResponse[]> {
const rows = await this.roomRepo.find({
relations: ['host'],
order: { createdAt: 'DESC' },
});
return rows.map((r) => this.toRoomResponse(r, userId));
}
// 방 삭제 — 진행자(생성자)만 가능.
async deleteRoom(userId: string, roomId: string): Promise<void> {
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,
);
}
await this.roomRepo.remove(room);
}
private isRestricted(room: MeetingRoom): boolean {
return !!(room.allowedUserIds && room.allowedUserIds.length > 0);
}
private toRoomResponse(
room: MeetingRoom,
userId: string,
): MeetingRoomResponse {
const isHost = room.host?.id === userId;
const restricted = this.isRestricted(room);
const canJoin =
isHost || !restricted || room.allowedUserIds!.includes(userId);
return {
id: room.id,
name: room.name,
host: room.host ? UserService.toPublic(room.host) : null,
scheduledAt: room.scheduledAt ? room.scheduledAt.toISOString() : null,
restricted,
isHost,
canJoin,
createdAt: room.createdAt.toISOString(),
};
}
}