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:
@@ -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: '토큰 발급 성공' })
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user