Merge feature/video-meeting-phase4: 화상회의 단계 4(방 관리)
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { useApi } from './useApi'
|
||||
import type {
|
||||
ApiMeetingRoom,
|
||||
CreateMeetingRoomPayload,
|
||||
} from '@/types/meeting'
|
||||
|
||||
// 회의 내 역할 — 진행자(방 생성자) / 일반 참여자
|
||||
export type MeetingRole = 'host' | 'participant'
|
||||
@@ -22,5 +26,25 @@ export function useMeeting() {
|
||||
})) as unknown as MeetingToken
|
||||
}
|
||||
|
||||
return { getToken }
|
||||
// 방 목록
|
||||
async function listRooms(): Promise<ApiMeetingRoom[]> {
|
||||
return (await api.get('/meetings/rooms')) as unknown as ApiMeetingRoom[]
|
||||
}
|
||||
|
||||
// 방 생성(생성자=진행자)
|
||||
async function createRoom(
|
||||
payload: CreateMeetingRoomPayload,
|
||||
): Promise<ApiMeetingRoom> {
|
||||
return (await api.post(
|
||||
'/meetings/rooms',
|
||||
payload,
|
||||
)) as unknown as ApiMeetingRoom
|
||||
}
|
||||
|
||||
// 방 삭제(진행자만)
|
||||
async function removeRoom(roomId: string): Promise<void> {
|
||||
await api.delete(`/meetings/rooms/${roomId}`)
|
||||
}
|
||||
|
||||
return { getToken, listRooms, createRoom, removeRoom }
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
// 화상회의 (단계 1: 인프라 토대) — 방 입장 → LiveKit 연결 → 참여자 영상 그리드
|
||||
// 진행자/일반 권한 분기(단계 2), 화면 승격/페이지네이션(단계 3)은 후속 단계에서 구현한다.
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
// 화상회의 — 로비(방 목록·생성·빠른입장) + 회의실(stage/썸네일/승격/페이지네이션)
|
||||
// ※ 레이아웃은 임시(추후 UI 개편). 데이터/로직 중심.
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import type { Participant } from 'livekit-client'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import ParticipantTile from '@/components/meeting/ParticipantTile.vue'
|
||||
import { useMeeting, type MeetingRole } from '@/composables/useMeeting'
|
||||
import { useLiveKitRoom } from '@/composables/useLiveKitRoom'
|
||||
import { useUser } from '@/composables/useUser'
|
||||
import { useDialog } from '@/composables/useDialog'
|
||||
import type { ApiMeetingRoom } from '@/types/meeting'
|
||||
import type { ApiMember } from '@/types/repo'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -68,6 +72,111 @@ async function onLeave() {
|
||||
await disconnect()
|
||||
myRole.value = null
|
||||
void router.replace('/meeting')
|
||||
void loadRooms()
|
||||
}
|
||||
|
||||
// --- 로비: 방 목록 / 생성 / 참여 제한 멤버 피커 ---
|
||||
const userApi = useUser()
|
||||
const dialog = useDialog()
|
||||
|
||||
const rooms = ref<ApiMeetingRoom[]>([])
|
||||
async function loadRooms() {
|
||||
try {
|
||||
rooms.value = await meetingApi.listRooms()
|
||||
} catch {
|
||||
// 목록 조회 실패는 인터셉터에서 전역 처리
|
||||
}
|
||||
}
|
||||
|
||||
// 목록에서 방 선택 입장
|
||||
async function joinRoom(name: string) {
|
||||
roomName.value = name
|
||||
await onJoin()
|
||||
}
|
||||
|
||||
// 방 삭제(진행자만)
|
||||
async function deleteRoom(r: ApiMeetingRoom) {
|
||||
const ok = await dialog.confirm(`'${r.name}' 방을 삭제할까요?`, {
|
||||
title: '방 삭제',
|
||||
confirmText: '삭제',
|
||||
variant: 'danger',
|
||||
})
|
||||
if (!ok) return
|
||||
try {
|
||||
await meetingApi.removeRoom(r.id)
|
||||
await loadRooms()
|
||||
} catch {
|
||||
// 전역 처리
|
||||
}
|
||||
}
|
||||
|
||||
// 예약 시각 표시(로컬)
|
||||
function fmtSchedule(iso: string): string {
|
||||
return new Date(iso).toLocaleString('ko-KR', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
}
|
||||
|
||||
// 방 생성 폼
|
||||
const showCreate = ref(false)
|
||||
const newName = ref('')
|
||||
const newScheduledAt = ref('')
|
||||
const restrictOn = ref(false)
|
||||
const allowedUsers = ref<ApiMember[]>([])
|
||||
const userQuery = ref('')
|
||||
const userResults = ref<ApiMember[]>([])
|
||||
const creating = ref(false)
|
||||
const createError = ref<string | null>(null)
|
||||
|
||||
// 참여 허용 멤버 검색(제한 켰을 때만)
|
||||
watch(userQuery, async (q) => {
|
||||
if (!restrictOn.value) return
|
||||
const list = await userApi.search(q)
|
||||
const picked = new Set(allowedUsers.value.map((u) => u.id))
|
||||
userResults.value = list.filter((u) => !picked.has(u.id)).slice(0, 8)
|
||||
})
|
||||
function addUser(u: ApiMember) {
|
||||
allowedUsers.value.push(u)
|
||||
userResults.value = userResults.value.filter((x) => x.id !== u.id)
|
||||
userQuery.value = ''
|
||||
}
|
||||
function removeUser(id: string) {
|
||||
allowedUsers.value = allowedUsers.value.filter((u) => u.id !== id)
|
||||
}
|
||||
|
||||
async function createRoom() {
|
||||
const name = newName.value.trim()
|
||||
if (!ROOM_PATTERN.test(name)) {
|
||||
createError.value = '방 이름은 영문, 숫자, 하이픈(-), 밑줄(_)만 사용할 수 있습니다.'
|
||||
return
|
||||
}
|
||||
creating.value = true
|
||||
createError.value = null
|
||||
try {
|
||||
await meetingApi.createRoom({
|
||||
name,
|
||||
scheduledAt: newScheduledAt.value
|
||||
? new Date(newScheduledAt.value).toISOString()
|
||||
: undefined,
|
||||
allowedUserIds:
|
||||
restrictOn.value && allowedUsers.value.length
|
||||
? allowedUsers.value.map((u) => u.id)
|
||||
: undefined,
|
||||
})
|
||||
showCreate.value = false
|
||||
newName.value = ''
|
||||
newScheduledAt.value = ''
|
||||
restrictOn.value = false
|
||||
allowedUsers.value = []
|
||||
userQuery.value = ''
|
||||
userResults.value = []
|
||||
await loadRooms()
|
||||
} catch {
|
||||
createError.value = '방 생성에 실패했습니다. (이름이 이미 사용 중일 수 있습니다)'
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const participantCount = computed(() => participants.value.length)
|
||||
@@ -113,6 +222,11 @@ watch(pageCount, (count) => {
|
||||
if (page.value >= count) page.value = Math.max(0, count - 1)
|
||||
})
|
||||
|
||||
// 로비 진입 시 방 목록 로드
|
||||
onMounted(() => {
|
||||
if (!connected.value) void loadRooms()
|
||||
})
|
||||
|
||||
// 화면 이탈 시 연결 정리
|
||||
onBeforeUnmount(() => {
|
||||
void disconnect()
|
||||
@@ -128,14 +242,22 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<div class="pagehead">
|
||||
<h1>화상회의</h1>
|
||||
<button
|
||||
class="btn primary"
|
||||
@click="showCreate = !showCreate"
|
||||
>
|
||||
{{ showCreate ? '닫기' : '방 만들기' }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="lede">
|
||||
방 이름을 입력해 입장하세요. 같은 방 이름으로 들어온 사람끼리 연결됩니다.
|
||||
회의 방을 만들거나 목록에서 선택해 입장하세요.
|
||||
</p>
|
||||
|
||||
<!-- 방 생성 폼 -->
|
||||
<form
|
||||
class="lobby card"
|
||||
@submit.prevent="onJoin"
|
||||
v-if="showCreate"
|
||||
class="card create-form"
|
||||
@submit.prevent="createRoom"
|
||||
>
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
@@ -145,13 +267,173 @@ onBeforeUnmount(() => {
|
||||
영문·숫자·하이픈(-)·밑줄(_)만 사용할 수 있습니다.
|
||||
</div>
|
||||
<input
|
||||
v-model="roomName"
|
||||
v-model="newName"
|
||||
type="text"
|
||||
class="tinput"
|
||||
placeholder="예: team-standup"
|
||||
placeholder="예: weekly-sync"
|
||||
maxlength="64"
|
||||
:disabled="connecting"
|
||||
>
|
||||
</div>
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
예약 시각 (선택)
|
||||
</div>
|
||||
<input
|
||||
v-model="newScheduledAt"
|
||||
type="datetime-local"
|
||||
class="tinput"
|
||||
>
|
||||
</div>
|
||||
<div class="fblock">
|
||||
<label class="check">
|
||||
<input
|
||||
v-model="restrictOn"
|
||||
type="checkbox"
|
||||
>
|
||||
참여 제한 (지정한 사람만 입장)
|
||||
</label>
|
||||
<div
|
||||
v-if="restrictOn"
|
||||
class="picker"
|
||||
>
|
||||
<input
|
||||
v-model="userQuery"
|
||||
type="text"
|
||||
class="tinput"
|
||||
placeholder="이름 또는 이메일로 검색"
|
||||
>
|
||||
<ul
|
||||
v-if="userResults.length"
|
||||
class="picker-results"
|
||||
>
|
||||
<li
|
||||
v-for="u in userResults"
|
||||
:key="u.id"
|
||||
@click="addUser(u)"
|
||||
>
|
||||
{{ u.name }} <span class="pr-email">{{ u.email }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div
|
||||
v-if="allowedUsers.length"
|
||||
class="chips"
|
||||
>
|
||||
<span
|
||||
v-for="u in allowedUsers"
|
||||
:key="u.id"
|
||||
class="chip"
|
||||
>
|
||||
{{ u.name }}
|
||||
<button
|
||||
type="button"
|
||||
@click="removeUser(u.id)"
|
||||
>×</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
v-if="createError"
|
||||
class="err"
|
||||
>
|
||||
{{ createError }}
|
||||
</p>
|
||||
<div class="form-footer">
|
||||
<button
|
||||
type="button"
|
||||
class="btn ghost"
|
||||
@click="showCreate = false"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn primary"
|
||||
:disabled="creating"
|
||||
>
|
||||
{{ creating ? '생성 중…' : '만들기' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- 방 목록 -->
|
||||
<div class="card rooms">
|
||||
<div class="rooms-head">
|
||||
회의 방 <span class="ct">{{ rooms.length }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="!rooms.length"
|
||||
class="rooms-empty"
|
||||
>
|
||||
생성된 방이 없습니다. "방 만들기"로 새 방을 만들어 보세요.
|
||||
</div>
|
||||
<div
|
||||
v-for="r in rooms"
|
||||
:key="r.id"
|
||||
class="room-row"
|
||||
>
|
||||
<div class="rr-info">
|
||||
<div class="rr-main">
|
||||
<span class="rr-name">{{ r.name }}</span>
|
||||
<span
|
||||
v-if="r.restricted"
|
||||
class="rr-badge"
|
||||
>🔒 제한</span>
|
||||
<span
|
||||
v-if="r.isHost"
|
||||
class="rr-badge host"
|
||||
>진행자</span>
|
||||
</div>
|
||||
<div class="rr-sub">
|
||||
<span v-if="r.host">{{ r.host.name }}</span>
|
||||
<span v-if="r.scheduledAt"> · 예약 {{ fmtSchedule(r.scheduledAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rr-actions">
|
||||
<button
|
||||
class="btn sm"
|
||||
:disabled="!r.canJoin || connecting"
|
||||
@click="joinRoom(r.name)"
|
||||
>
|
||||
{{ r.canJoin ? '입장' : '권한 없음' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="r.isHost"
|
||||
class="btn sm ghost"
|
||||
@click="deleteRoom(r)"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 빠른 입장(방 이름 직접) -->
|
||||
<form
|
||||
class="card quick"
|
||||
@submit.prevent="onJoin"
|
||||
>
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
방 이름으로 바로 입장
|
||||
</div>
|
||||
<div class="quick-row">
|
||||
<input
|
||||
v-model="roomName"
|
||||
type="text"
|
||||
class="tinput"
|
||||
placeholder="예: team-standup"
|
||||
maxlength="64"
|
||||
:disabled="connecting"
|
||||
>
|
||||
<button
|
||||
class="btn primary"
|
||||
type="submit"
|
||||
:disabled="connecting"
|
||||
>
|
||||
{{ connecting ? '입장 중…' : '입장' }}
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
v-if="joinError"
|
||||
class="err"
|
||||
@@ -159,15 +441,6 @@ onBeforeUnmount(() => {
|
||||
{{ joinError }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="form-footer">
|
||||
<button
|
||||
class="btn primary"
|
||||
type="submit"
|
||||
:disabled="connecting"
|
||||
>
|
||||
{{ connecting ? '입장 중…' : '입장하기' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -286,6 +559,10 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
.pagehead {
|
||||
padding: 1.125rem 0 0.375rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.pagehead h1 {
|
||||
font-size: 1.375rem;
|
||||
@@ -373,6 +650,166 @@ onBeforeUnmount(() => {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
.btn.ghost {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.btn.sm {
|
||||
height: 1.875rem;
|
||||
padding: 0 0.75rem;
|
||||
font-size: 0.781rem;
|
||||
}
|
||||
.btn.sm:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* 로비: 방 생성 폼 */
|
||||
.create-form {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.create-form .fblock {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.create-form .fblock:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
.check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
.picker {
|
||||
margin-top: 0.625rem;
|
||||
}
|
||||
.picker-results {
|
||||
list-style: none;
|
||||
margin: 0.375rem 0 0;
|
||||
padding: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
.picker-results li {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.picker-results li:hover {
|
||||
background: var(--accent-weak);
|
||||
}
|
||||
.pr-email {
|
||||
color: var(--text-3);
|
||||
font-size: 0.719rem;
|
||||
}
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.1875rem 0.5rem;
|
||||
background: var(--accent-weak);
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: 0.3125rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.chip button {
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 로비: 방 목록 */
|
||||
.rooms {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.rooms-head {
|
||||
padding: 0.875rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.rooms-head .ct {
|
||||
color: var(--text-3);
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
.rooms-empty {
|
||||
padding: 1.5rem 1.25rem;
|
||||
text-align: center;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.room-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.room-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.rr-info {
|
||||
min-width: 0;
|
||||
}
|
||||
.rr-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4375rem;
|
||||
}
|
||||
.rr-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.rr-badge {
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
background: #f3f4f6;
|
||||
color: var(--text-2);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.rr-badge.host {
|
||||
background: var(--accent-weak);
|
||||
color: var(--accent);
|
||||
}
|
||||
.rr-sub {
|
||||
margin-top: 0.1875rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.rr-actions {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 로비: 빠른 입장 */
|
||||
.quick-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.quick-row .tinput {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 회의실 */
|
||||
.room {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// 화상회의 도메인 타입 — 백엔드 MeetingService 응답/DTO 와 1:1
|
||||
|
||||
// 회의 내 역할
|
||||
export type MeetingRole = 'host' | 'participant'
|
||||
|
||||
// 회의 방(API 원시) — 백엔드 MeetingRoomResponse 와 1:1
|
||||
export interface ApiMeetingRoom {
|
||||
id: string
|
||||
name: string
|
||||
host: { id: string; name: string; avatarUrl: string | null } | null
|
||||
scheduledAt: string | null // ISO, 예약 시각(없으면 null)
|
||||
restricted: boolean // 참여 제한(allow-list) 여부
|
||||
isHost: boolean // 현재 사용자가 진행자
|
||||
canJoin: boolean // 현재 사용자가 입장 가능
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
// 방 생성 요청
|
||||
export interface CreateMeetingRoomPayload {
|
||||
name: string
|
||||
scheduledAt?: string // ISO
|
||||
allowedUserIds?: string[] // 비우면 전체 공개
|
||||
}
|
||||
+23
-16
@@ -13,7 +13,8 @@
|
||||
- 단계 1: 방 입장 → LiveKit 연결 → 카메라/마이크 발행 → 나가기, 마이크/카메라 on·off
|
||||
- 단계 2: **권한 분기** — 방 생성자=진행자(host), 이후 입장자=일반. 역할별 토큰 grant 차등
|
||||
- 단계 3: 진행자/승격된 참여자 **큰 화면(stage)** + 나머지 **썸네일**, 진행자의 **화면 승격**(데이터 채널 브로드캐스트), 썸네일 **페이지네이션**
|
||||
- **제외(후속 단계)**: 단계 4(방 목록/예약/참여 제한 UI), 5(부하 테스트), 6(셀프호스팅 배포)
|
||||
- 단계 4: **방 관리** — 명시적 방 생성(이름+예약시각+참여 허용 멤버), 방 목록·입장, 참여 제한(allow-list) + 토큰 발급 시 enforcement, 진행자 방 삭제
|
||||
- **제외(후속 단계)**: 5(부하 테스트), 6(셀프호스팅 배포)
|
||||
- ⚠️ **현재 화면 레이아웃은 임시(대충)이며 UI 는 새로 개편 예정.** 아래 "데이터 계약"만 그대로 연결하면 동작이 유지된다.
|
||||
|
||||
---
|
||||
@@ -48,16 +49,14 @@
|
||||
|
||||
---
|
||||
|
||||
## 4. 로비 화면 (현재 구성)
|
||||
## 4. 로비 화면 (현재 구성, 임시)
|
||||
|
||||
- 페이지 제목 `화상회의` + 안내 문구(lede)
|
||||
- 카드 폼:
|
||||
- 라벨 `* 방 이름` (필수 표기)
|
||||
- 힌트: "영문·숫자·하이픈(-)·밑줄(_)만 사용할 수 있습니다."
|
||||
- 입력란: `maxlength=64`, placeholder `예: team-standup`
|
||||
- 오류 문구(`joinError`)
|
||||
- 푸터: `입장하기` 버튼(제출 시 `onJoin`)
|
||||
- **방 이름 규칙**: `^[A-Za-z0-9_-]+$`, 1~64자. (백엔드 DTO와 동일하게 검증 — 한글/공백 불가)
|
||||
- 헤더: 제목 `화상회의` + **[방 만들기]** 토글 버튼
|
||||
- **방 생성 폼**(토글): 방 이름(필수), 예약 시각(datetime-local, 선택), 참여 제한 체크 + 멤버 검색/선택(`useUser().search`) → 칩. 제출 시 `createRoom()`
|
||||
- **방 목록**: 각 방 = 이름 + `🔒 제한`/`진행자` 배지 + 진행자명·예약시각, **[입장]**(`canJoin` 일 때만, `joinRoom(name)`) / **[삭제]**(`isHost` 만)
|
||||
- **빠른 입장**: 방 이름 직접 입력 + `[입장]`(`onJoin`) — 직접 링크/즉석 방 호환
|
||||
- **방 이름 규칙**: `^[A-Za-z0-9_-]+$`, 1~64자. (백엔드 DTO와 동일 — 한글/공백 불가)
|
||||
- 방이 없는 이름으로 입장하면 그 방이 생성되고 입장자가 진행자가 된다(공개 방). 참여 제한 방은 진행자+허용 멤버만 입장(아니면 입장 시 권한 오류).
|
||||
|
||||
---
|
||||
|
||||
@@ -151,17 +150,25 @@ LiveKit 방 연결/생명주기를 캡슐화. 반환 객체:
|
||||
|
||||
| 메서드 | 반환 | 설명 |
|
||||
|---|---|---|
|
||||
| `getToken(roomName)` | `Promise<{ token, roomName, identity }>` | 백엔드에서 LiveKit 입장 토큰(JWT) 발급 |
|
||||
| `getToken(roomName)` | `Promise<{ token, roomName, identity, role }>` | LiveKit 입장 토큰(JWT) 발급 + 본인 역할 |
|
||||
| `listRooms()` | `Promise<ApiMeetingRoom[]>` | 방 목록(접근 가능 여부·진행자 여부 포함) |
|
||||
| `createRoom(payload)` | `Promise<ApiMeetingRoom>` | 방 생성(name, scheduledAt?, allowedUserIds?). 생성자=진행자 |
|
||||
| `removeRoom(roomId)` | `Promise<void>` | 방 삭제(진행자만) |
|
||||
|
||||
`ApiMeetingRoom`(`frontend/src/types/meeting.ts`): `{ id, name, host, scheduledAt, restricted, isHost, canJoin, createdAt }`
|
||||
|
||||
---
|
||||
|
||||
## 9. 백엔드 (참고 — 변경 불필요)
|
||||
|
||||
- **엔드포인트**: `POST /api/meetings/token` (JWT 인증 필요)
|
||||
- **요청 바디**: `{ "roomName": "team-standup" }` (DTO 검증: `^[A-Za-z0-9_-]+$`, 1~64자)
|
||||
- **응답**: `{ "token": "<LiveKit JWT>", "roomName": "...", "identity": "<userId>" }`
|
||||
- **권한(현재 단계 1)**: 모든 입장자에게 `roomJoin + canPublish + canSubscribe + canPublishData` 부여. identity=사용자 ID, name=사용자 이름. 토큰 TTL 1시간.
|
||||
- 파일: `backend/src/modules/meeting/{meeting.controller.ts, meeting.service.ts, dto/create-meeting-token.dto.ts}`
|
||||
- `POST /api/meetings/token` — 입장 토큰 발급. 바디 `{ roomName }`. 응답 `{ token, roomName, identity, role }`.
|
||||
- 역할: 방 생성자=진행자(roomAdmin+발행/구독), 그 외=일반(발행/구독). 역할은 토큰 metadata 에도 포함.
|
||||
- 참여 제한 방에 비허용자가 요청하면 `AUTH_003`(403).
|
||||
- `GET /api/meetings/rooms` — 방 목록(`MeetingRoomResponse[]`).
|
||||
- `POST /api/meetings/rooms` — 방 생성. 바디 `{ name, scheduledAt?, allowedUserIds? }`. 이름 중복 시 `BIZ_001`(409).
|
||||
- `DELETE /api/meetings/rooms/:roomId` — 방 삭제(진행자만, 아니면 `AUTH_003`).
|
||||
- 모두 JWT 인증 필요. 방 정보는 `meeting_rooms`(name 고유, host, scheduled_at, allowed_user_ids) 에 보관.
|
||||
- 파일: `backend/src/modules/meeting/{meeting.controller, meeting.service, entities/meeting-room.entity, dto/*}`
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user