Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 101b1553b0 | |||
| 2d1026466f | |||
| 5ecc1d7be4 | |||
| bde4b561f3 |
@@ -0,0 +1,36 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 화상회의 방에 입장 코드(code) 추가 — 이름과 식별자를 분리한다.
|
||||
// code = 입장 URL·LiveKit 방·초대 링크의 안정적 키(불변). 이름은 표시용 라벨로 강등(고유 해제).
|
||||
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
|
||||
export class AddMeetingRoomCode1782900000000 implements MigrationInterface {
|
||||
name = 'AddMeetingRoomCode1782900000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// 1) 코드 컬럼 추가(우선 nullable) → 기존 행 백필 → NOT NULL 승격
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "meeting_rooms" ADD "code" character varying`,
|
||||
);
|
||||
// 기존 방에 고유 코드 백필(md5(random) 12자리 16진수 — 충돌 사실상 없음)
|
||||
await queryRunner.query(
|
||||
`UPDATE "meeting_rooms" SET "code" = substr(md5(random()::text || "id"::text), 1, 12) WHERE "code" IS NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "meeting_rooms" ALTER COLUMN "code" SET NOT NULL`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "IDX_meeting_rooms_code" ON "meeting_rooms" ("code")`,
|
||||
);
|
||||
// 2) 이름은 더 이상 고유가 아니다(표시용 라벨) → 고유 인덱스 제거
|
||||
await queryRunner.query(`DROP INDEX "IDX_meeting_rooms_name"`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// 주의: 이름 중복 데이터가 있으면 고유 인덱스 복원이 실패할 수 있다.
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "IDX_meeting_rooms_name" ON "meeting_rooms" ("name")`,
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_meeting_rooms_code"`);
|
||||
await queryRunner.query(`ALTER TABLE "meeting_rooms" DROP COLUMN "code"`);
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,16 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, Length, Matches } from 'class-validator';
|
||||
|
||||
// 화상회의 입장 토큰 발급 요청 — 참여할 방 이름을 전달한다.
|
||||
// 화상회의 입장 토큰 발급 요청 — 참여할 방의 입장 코드를 전달한다.
|
||||
export class CreateMeetingTokenDto {
|
||||
@ApiProperty({
|
||||
description: '참여할 회의 방 이름(한글/영문/숫자/-/_ 허용, 공백 불가)',
|
||||
example: '주간-회의',
|
||||
description: '참여할 회의 방 입장 코드(영문/숫자)',
|
||||
example: 'a1b2c3d4e5',
|
||||
})
|
||||
@IsString()
|
||||
@Length(1, 64, { message: '방 이름은 1~64자여야 합니다.' })
|
||||
@Matches(/^[A-Za-z0-9가-힣ㄱ-ㅎㅏ-ㅣ_-]+$/, {
|
||||
message:
|
||||
'방 이름은 한글, 영문, 숫자, 하이픈(-), 밑줄(_)만 사용할 수 있습니다. (공백 불가)',
|
||||
@Length(6, 32, { message: '입장 코드 형식이 올바르지 않습니다.' })
|
||||
@Matches(/^[A-Za-z0-9]+$/, {
|
||||
message: '입장 코드 형식이 올바르지 않습니다.',
|
||||
})
|
||||
roomName!: string;
|
||||
code!: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { CreateMeetingRoomDto } from './create-meeting-room.dto';
|
||||
|
||||
// 화상회의 방 수정 요청 — 생성과 동일한 필드(진행자가 이름·예약 시각·참여 제한을 수정).
|
||||
// 폼이 현재 값을 그대로 채워 전체 상태를 보내므로 별도 부분 갱신(Partial) 의미는 두지 않는다.
|
||||
export class UpdateMeetingRoomDto extends CreateMeetingRoomDto {}
|
||||
@@ -18,9 +18,14 @@ export class MeetingRoom {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// 방 이름(고유) — 같은 이름으로 들어온 사람끼리 연결된다
|
||||
// 방 입장 코드(고유·불변) — 입장 URL·LiveKit 방 식별자·초대 링크의 기준.
|
||||
// 이름과 분리해, 이름을 바꿔도 링크/세션이 유지된다.
|
||||
@Index({ unique: true })
|
||||
@Column()
|
||||
code!: string;
|
||||
|
||||
// 방 이름(표시용 라벨) — 고유하지 않으며 진행자가 자유롭게 변경할 수 있다.
|
||||
@Column()
|
||||
name!: string;
|
||||
|
||||
// 진행자(생성자). 사용자 삭제 시에도 방 기록은 유지(SET NULL)
|
||||
|
||||
@@ -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: '회의 방 삭제(진행자만)' })
|
||||
@@ -70,6 +83,6 @@ export class MeetingController {
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Body() dto: CreateMeetingTokenDto,
|
||||
): Promise<MeetingTokenResult> {
|
||||
return this.meetingService.issueToken(user, dto.roomName);
|
||||
return this.meetingService.issueToken(user, dto.code);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
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';
|
||||
@@ -15,12 +17,15 @@ export type MeetingRole = 'host' | 'participant';
|
||||
// 방 목록/생성 응답 — 진행자·예약·참여제한·접근 가능 여부 포함
|
||||
export interface MeetingRoomResponse {
|
||||
id: string;
|
||||
code: string; // 입장 코드(불변) — 입장 URL·초대 링크의 기준
|
||||
name: string;
|
||||
host: PublicUser | null;
|
||||
scheduledAt: string | null;
|
||||
restricted: boolean; // 참여 제한(allow-list) 적용 여부
|
||||
isHost: boolean; // 현재 사용자가 진행자인지
|
||||
canJoin: boolean; // 현재 사용자가 입장 가능한지(공개 OR 진행자 OR 허용목록 포함)
|
||||
// 참여 허용 멤버 — 진행자에게만 노출(편집 폼 사전 채움용). 공개 방/비진행자는 null.
|
||||
allowedUsers: PublicUser[] | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -45,6 +50,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();
|
||||
@@ -65,7 +72,7 @@ export class MeetingService {
|
||||
// 역할은 토큰 metadata 에도 실어 다른 참여자가 진행자를 식별할 수 있게 한다.
|
||||
async issueToken(
|
||||
user: PublicUser,
|
||||
roomName: string,
|
||||
code: string,
|
||||
): Promise<MeetingTokenResult> {
|
||||
if (!this.enabled) {
|
||||
throw new BusinessException(
|
||||
@@ -75,7 +82,7 @@ export class MeetingService {
|
||||
);
|
||||
}
|
||||
|
||||
const role = await this.resolveAccess(user.id, roomName);
|
||||
const { room, role } = await this.resolveRoomAccess(user.id, code);
|
||||
const isHost = role === 'host';
|
||||
|
||||
const at = new AccessToken(this.apiKey, this.apiSecret, {
|
||||
@@ -88,7 +95,8 @@ export class MeetingService {
|
||||
});
|
||||
at.addGrant({
|
||||
roomJoin: true,
|
||||
room: roomName,
|
||||
// LiveKit 방 식별자 = 코드(이름이 아니라 불변 코드라 이름을 바꿔도 같은 방으로 연결).
|
||||
room: room.code,
|
||||
canPublish: true,
|
||||
canSubscribe: true,
|
||||
canPublishData: true,
|
||||
@@ -96,41 +104,26 @@ export class MeetingService {
|
||||
});
|
||||
|
||||
const token = await at.toJwt();
|
||||
return { token, roomName, identity: user.id, role };
|
||||
return { token, roomName: room.name, identity: user.id, role };
|
||||
}
|
||||
|
||||
// 입장 권한 판정 + 역할 결정.
|
||||
// · 방이 없으면 이 사용자를 진행자로 생성(공개 방) — 직접 링크 입장 호환.
|
||||
// · 방이 있으면 참여 제한(allow-list)을 검사하고, 생성자 일치로 역할을 판정.
|
||||
private async resolveAccess(
|
||||
// 코드로 방을 찾아 입장 권한을 판정한다(자동 생성 없음 — 명시적으로 생성된 방만 입장 가능).
|
||||
// · 방이 없으면 404. · 참여 제한(allow-list) 검사 후 생성자 일치로 역할을 판정.
|
||||
private async resolveRoomAccess(
|
||||
userId: string,
|
||||
roomName: string,
|
||||
): Promise<MeetingRole> {
|
||||
let room = await this.roomRepo.findOne({
|
||||
where: { name: roomName },
|
||||
code: string,
|
||||
): Promise<{ room: MeetingRoom; role: MeetingRole }> {
|
||||
const room = await this.roomRepo.findOne({
|
||||
where: { code },
|
||||
relations: ['host'],
|
||||
});
|
||||
|
||||
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'],
|
||||
});
|
||||
}
|
||||
throw new BusinessException(
|
||||
'RES_001',
|
||||
'회의 방을 찾을 수 없습니다. 링크를 확인해 주세요.',
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
if (!room) return 'participant';
|
||||
|
||||
const isHost = room.host?.id === userId;
|
||||
if (
|
||||
!isHost &&
|
||||
@@ -143,24 +136,39 @@ export class MeetingService {
|
||||
HttpStatus.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
return isHost ? 'host' : 'participant';
|
||||
return { room, role: isHost ? 'host' : 'participant' };
|
||||
}
|
||||
|
||||
// 명시적 방 생성 — 생성자가 진행자. 예약 시각/참여 제한을 함께 설정.
|
||||
// URL-safe 입장 코드(base62, 10자) 생성.
|
||||
private generateCode(len = 10): string {
|
||||
const ALPHA =
|
||||
'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
const bytes = randomBytes(len);
|
||||
let out = '';
|
||||
for (let i = 0; i < len; i++) out += ALPHA[bytes[i] % 62];
|
||||
return out;
|
||||
}
|
||||
|
||||
// 충돌하지 않는 고유 입장 코드 — 드문 충돌 시 재시도, 그래도 충돌하면 길이를 늘린다.
|
||||
private async generateUniqueCode(): Promise<string> {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const code = this.generateCode();
|
||||
const taken = await this.roomRepo.count({ where: { code } });
|
||||
if (!taken) return code;
|
||||
}
|
||||
return this.generateCode(14);
|
||||
}
|
||||
|
||||
// 명시적 방 생성 — 생성자가 진행자. 고유 코드 발급 + 예약 시각/참여 제한 설정.
|
||||
// 이름은 표시용 라벨이라 중복을 허용한다(식별은 코드로 한다).
|
||||
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 code = await this.generateUniqueCode();
|
||||
const saved = await this.roomRepo.save(
|
||||
this.roomRepo.create({
|
||||
code,
|
||||
name: dto.name,
|
||||
host: { id: userId } as User,
|
||||
scheduledAt: dto.scheduledAt ? new Date(dto.scheduledAt) : null,
|
||||
@@ -180,7 +188,39 @@ 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) 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,23 +250,40 @@ 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 =
|
||||
isHost || !restricted || room.allowedUserIds!.includes(userId);
|
||||
return {
|
||||
id: room.id,
|
||||
code: room.code,
|
||||
name: room.name,
|
||||
host: room.host ? UserService.toPublic(room.host) : null,
|
||||
scheduledAt: room.scheduledAt ? room.scheduledAt.toISOString() : null,
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,12 +12,12 @@ import type { ApiMember } from '@/types/repo'
|
||||
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 +27,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 +87,30 @@ async function submit() {
|
||||
error.value = '방 이름은 한글·영문·숫자·-·_ 1~64자만 가능합니다.'
|
||||
return
|
||||
}
|
||||
if (props.rooms.some((r) => r.name === n)) {
|
||||
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 +118,11 @@ async function submit() {
|
||||
|
||||
<template>
|
||||
<BaseModal
|
||||
title="새 회의 만들기"
|
||||
:title="isEdit ? '회의 편집' : '새 회의 만들기'"
|
||||
@close="emit('cancel')"
|
||||
>
|
||||
<template #subtitle>
|
||||
방을 만들면 내가 진행자가 됩니다.
|
||||
{{ isEdit ? '내가 만든 방의 이름·예약·참여 제한을 수정합니다.' : '방을 만들면 내가 진행자가 됩니다.' }}
|
||||
</template>
|
||||
|
||||
<form
|
||||
@@ -243,7 +259,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,9 @@ export type MeetingIconName =
|
||||
| 'warn'
|
||||
| 'lock'
|
||||
| 'trash'
|
||||
| 'edit'
|
||||
| 'link'
|
||||
| 'check'
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -173,5 +176,14 @@ 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>
|
||||
<template v-else-if="name === 'link'">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</template>
|
||||
<template v-else-if="name === 'check'">
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</template>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
@@ -14,13 +14,17 @@ interface Props {
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'join', name: string): void
|
||||
(e: 'join', room: ApiMeetingRoom): void
|
||||
(e: 'created'): void
|
||||
(e: 'delete', room: ApiMeetingRoom): void
|
||||
(e: 'clear-error'): void
|
||||
}>()
|
||||
|
||||
const showForm = ref(false)
|
||||
// 방금 초대 링크를 복사한 방 코드(잠깐 "복사됨" 표시)
|
||||
const copiedCode = ref<string | null>(null)
|
||||
// 편집 대상 방 — 설정되면 생성 폼이 편집 모드로 열린다(null 이면 생성 모드).
|
||||
const editRoom = ref<ApiMeetingRoom | null>(null)
|
||||
const keyword = ref('')
|
||||
|
||||
// 방 이름·진행자 이름으로 목록 검색(클라이언트 필터)
|
||||
@@ -37,8 +41,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')
|
||||
}
|
||||
|
||||
@@ -49,6 +68,20 @@ function fmtSchedule(iso: string): string {
|
||||
timeStyle: 'short',
|
||||
})
|
||||
}
|
||||
|
||||
// 초대 링크 복사 — 입장 코드 기반 URL(/meeting/<code>). 이름이 바뀌어도 링크는 유지된다.
|
||||
async function copyInvite(room: ApiMeetingRoom) {
|
||||
const url = `${window.location.origin}/meeting/${room.code}`
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
copiedCode.value = room.code
|
||||
window.setTimeout(() => {
|
||||
if (copiedCode.value === room.code) copiedCode.value = null
|
||||
}, 1500)
|
||||
} catch {
|
||||
// 클립보드 권한이 없으면 무시한다(사용자가 직접 복사)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -78,7 +111,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 +125,10 @@ function fmtSchedule(iso: string): string {
|
||||
</div>
|
||||
|
||||
<MeetingCreateForm
|
||||
v-if="showForm"
|
||||
:rooms="rooms"
|
||||
@created="onCreated"
|
||||
@cancel="showForm = false"
|
||||
v-if="showForm || editRoom"
|
||||
:edit-room="editRoom"
|
||||
@created="onFormDone"
|
||||
@cancel="closeForm"
|
||||
/>
|
||||
|
||||
<!-- 방 목록 -->
|
||||
@@ -135,11 +168,20 @@ function fmtSchedule(iso: string): string {
|
||||
</div>
|
||||
</div>
|
||||
<div class="rr-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn rr-invite"
|
||||
:class="{ copied: copiedCode === r.code }"
|
||||
:title="copiedCode === r.code ? '복사됨!' : '초대 링크 복사'"
|
||||
@click="copyInvite(r)"
|
||||
>
|
||||
<MeetingIcon :name="copiedCode === r.code ? 'check' : 'link'" />
|
||||
</button>
|
||||
<button
|
||||
v-if="r.canJoin"
|
||||
type="button"
|
||||
class="btn primary rr-join"
|
||||
@click="emit('join', r.name)"
|
||||
@click="emit('join', r)"
|
||||
>
|
||||
입장
|
||||
</button>
|
||||
@@ -152,6 +194,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 +383,29 @@ function fmtSchedule(iso: string): string {
|
||||
.rr-noaccess {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.rr-invite,
|
||||
.rr-edit,
|
||||
.rr-del {
|
||||
width: 2.25rem;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
.rr-invite svg,
|
||||
.rr-edit svg,
|
||||
.rr-del svg {
|
||||
width: 0.9375rem;
|
||||
height: 0.9375rem;
|
||||
}
|
||||
.rr-invite:hover,
|
||||
.rr-edit:hover {
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text);
|
||||
}
|
||||
/* 초대 링크 복사 완료 — 잠깐 초록 강조 */
|
||||
.rr-invite.copied {
|
||||
color: var(--green);
|
||||
border-color: var(--green);
|
||||
}
|
||||
|
||||
/* 버튼 기본/primary 는 전역 .btn 사용. 여기선 danger·비활성·사이즈 변형만 보강. */
|
||||
.btn.danger {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useApi } from './useApi'
|
||||
import type {
|
||||
ApiMeetingRoom,
|
||||
CreateMeetingRoomPayload,
|
||||
UpdateMeetingRoomPayload,
|
||||
} from '@/types/meeting'
|
||||
|
||||
// 회의 내 역할 — 진행자(방 생성자) / 일반 참여자
|
||||
@@ -19,10 +20,10 @@ export interface MeetingToken {
|
||||
export function useMeeting() {
|
||||
const api = useApi()
|
||||
|
||||
// LiveKit 입장 토큰 발급
|
||||
async function getToken(roomName: string): Promise<MeetingToken> {
|
||||
// LiveKit 입장 토큰 발급 — 방 입장 코드로 요청한다.
|
||||
async function getToken(code: string): Promise<MeetingToken> {
|
||||
return (await api.post('/meetings/token', {
|
||||
roomName,
|
||||
code,
|
||||
})) as unknown as MeetingToken
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -35,10 +35,9 @@ const {
|
||||
sendChat,
|
||||
} = useLiveKitRoom()
|
||||
|
||||
const ROOM_PATTERN = /^[A-Za-z0-9가-힣ㄱ-ㅎㅏ-ㅣ_-]{1,64}$/
|
||||
|
||||
// 프리조인 대상 방 정보(표시용)
|
||||
// 프리조인 대상 방 정보(표시용 + 입장 코드)
|
||||
interface PrejoinTarget {
|
||||
code: string
|
||||
name: string
|
||||
hostName: string | null
|
||||
restricted: boolean
|
||||
@@ -74,51 +73,34 @@ async function loadRooms() {
|
||||
}
|
||||
}
|
||||
|
||||
// 로비에서 방 선택/입력 → 프리조인으로
|
||||
function onJoin(name: string) {
|
||||
const n = (name || '').trim()
|
||||
if (!n) {
|
||||
joinError.value = '방 이름을 입력해 주세요.'
|
||||
return
|
||||
}
|
||||
if (!ROOM_PATTERN.test(n)) {
|
||||
joinError.value =
|
||||
'방 이름은 한글·영문·숫자·-·_ 1~64자만 가능합니다. (공백 불가)'
|
||||
return
|
||||
}
|
||||
const room = rooms.value.find((r) => r.name === n)
|
||||
if (room && room.restricted && !room.canJoin) {
|
||||
joinError.value = `'${n}' 방은 참여가 제한되어 있습니다. 진행자에게 허용을 요청하세요.`
|
||||
// 로비에서 방 선택 → 프리조인으로 (방은 명시적으로 생성된 것만 입장 가능)
|
||||
function onJoin(room: ApiMeetingRoom) {
|
||||
if (room.restricted && !room.canJoin) {
|
||||
joinError.value = `'${room.name}' 방은 참여가 제한되어 있습니다. 진행자에게 허용을 요청하세요.`
|
||||
return
|
||||
}
|
||||
joinError.value = null
|
||||
target.value = room
|
||||
? {
|
||||
name: room.name,
|
||||
hostName: room.host?.name ?? null,
|
||||
restricted: room.restricted,
|
||||
isHost: room.isHost,
|
||||
}
|
||||
: {
|
||||
name: n,
|
||||
hostName: myName.value, // 새 방은 입장자가 진행자가 된다
|
||||
restricted: false,
|
||||
isHost: true,
|
||||
}
|
||||
target.value = {
|
||||
code: room.code,
|
||||
name: room.name,
|
||||
hostName: room.host?.name ?? null,
|
||||
restricted: room.restricted,
|
||||
isHost: room.isHost,
|
||||
}
|
||||
phase.value = 'prejoin'
|
||||
}
|
||||
|
||||
// 프리조인 → 입장(토큰 발급 + 연결)
|
||||
async function onEnter(opts: { mic: boolean; cam: boolean }) {
|
||||
const name = target.value?.name
|
||||
if (!name) return
|
||||
const t = target.value
|
||||
if (!t) return
|
||||
entering.value = true
|
||||
try {
|
||||
const { token, role } = await meetingApi.getToken(name)
|
||||
const { token, role } = await meetingApi.getToken(t.code)
|
||||
myRole.value = role
|
||||
await connect(token, opts)
|
||||
if (route.params.room !== name) {
|
||||
void router.replace(`/meeting/${name}`)
|
||||
if (route.params.code !== t.code) {
|
||||
void router.replace(`/meeting/${t.code}`)
|
||||
}
|
||||
} catch {
|
||||
joinError.value = '입장에 실패했습니다. 잠시 후 다시 시도해 주세요.'
|
||||
@@ -168,8 +150,13 @@ function onCreated() {
|
||||
// 진입 시 방 목록 로드. 공유 링크(/meeting/:room)면 그 방 프리조인으로 바로 이동.
|
||||
onMounted(async () => {
|
||||
await loadRooms()
|
||||
const param = route.params.room
|
||||
if (typeof param === 'string' && param) onJoin(param)
|
||||
// 공유/초대 링크(/meeting/:code) — 코드로 방을 찾아 프리조인. 없으면 안내한다.
|
||||
const param = route.params.code
|
||||
if (typeof param === 'string' && param) {
|
||||
const room = rooms.value.find((r) => r.code === param)
|
||||
if (room) onJoin(room)
|
||||
else joinError.value = '회의 방을 찾을 수 없습니다. 링크를 확인해 주세요.'
|
||||
}
|
||||
})
|
||||
|
||||
// 화면 이탈 시 연결 정리
|
||||
|
||||
@@ -109,8 +109,8 @@ const routes: RouteRecordRaw[] = [
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
// 화상회의 — LiveKit 기반(단계 1: 방 입장/연결). :room 으로 공유 링크 가능
|
||||
path: '/meeting/:room?',
|
||||
// 화상회의 — LiveKit 기반. :code 로 공유/초대 링크 가능(/meeting/<입장코드>)
|
||||
path: '/meeting/:code?',
|
||||
name: 'meeting',
|
||||
component: () => import('@/pages/relay/MeetingPage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// 화상회의 도메인 타입 — 백엔드 MeetingService 응답/DTO 와 1:1
|
||||
import type { ApiMember } from '@/types/repo'
|
||||
|
||||
// 회의 내 역할
|
||||
export type MeetingRole = 'host' | 'participant'
|
||||
@@ -6,12 +7,15 @@ export type MeetingRole = 'host' | 'participant'
|
||||
// 회의 방(API 원시) — 백엔드 MeetingRoomResponse 와 1:1
|
||||
export interface ApiMeetingRoom {
|
||||
id: string
|
||||
code: string // 입장 코드(불변) — 입장 URL·초대 링크의 기준
|
||||
name: string
|
||||
host: { id: string; name: string; avatarUrl: string | null } | null
|
||||
scheduledAt: string | null // ISO, 예약 시각(없으면 null)
|
||||
restricted: boolean // 참여 제한(allow-list) 여부
|
||||
isHost: boolean // 현재 사용자가 진행자
|
||||
canJoin: boolean // 현재 사용자가 입장 가능
|
||||
// 참여 허용 멤버 — 진행자에게만 내려옴(편집 폼 사전 채움용). 공개 방/비진행자는 null.
|
||||
allowedUsers: ApiMember[] | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
@@ -22,6 +26,9 @@ export interface CreateMeetingRoomPayload {
|
||||
allowedUserIds?: string[] // 비우면 전체 공개
|
||||
}
|
||||
|
||||
// 방 수정 요청 — 생성과 동일 필드(진행자가 이름·예약·참여 제한 변경)
|
||||
export type UpdateMeetingRoomPayload = CreateMeetingRoomPayload
|
||||
|
||||
// 회의실 채팅 메시지 — LiveKit 데이터 채널로 주고받는 인메모리 메시지(서버 저장 없음)
|
||||
export interface ChatMessage {
|
||||
id: string
|
||||
|
||||
Reference in New Issue
Block a user