Compare commits
76 Commits
916dd49887
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 01132d2ac3 | |||
| 5ff9bf0df8 | |||
| fb0669990d | |||
| de92fc01b4 | |||
| a65b5aac05 | |||
| 9a79e169c8 | |||
| 2d4f2e2c19 | |||
| 3adb596511 | |||
| bd4f3e70bf | |||
| 8d00d71072 | |||
| 26cad39857 | |||
| 5ee5ad6973 | |||
| f7b8a4c42d | |||
| e42ccf7139 | |||
| ec54ba0e8d | |||
| 2cbd19e791 | |||
| 65796a0893 | |||
| 5267b759be | |||
| 524c6f6782 | |||
| 5a92813a31 | |||
| cf1f4db6af | |||
| f023bd0ed7 | |||
| 638d1265f6 | |||
| 9ce3158450 | |||
| 83d0cda163 | |||
| 46f1875ca3 | |||
| 612c4fec4c | |||
| eebdbc34f5 | |||
| 22a02be937 | |||
| 101b1553b0 | |||
| 2d1026466f | |||
| 5ecc1d7be4 | |||
| bde4b561f3 | |||
| 5866d6ad55 | |||
| 2015b794fc | |||
| 04995ca91a | |||
| 2740205ed5 | |||
| 4ad762881c | |||
| 6bd7fac875 | |||
| 7f6baff425 | |||
| 8d78ba25a2 | |||
| 2313418e9c | |||
| 38a3ecf8a2 | |||
| 0da97ebd21 | |||
| 8f49774aff | |||
| 79194428fc | |||
| 954f4a6932 | |||
| 034afe2cf8 | |||
| 1a17540d91 | |||
| 3ec7e76f7c | |||
| dfe2310661 | |||
| 9ad10afca6 | |||
| 960ff1d420 | |||
| d8d2ff21f9 | |||
| 30b4b87368 | |||
| 999a8730dd | |||
| 251830d421 | |||
| c5a8fc8697 | |||
| b40e795664 | |||
| 88ef92628d | |||
| 423f2197ed | |||
| 54fe3d36e7 | |||
| 2416f728ba | |||
| 3c7aec86a2 | |||
| 9a4d287ffd | |||
| f519723260 | |||
| 85b6b65e24 | |||
| bf2321f2d5 | |||
| 145b4611d3 | |||
| c52630af27 | |||
| 3f2a72ab31 | |||
| e719829f0b | |||
| d964f9101a | |||
| 739a5a70f3 | |||
| 87c9019446 | |||
| 8fc9287747 |
@@ -52,3 +52,40 @@ CORS_ORIGIN=http://localhost:5273
|
||||
# AI_TIMEOUT_MS: AI 요청 타임아웃(ms). 백엔드 Claude 호출 abort + 프론트 axios 에 동일 적용.
|
||||
# 프론트에는 build arg VITE_AI_TIMEOUT_MS 로 주입된다.
|
||||
AI_TIMEOUT_MS=60000
|
||||
|
||||
# ==========================================
|
||||
# 화상회의 (LiveKit Cloud)
|
||||
# ==========================================
|
||||
# 개발은 LiveKit Cloud(무료 dev)를 사용한다. https://cloud.livekit.io 에서 프로젝트 생성 후
|
||||
# Settings → Keys 에서 발급된 값을 채운다. 코드 변경 없이 env 만으로 동작한다.
|
||||
# LIVEKIT_API_KEY / LIVEKIT_API_SECRET : 백엔드 입장 토큰(JWT) 서명용 (절대 커밋 금지)
|
||||
# LIVEKIT_WS_URL : 브라우저 → LiveKit 접속 URL. Cloud 는 wss://<project>.livekit.cloud
|
||||
# (build arg/런타임 env 로 VITE_LIVEKIT_URL 에 주입)
|
||||
# LIVEKIT_URL : 백엔드 → LiveKit 서버 API(RoomService 등) 주소. 보통 LIVEKIT_WS_URL 과 동일.
|
||||
# (단계 1 토큰 발급은 오프라인이라 아직 미사용)
|
||||
# 운영: .env.production 에 운영용 키/URL 만 넣으면 그대로 전환된다(셀프호스팅 시 자체 URL/키 사용).
|
||||
LIVEKIT_API_KEY=APIxxxxxxxxxxxx
|
||||
LIVEKIT_API_SECRET=your-livekit-cloud-api-secret
|
||||
LIVEKIT_WS_URL=wss://your-project.livekit.cloud
|
||||
LIVEKIT_URL=wss://your-project.livekit.cloud
|
||||
|
||||
# ==========================================
|
||||
# 드라이브 (NCP Object Storage / S3 호환)
|
||||
# ==========================================
|
||||
# 파일 업로드/저장용 오브젝트 스토리지. NCP 콘솔에서 버킷 생성 후 값을 채운다.
|
||||
# 비시크릿(endpoint/region/bucket)만 루트에 두고, ACCESS/SECRET 키는 backend/.env 에 둔다.
|
||||
# OBJECT_STORAGE_ENDPOINT : NCP 리전 엔드포인트(서울: https://kr.object.ncloudstorage.com)
|
||||
# OBJECT_STORAGE_REGION : 리전명(NCP: kr-standard)
|
||||
# OBJECT_STORAGE_BUCKET : 버킷명(환경별로 분리 권장, 예: comrelay-dev)
|
||||
OBJECT_STORAGE_ENDPOINT=https://kr.object.ncloudstorage.com
|
||||
OBJECT_STORAGE_REGION=kr-standard
|
||||
OBJECT_STORAGE_BUCKET=comrelay-dev
|
||||
|
||||
# ==========================================
|
||||
# 웹 푸시 알림 (Web Push / VAPID)
|
||||
# ==========================================
|
||||
# 비밀 아닌 값만 루트에서 주입. 키페어 생성:
|
||||
# node -e "console.log(require('web-push').generateVAPIDKeys())"
|
||||
# PRIVATE_KEY 는 backend/.env.<APP_ENV> 에 작성(시크릿). 공개키는 프론트 빌드에도 주입됨.
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_SUBJECT=mailto:admin@example.com
|
||||
|
||||
@@ -52,3 +52,43 @@ CORS_ORIGIN=http://localhost:5173
|
||||
# AI_TIMEOUT_MS: AI 요청 타임아웃(ms). 백엔드 Claude 호출 abort + 프론트 axios 에 동일 적용.
|
||||
# 프론트에는 build arg VITE_AI_TIMEOUT_MS 로 주입된다.
|
||||
AI_TIMEOUT_MS=60000
|
||||
|
||||
# ==========================================
|
||||
# 화상회의 (LiveKit) — 셀프호스팅(기본) / Cloud(대안)
|
||||
# ==========================================
|
||||
# [셀프호스팅] 운영 권장. 절차: livekit/DEPLOY.md (단일노드 3장, AWS EC2+도커 nginx 4-1장).
|
||||
# - selfhost-livekit 프로파일로 컨테이너 기동:
|
||||
# docker compose --profile selfhost-livekit --env-file .env.production up -d --build
|
||||
# - 리버스 프록시(wss 종단)는 livekit/nginx.livekit.conf.example 참고.
|
||||
# - 키 생성(절대 Cloud 키 재사용 금지):
|
||||
# LIVEKIT_API_KEY=$(openssl rand -hex 8)
|
||||
# LIVEKIT_API_SECRET=$(openssl rand -hex 32) # 최소 32자
|
||||
# - LIVEKIT_WS_URL : 브라우저 접속(리버스 프록시 TLS) → wss://livekit.<운영도메인>
|
||||
# - LIVEKIT_URL : 백엔드 → LiveKit 내부 API(도커 네트워크) → http://livekit:7880
|
||||
# [Cloud] 대안. LIVEKIT_WS_URL/URL 을 wss://<project>.livekit.cloud 로, 키를 Cloud 값으로
|
||||
# 채우고 selfhost-livekit 프로파일은 켜지 않는다.
|
||||
LIVEKIT_API_KEY=change-me-livekit-key
|
||||
LIVEKIT_API_SECRET=change-me-livekit-secret-min-32-characters
|
||||
LIVEKIT_WS_URL=wss://livekit.example.com
|
||||
LIVEKIT_URL=http://livekit:7880
|
||||
|
||||
# ==========================================
|
||||
# 드라이브 (NCP Object Storage / S3 호환)
|
||||
# ==========================================
|
||||
# 운영 버킷은 개발과 분리한다(예: comrelay-prod). 비시크릿만 루트에 두고
|
||||
# ACCESS/SECRET 키는 backend/.env.production 에 둔다.
|
||||
# OBJECT_STORAGE_ENDPOINT : NCP 리전 엔드포인트(서울: https://kr.object.ncloudstorage.com)
|
||||
# OBJECT_STORAGE_REGION : 리전명(NCP: kr-standard)
|
||||
# OBJECT_STORAGE_BUCKET : 운영 버킷명
|
||||
OBJECT_STORAGE_ENDPOINT=https://kr.object.ncloudstorage.com
|
||||
OBJECT_STORAGE_REGION=kr-standard
|
||||
OBJECT_STORAGE_BUCKET=comrelay-prod
|
||||
|
||||
# ==========================================
|
||||
# 웹 푸시 알림 (Web Push / VAPID)
|
||||
# ==========================================
|
||||
# 비밀 아닌 값만 루트에서 주입. 키페어 생성:
|
||||
# node -e "console.log(require('web-push').generateVAPIDKeys())"
|
||||
# PRIVATE_KEY 는 backend/.env.<APP_ENV> 에 작성(시크릿). 공개키는 프론트 빌드에도 주입됨.
|
||||
VAPID_PUBLIC_KEY=
|
||||
VAPID_SUBJECT=mailto:admin@example.com
|
||||
|
||||
@@ -54,3 +54,12 @@ Thumbs.db
|
||||
# 디자인 시안 단독 HTML(참고용, 대용량 번들) — 커밋 제외
|
||||
frontend/*단독*.html
|
||||
frontend/*단일 파일*.html
|
||||
|
||||
# LiveKit 클러스터 설정 사본(redis 비밀번호 포함) — 커밋 제외
|
||||
livekit/*.local.yaml
|
||||
|
||||
# 작업 중 공유용 임시 스크린샷(루트) 커밋 방지
|
||||
/image.png
|
||||
|
||||
# Claude Code 메모리 디렉터리(작업 메모) — 커밋 제외
|
||||
/memory/
|
||||
|
||||
@@ -42,3 +42,18 @@ GOOGLE_CALLBACK_URL=http://localhost:3100/api/auth/google/callback
|
||||
# CLAUDE_MODEL : 모델 ID. 미설정 시 claude-sonnet-4-6.
|
||||
CLAUDE_API_KEY=change-me-claude-api-key
|
||||
CLAUDE_MODEL=claude-sonnet-4-6
|
||||
|
||||
# ==========================================
|
||||
# 드라이브 (NCP Object Storage) — 시크릿
|
||||
# ==========================================
|
||||
# NCP 콘솔 → 마이페이지 → 인증키 관리(또는 서브계정)에서 발급한 키.
|
||||
# endpoint/region/bucket 은 루트 .env 에서 docker-compose 가 주입한다.
|
||||
# 미설정 시 드라이브 기능만 비활성화되고 앱은 정상 부팅된다.
|
||||
OBJECT_STORAGE_ACCESS_KEY=change-me-ncp-access-key
|
||||
OBJECT_STORAGE_SECRET_KEY=change-me-ncp-secret-key
|
||||
|
||||
# ==========================================
|
||||
# 웹 푸시 알림 (Web Push / VAPID) — 시크릿(개인키)
|
||||
# ==========================================
|
||||
# PUBLIC_KEY/SUBJECT 는 루트 .env 에서 주입. 미설정 시 푸시 발송만 비활성.
|
||||
VAPID_PRIVATE_KEY=
|
||||
|
||||
@@ -42,3 +42,17 @@ GOOGLE_CALLBACK_URL=https://your-domain.com/api/auth/google/callback
|
||||
# CLAUDE_MODEL : 모델 ID. 미설정 시 claude-sonnet-4-6.
|
||||
CLAUDE_API_KEY=change-me-claude-api-key
|
||||
CLAUDE_MODEL=claude-sonnet-4-6
|
||||
|
||||
# ==========================================
|
||||
# 드라이브 (NCP Object Storage) — 시크릿
|
||||
# ==========================================
|
||||
# NCP 콘솔에서 발급한 운영용 키. endpoint/region/bucket 은 루트 .env 가 주입.
|
||||
# 운영은 권한 최소화를 위해 해당 버킷에만 접근 가능한 서브계정 키 사용 권장.
|
||||
OBJECT_STORAGE_ACCESS_KEY=change-me-ncp-access-key
|
||||
OBJECT_STORAGE_SECRET_KEY=change-me-ncp-secret-key
|
||||
|
||||
# ==========================================
|
||||
# 웹 푸시 알림 (Web Push / VAPID) — 시크릿(개인키)
|
||||
# ==========================================
|
||||
# PUBLIC_KEY/SUBJECT 는 루트 .env 에서 주입. 미설정 시 푸시 발송만 비활성.
|
||||
VAPID_PRIVATE_KEY=
|
||||
|
||||
Generated
+1884
-111
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,8 @@
|
||||
"migration:revert": "typeorm-ts-node-commonjs migration:revert -d ./src/data-source.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1075.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1075.0",
|
||||
"@keyv/redis": "^5.1.6",
|
||||
"@nestjs/cache-manager": "^3.0.1",
|
||||
"@nestjs/common": "^11.0.1",
|
||||
@@ -39,23 +41,29 @@
|
||||
"@nestjs/throttler": "^6.4.0",
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"@types/passport-google-oauth20": "^2.0.17",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"cache-manager": "^6.4.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.4",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"exceljs": "^4.4.0",
|
||||
"express-rate-limit": "^8.3.2",
|
||||
"helmet": "^8.1.0",
|
||||
"ioredis": "^5.11.1",
|
||||
"livekit-server-sdk": "^2.15.5",
|
||||
"mammoth": "^1.12.0",
|
||||
"nest-winston": "^1.10.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"passport-kakao": "^1.0.1",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"pg": "^8.20.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"typeorm": "^0.3.28",
|
||||
"web-push": "^3.6.7",
|
||||
"winston": "^3.17.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { APP_GUARD } from '@nestjs/core';
|
||||
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { StorageModule } from './common/storage/storage.module';
|
||||
import { HealthModule } from './modules/health/health.module';
|
||||
import { UserModule } from './modules/user/user.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
@@ -19,6 +20,8 @@ import { NotificationModule } from './modules/notification/notification.module';
|
||||
import { AiModule } from './modules/ai/ai.module';
|
||||
import { ProjectModule } from './modules/project/project.module';
|
||||
import { ScheduleModule } from './modules/schedule/schedule.module';
|
||||
import { MeetingModule } from './modules/meeting/meeting.module';
|
||||
import { DriveModule } from './modules/drive/drive.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -91,6 +94,7 @@ import { ScheduleModule } from './modules/schedule/schedule.module';
|
||||
};
|
||||
},
|
||||
}),
|
||||
StorageModule,
|
||||
HealthModule,
|
||||
UserModule,
|
||||
AuthModule,
|
||||
@@ -100,6 +104,8 @@ import { ScheduleModule } from './modules/schedule/schedule.module';
|
||||
AiModule,
|
||||
ProjectModule,
|
||||
ScheduleModule,
|
||||
MeetingModule,
|
||||
DriveModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ValueTransformer } from 'typeorm';
|
||||
|
||||
// bigint 컬럼 변환기 — TypeORM 은 bigint 를 문자열로 반환하므로 number 로 복원한다.
|
||||
// (파일 크기 등 Number.MAX_SAFE_INTEGER 이내 값에 사용)
|
||||
export const numericColumnTransformer: ValueTransformer = {
|
||||
// 저장: number → DB(bigint). null/undefined 는 그대로.
|
||||
to(value: number | null | undefined): number | null | undefined {
|
||||
return value;
|
||||
},
|
||||
// 조회: DB 문자열 → number. null 은 그대로.
|
||||
from(value: string | null): number | null {
|
||||
return value === null ? null : Number(value);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,187 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
HeadObjectCommand,
|
||||
PutObjectCommand,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { StorageService } from './storage.service';
|
||||
import type {
|
||||
ObjectHead,
|
||||
PresignDownloadInput,
|
||||
PresignUploadInput,
|
||||
PresignedUpload,
|
||||
} from './storage.types';
|
||||
|
||||
// presigned URL 기본 유효 시간(초)
|
||||
const DEFAULT_UPLOAD_EXPIRES = 300; // 업로드: 5분
|
||||
const DEFAULT_DOWNLOAD_EXPIRES = 60; // 다운로드: 1분(단기 노출)
|
||||
|
||||
// NCP Object Storage 구현체 — S3 호환 API 를 그대로 사용한다.
|
||||
// 환경변수(SSOT: 루트 .env → docker-compose 주입 / 시크릿은 backend 자체 env):
|
||||
// OBJECT_STORAGE_ENDPOINT 예) https://kr.object.ncloudstorage.com
|
||||
// OBJECT_STORAGE_REGION 예) kr-standard
|
||||
// OBJECT_STORAGE_BUCKET 버킷명
|
||||
// OBJECT_STORAGE_ACCESS_KEY 서브계정 Access Key (시크릿)
|
||||
// OBJECT_STORAGE_SECRET_KEY 서브계정 Secret Key (시크릿)
|
||||
@Injectable()
|
||||
export class NcpObjectStorageService extends StorageService {
|
||||
private readonly logger = new Logger(NcpObjectStorageService.name);
|
||||
// 설정 누락 시에도 앱은 부팅되며(드라이브 외 기능 정상), 실제 사용 시점에 실패한다.
|
||||
private readonly client: S3Client | null;
|
||||
private readonly bucketName: string;
|
||||
private readonly missing: string[];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const endpoint = process.env.OBJECT_STORAGE_ENDPOINT;
|
||||
const region = process.env.OBJECT_STORAGE_REGION || 'kr-standard';
|
||||
const bucket = process.env.OBJECT_STORAGE_BUCKET;
|
||||
const accessKeyId = process.env.OBJECT_STORAGE_ACCESS_KEY;
|
||||
const secretAccessKey = process.env.OBJECT_STORAGE_SECRET_KEY;
|
||||
|
||||
this.bucketName = bucket || '';
|
||||
this.missing = [
|
||||
['OBJECT_STORAGE_ENDPOINT', endpoint],
|
||||
['OBJECT_STORAGE_BUCKET', bucket],
|
||||
['OBJECT_STORAGE_ACCESS_KEY', accessKeyId],
|
||||
['OBJECT_STORAGE_SECRET_KEY', secretAccessKey],
|
||||
]
|
||||
.filter(([, v]) => !v)
|
||||
.map(([k]) => k as string);
|
||||
|
||||
if (this.missing.length > 0) {
|
||||
// 부팅은 통과시키되 경고를 남긴다 — 드라이브 API 호출 시 ensureConfigured() 가 막는다.
|
||||
this.client = null;
|
||||
this.logger.warn(
|
||||
`오브젝트 스토리지 미설정(드라이브 비활성): ${this.missing.join(', ')} 누락`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.client = new S3Client({
|
||||
endpoint,
|
||||
region,
|
||||
// NCP/MinIO 등 S3 호환 스토리지는 path-style 접근이 필요(가상호스트 도메인 미지원)
|
||||
forcePathStyle: true,
|
||||
credentials: {
|
||||
accessKeyId: accessKeyId as string,
|
||||
secretAccessKey: secretAccessKey as string,
|
||||
},
|
||||
});
|
||||
this.logger.log(
|
||||
`오브젝트 스토리지 초기화: endpoint=${endpoint}, bucket=${this.bucketName}`,
|
||||
);
|
||||
}
|
||||
|
||||
get bucket(): string {
|
||||
return this.bucketName;
|
||||
}
|
||||
|
||||
// 설정이 완료되어 실제 호출 가능한지 보장 — 미설정 시 명확한 에러로 막는다.
|
||||
private ensureConfigured(): S3Client {
|
||||
if (!this.client) {
|
||||
throw new Error(
|
||||
`오브젝트 스토리지 설정 누락: ${this.missing.join(', ')} 를 환경변수로 지정해 주세요.`,
|
||||
);
|
||||
}
|
||||
return this.client;
|
||||
}
|
||||
|
||||
async presignUpload(input: PresignUploadInput): Promise<PresignedUpload> {
|
||||
const client = this.ensureConfigured();
|
||||
const expiresIn = input.expiresInSeconds ?? DEFAULT_UPLOAD_EXPIRES;
|
||||
const command = new PutObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: input.key,
|
||||
// ContentType 을 서명에 포함 → 클라이언트는 동일 Content-Type 헤더로만 업로드 가능
|
||||
ContentType: input.contentType,
|
||||
});
|
||||
const url = await getSignedUrl(client, command, { expiresIn });
|
||||
return {
|
||||
url,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': input.contentType },
|
||||
expiresAt: new Date(Date.now() + expiresIn * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
async presignDownload(input: PresignDownloadInput): Promise<string> {
|
||||
const client = this.ensureConfigured();
|
||||
const expiresIn = input.expiresInSeconds ?? DEFAULT_DOWNLOAD_EXPIRES;
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: this.bucketName,
|
||||
Key: input.key,
|
||||
// 다운로드 강제 + 원본 파일명 복원(비ASCII 안전) + 타입 고정
|
||||
ResponseContentDisposition: `attachment; filename*=UTF-8''${encodeURIComponent(
|
||||
input.filename,
|
||||
)}`,
|
||||
ResponseContentType: input.contentType,
|
||||
});
|
||||
return getSignedUrl(client, command, { expiresIn });
|
||||
}
|
||||
|
||||
async getObjectBuffer(key: string): Promise<Buffer | null> {
|
||||
const client = this.ensureConfigured();
|
||||
try {
|
||||
const res = await client.send(
|
||||
new GetObjectCommand({ Bucket: this.bucketName, Key: key }),
|
||||
);
|
||||
const body = res.Body as
|
||||
| { transformToByteArray?: () => Promise<Uint8Array> }
|
||||
| undefined;
|
||||
// AWS SDK v3 의 스트림 → 바이트 배열(transformToByteArray)로 수집
|
||||
if (!body?.transformToByteArray) return null;
|
||||
const bytes = await body.transformToByteArray();
|
||||
return Buffer.from(bytes);
|
||||
} catch (err: unknown) {
|
||||
if (this.isNotFound(err)) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async headObject(key: string): Promise<ObjectHead | null> {
|
||||
const client = this.ensureConfigured();
|
||||
try {
|
||||
const res = await client.send(
|
||||
new HeadObjectCommand({ Bucket: this.bucketName, Key: key }),
|
||||
);
|
||||
return {
|
||||
size: res.ContentLength ?? 0,
|
||||
contentType: res.ContentType ?? null,
|
||||
};
|
||||
} catch (err: unknown) {
|
||||
// 미존재(404/NotFound)는 정상 흐름 → null. 그 외 오류는 상위로 전파.
|
||||
if (this.isNotFound(err)) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteObject(key: string): Promise<void> {
|
||||
const client = this.ensureConfigured();
|
||||
try {
|
||||
await client.send(
|
||||
new DeleteObjectCommand({ Bucket: this.bucketName, Key: key }),
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
// 이미 없으면 멱등 처리(삭제 목적은 달성). 그 외 오류는 전파.
|
||||
if (this.isNotFound(err)) return;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// AWS SDK v3 의 미존재 응답 판별(상태코드 404 또는 NotFound 계열 name)
|
||||
private isNotFound(err: unknown): boolean {
|
||||
const e = err as {
|
||||
name?: string;
|
||||
$metadata?: { httpStatusCode?: number };
|
||||
};
|
||||
return (
|
||||
e?.$metadata?.httpStatusCode === 404 ||
|
||||
e?.name === 'NotFound' ||
|
||||
e?.name === 'NoSuchKey'
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { StorageService } from './storage.service';
|
||||
import { NcpObjectStorageService } from './ncp-object-storage.service';
|
||||
|
||||
// 스토리지 모듈 — StorageService 토큰을 NCP Object Storage 구현체에 바인딩한다.
|
||||
// @Global 로 등록해 어느 모듈에서든 StorageService 를 주입받을 수 있다.
|
||||
// (구현체 교체 시 useClass 만 변경하면 됨 — 예: 로컬 디스크/MinIO)
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: StorageService,
|
||||
useClass: NcpObjectStorageService,
|
||||
},
|
||||
],
|
||||
exports: [StorageService],
|
||||
})
|
||||
export class StorageModule {}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type {
|
||||
ObjectHead,
|
||||
PresignDownloadInput,
|
||||
PresignUploadInput,
|
||||
PresignedUpload,
|
||||
} from './storage.types';
|
||||
|
||||
// 오브젝트 스토리지 추상 인터페이스.
|
||||
// abstract class 를 DI 토큰으로 사용한다(NestJS 권장 패턴) — 구현체는 storage.module 에서 주입.
|
||||
// 바이너리는 백엔드를 거치지 않고 클라이언트가 presigned URL 로 직접 송수신한다.
|
||||
export abstract class StorageService {
|
||||
// 현재 사용 중인 버킷 이름(메타데이터에 함께 저장해 추후 버킷 이전 대비)
|
||||
abstract get bucket(): string;
|
||||
|
||||
// 업로드용 presigned URL 발급(PUT). contentType 이 서명에 포함된다.
|
||||
abstract presignUpload(input: PresignUploadInput): Promise<PresignedUpload>;
|
||||
|
||||
// 다운로드용 단기 presigned URL 발급(GET). 파일명/타입을 응답 헤더로 강제한다.
|
||||
abstract presignDownload(input: PresignDownloadInput): Promise<string>;
|
||||
|
||||
// 객체 메타 조회 — 미존재 시 null. 업로드 완료(complete) 검증에 사용.
|
||||
abstract headObject(key: string): Promise<ObjectHead | null>;
|
||||
|
||||
// 객체 바이트 다운로드 — 서버 측 텍스트 추출(AI 검토) 등에 사용. 미존재 시 null.
|
||||
abstract getObjectBuffer(key: string): Promise<Buffer | null>;
|
||||
|
||||
// 객체 삭제(파일/미완료 업로드 정리). 미존재여도 에러 없이 통과.
|
||||
abstract deleteObject(key: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// 스토리지 추상화 공용 타입 — 구현체(NCP Object Storage 등)와 호출부가 공유한다.
|
||||
|
||||
// Presigned 업로드 발급 입력
|
||||
export interface PresignUploadInput {
|
||||
// 버킷 내 객체 키 (예: development/drive/{userId}/{uuid}-name.pdf)
|
||||
key: string;
|
||||
// 업로드 시 강제할 MIME 타입 — 서명에 포함되어 클라이언트가 다른 타입으로 못 올림
|
||||
contentType: string;
|
||||
// URL 유효 시간(초). 미지정 시 구현체 기본값
|
||||
expiresInSeconds?: number;
|
||||
}
|
||||
|
||||
// Presigned 업로드 발급 결과
|
||||
export interface PresignedUpload {
|
||||
// 클라이언트가 직접 PUT 할 URL
|
||||
url: string;
|
||||
// HTTP 메서드 (PUT 고정)
|
||||
method: 'PUT';
|
||||
// 업로드 요청 시 반드시 포함해야 하는 헤더(서명 대상) — 예: { 'Content-Type': '...' }
|
||||
headers: Record<string, string>;
|
||||
// 만료 시각(클라이언트 표시/검증용)
|
||||
expiresAt: Date;
|
||||
}
|
||||
|
||||
// Presigned 다운로드 발급 입력
|
||||
export interface PresignDownloadInput {
|
||||
key: string;
|
||||
// 다운로드 시 표시할 원본 파일명(Content-Disposition 에 반영)
|
||||
filename: string;
|
||||
// 응답 Content-Type
|
||||
contentType: string;
|
||||
// URL 유효 시간(초). 미지정 시 구현체 기본값
|
||||
expiresInSeconds?: number;
|
||||
}
|
||||
|
||||
// HEAD 조회 결과 — 업로드 완료 검증(실제 size/type 재확인)에 사용
|
||||
export interface ObjectHead {
|
||||
// 실제 저장된 바이트 크기
|
||||
size: number;
|
||||
// 실제 저장된 MIME 타입(없을 수 있음)
|
||||
contentType: string | null;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 프로젝트 문서 첨부 — project_attachments 테이블 추가.
|
||||
// (업무 첨부 attachments 와 동일 구조, project_id FK)
|
||||
export class AddProjectAttachments1782500000000 implements MigrationInterface {
|
||||
name = 'AddProjectAttachments1782500000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "project_attachments" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "stored_name" character varying NOT NULL, "size" integer NOT NULL, "mime" character varying NOT NULL, "kind" character varying NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "project_id" uuid, "uploader_id" uuid, CONSTRAINT "PK_project_attachments" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_project_attachments_project" ON "project_attachments" ("project_id")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_attachments" ADD CONSTRAINT "FK_project_attachments_project" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_attachments" ADD CONSTRAINT "FK_project_attachments_uploader" FOREIGN KEY ("uploader_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_attachments" DROP CONSTRAINT "FK_project_attachments_uploader"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_attachments" DROP CONSTRAINT "FK_project_attachments_project"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE "project_attachments"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 드라이브 기능 — drive_folders / drive_files / project_folder_links 테이블 추가.
|
||||
// - drive_folders: 사용자 소유 폴더 트리(adjacency list + materialized path)
|
||||
// - drive_files: 오브젝트 스토리지 객체 메타(presign 업로드, status 로 완료 관리)
|
||||
// - project_folder_links: 폴더를 프로젝트에 연동(공유), 권한(read/read-write)
|
||||
export class AddDriveTables1782600000000 implements MigrationInterface {
|
||||
name = 'AddDriveTables1782600000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// ===== drive_folders =====
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "drive_folders" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "path" text NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "owner_id" uuid, "parent_id" uuid, CONSTRAINT "PK_drive_folders" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_drive_folders_owner" ON "drive_folders" ("owner_id")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_drive_folders_parent" ON "drive_folders" ("parent_id")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_drive_folders_path" ON "drive_folders" ("path")`,
|
||||
);
|
||||
|
||||
// ===== drive_files =====
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "drive_files" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "bucket" character varying NOT NULL, "object_key" character varying NOT NULL, "size" bigint NOT NULL, "mime" character varying NOT NULL, "kind" character varying NOT NULL, "status" character varying NOT NULL DEFAULT 'pending', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "owner_id" uuid, "folder_id" uuid, CONSTRAINT "PK_drive_files" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "IDX_drive_files_object_key" ON "drive_files" ("object_key")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_drive_files_owner" ON "drive_files" ("owner_id")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_drive_files_folder" ON "drive_files" ("folder_id")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_drive_files_status" ON "drive_files" ("status")`,
|
||||
);
|
||||
|
||||
// ===== project_folder_links =====
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "project_folder_links" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "permission" character varying NOT NULL DEFAULT 'read', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "project_id" uuid, "folder_id" uuid, "linked_by_id" uuid, CONSTRAINT "PK_project_folder_links" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_project_folder_links_project" ON "project_folder_links" ("project_id")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_project_folder_links_folder" ON "project_folder_links" ("folder_id")`,
|
||||
);
|
||||
// 한 폴더는 한 프로젝트에 한 번만 연동
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_folder_links" ADD CONSTRAINT "UQ_project_folder_link" UNIQUE ("project_id", "folder_id")`,
|
||||
);
|
||||
|
||||
// ===== 외래키 =====
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_folders" ADD CONSTRAINT "FK_drive_folders_owner" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_folders" ADD CONSTRAINT "FK_drive_folders_parent" FOREIGN KEY ("parent_id") REFERENCES "drive_folders"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_files" ADD CONSTRAINT "FK_drive_files_owner" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_files" ADD CONSTRAINT "FK_drive_files_folder" FOREIGN KEY ("folder_id") REFERENCES "drive_folders"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_folder_links" ADD CONSTRAINT "FK_project_folder_links_project" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_folder_links" ADD CONSTRAINT "FK_project_folder_links_folder" FOREIGN KEY ("folder_id") REFERENCES "drive_folders"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_folder_links" ADD CONSTRAINT "FK_project_folder_links_linked_by" FOREIGN KEY ("linked_by_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// 외래키 → 테이블 순서 역순 정리
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_folder_links" DROP CONSTRAINT "FK_project_folder_links_linked_by"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_folder_links" DROP CONSTRAINT "FK_project_folder_links_folder"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_folder_links" DROP CONSTRAINT "FK_project_folder_links_project"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_files" DROP CONSTRAINT "FK_drive_files_folder"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_files" DROP CONSTRAINT "FK_drive_files_owner"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_folders" DROP CONSTRAINT "FK_drive_folders_parent"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_folders" DROP CONSTRAINT "FK_drive_folders_owner"`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`DROP TABLE "project_folder_links"`);
|
||||
await queryRunner.query(`DROP TABLE "drive_files"`);
|
||||
await queryRunner.query(`DROP TABLE "drive_folders"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 프로젝트 첨부 문서의 추출 본문 캐시 컬럼 추가.
|
||||
// 업로드 시 1회 추출해 보관하고, AI 업무 추천에서 꺼내 읽는다.
|
||||
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
|
||||
export class AddProjectAttachmentExtractedText1782600000000 implements MigrationInterface {
|
||||
name = 'AddProjectAttachmentExtractedText1782600000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_attachments" ADD "extracted_text" text`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_attachments" DROP COLUMN "extracted_text"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 화상회의 방(meeting_rooms) 테이블 추가 — 방 이름(고유)과 진행자(host) 보관.
|
||||
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
|
||||
export class AddMeetingRooms1782700000000 implements MigrationInterface {
|
||||
name = 'AddMeetingRooms1782700000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "meeting_rooms" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "host_id" uuid, CONSTRAINT "PK_meeting_rooms" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX "IDX_meeting_rooms_name" ON "meeting_rooms" ("name")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "meeting_rooms" ADD CONSTRAINT "FK_meeting_rooms_host" FOREIGN KEY ("host_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "meeting_rooms" DROP CONSTRAINT "FK_meeting_rooms_host"`,
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_meeting_rooms_name"`);
|
||||
await queryRunner.query(`DROP TABLE "meeting_rooms"`);
|
||||
}
|
||||
}
|
||||
@@ -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,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"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 프로젝트 AI 검토 문서를 "드라이브 폴더 연동"으로 전환.
|
||||
// - drive_files 에 AI 추출 캐시 컬럼(extracted_text, extract_status) 추가
|
||||
// → 연동 폴더 문서를 NCP 에서 받아 텍스트 추출·캐싱(AI 업무 추천 주입)
|
||||
// - 프로젝트 직접 업로드(project_attachments) 폐기 → 테이블 제거(완전 대체)
|
||||
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
|
||||
//
|
||||
// 멱등·견고하게 작성: 과거 synchronize 로 만들어진 운영 DB(제약 이름 상이)나
|
||||
// 부분 적용 상태에서도 실패하지 않도록 IF [NOT] EXISTS / CASCADE 를 사용한다.
|
||||
export class AddDriveFileExtractDropProjectAttachments1783000000000 implements MigrationInterface {
|
||||
name = 'AddDriveFileExtractDropProjectAttachments1783000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// 1) drive_files AI 추출 캐시 컬럼 (이미 있으면 건너뜀)
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_files" ADD COLUMN IF NOT EXISTS "extracted_text" text`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_files" ADD COLUMN IF NOT EXISTS "extract_status" character varying NOT NULL DEFAULT 'pending'`,
|
||||
);
|
||||
|
||||
// 2) project_attachments 폐기 (드라이브 연동으로 대체)
|
||||
// 제약 이름에 의존하지 않도록 CASCADE 로 테이블·FK 를 한 번에 제거.
|
||||
await queryRunner.query(
|
||||
`DROP TABLE IF EXISTS "project_attachments" CASCADE`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// 1) project_attachments 복원 (AddProjectAttachments + ExtractedText 와 동일 스키마)
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE IF NOT EXISTS "project_attachments" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying NOT NULL, "stored_name" character varying NOT NULL, "size" integer NOT NULL, "mime" character varying NOT NULL, "kind" character varying NOT NULL, "extracted_text" text, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "project_id" uuid, "uploader_id" uuid, CONSTRAINT "PK_project_attachments" PRIMARY KEY ("id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_project_attachments_project" ON "project_attachments" ("project_id")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_attachments" ADD CONSTRAINT "FK_project_attachments_project" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "project_attachments" ADD CONSTRAINT "FK_project_attachments_uploader" FOREIGN KEY ("uploader_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
|
||||
);
|
||||
|
||||
// 2) drive_files 추출 캐시 컬럼 제거
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_files" DROP COLUMN IF EXISTS "extract_status"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_files" DROP COLUMN IF EXISTS "extracted_text"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 드라이브 파일 삭제 감사(소프트 삭제) 컬럼 추가.
|
||||
// - deleted_at: 삭제 시각(null 이면 미삭제)
|
||||
// - deleted_by_id: 삭제한 사용자(FK users, 사용자 삭제 시 SET NULL)
|
||||
// 파일 삭제 시 실제 객체는 즉시 영구 삭제하되, 메타 행은 status='deleted' 묘비로 보존해
|
||||
// "누가·언제 지웠는지" 추적한다. status 는 기존 varchar 라 값만 추가(스키마 변경 없음).
|
||||
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
|
||||
// 멱등·견고: IF NOT EXISTS / 제약 존재 검사로 재실행·부분적용 상태에서도 실패하지 않는다.
|
||||
export class AddDriveFileSoftDeleteAudit1783100000000 implements MigrationInterface {
|
||||
name = 'AddDriveFileSoftDeleteAudit1783100000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_files" ADD COLUMN IF NOT EXISTS "deleted_at" TIMESTAMP`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_files" ADD COLUMN IF NOT EXISTS "deleted_by_id" uuid`,
|
||||
);
|
||||
// FK 는 제약 이름 존재 검사로 중복 추가를 방지(IF NOT EXISTS 미지원 대비)
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'FK_drive_files_deleted_by'
|
||||
) THEN
|
||||
ALTER TABLE "drive_files"
|
||||
ADD CONSTRAINT "FK_drive_files_deleted_by"
|
||||
FOREIGN KEY ("deleted_by_id") REFERENCES "users"("id")
|
||||
ON DELETE SET NULL ON UPDATE NO ACTION;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_files" DROP CONSTRAINT IF EXISTS "FK_drive_files_deleted_by"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_files" DROP COLUMN IF EXISTS "deleted_by_id"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_files" DROP COLUMN IF EXISTS "deleted_at"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 전사 공유 드라이브 전환 + 전역 감사 로그.
|
||||
// - drive_activities: 누가 무엇을(추가/이름변경/삭제) 했는지 전역 감사 로그(프로젝트 무관).
|
||||
// - drive_files / drive_folders 의 owner_id FK: CASCADE → SET NULL 로 변경
|
||||
// (공유 드라이브에서 업로더/생성자가 탈퇴해도 공유 파일·폴더는 보존).
|
||||
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
|
||||
// 멱등·견고: IF NOT EXISTS / owner_id 의 기존 FK 를 동적으로 모두 제거 후 재생성(제약 이름 상이 대비).
|
||||
export class AddDriveSharedAndAudit1783200000000 implements MigrationInterface {
|
||||
name = 'AddDriveSharedAndAudit1783200000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// ===== 감사 로그 테이블 =====
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "drive_activities" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"type" character varying NOT NULL,
|
||||
"payload" jsonb NOT NULL DEFAULT '{}',
|
||||
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
"actor_id" uuid,
|
||||
CONSTRAINT "PK_drive_activities" PRIMARY KEY ("id")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_drive_activities_actor" ON "drive_activities" ("actor_id")`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'FK_drive_activities_actor'
|
||||
) THEN
|
||||
ALTER TABLE "drive_activities"
|
||||
ADD CONSTRAINT "FK_drive_activities_actor"
|
||||
FOREIGN KEY ("actor_id") REFERENCES "users"("id")
|
||||
ON DELETE SET NULL ON UPDATE NO ACTION;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
|
||||
// ===== owner_id FK 를 SET NULL 로 교체(drive_files, drive_folders) =====
|
||||
for (const table of ['drive_files', 'drive_folders']) {
|
||||
// 혹시 NOT NULL 이면 해제(공유 파일은 업로더 없이도 존재 가능)
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "${table}" ALTER COLUMN "owner_id" DROP NOT NULL`,
|
||||
);
|
||||
// owner_id 컬럼에 걸린 기존 외래키를 이름과 무관하게 모두 제거 → SET NULL 로 재생성
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
DECLARE r record;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT con.conname
|
||||
FROM pg_constraint con
|
||||
JOIN pg_class rel ON rel.oid = con.conrelid
|
||||
JOIN pg_attribute att
|
||||
ON att.attrelid = con.conrelid AND att.attnum = ANY(con.conkey)
|
||||
WHERE rel.relname = '${table}'
|
||||
AND con.contype = 'f'
|
||||
AND att.attname = 'owner_id'
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE "${table}" DROP CONSTRAINT %I', r.conname);
|
||||
END LOOP;
|
||||
ALTER TABLE "${table}"
|
||||
ADD CONSTRAINT "FK_${table}_owner"
|
||||
FOREIGN KEY ("owner_id") REFERENCES "users"("id")
|
||||
ON DELETE SET NULL ON UPDATE NO ACTION;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// owner_id FK 를 CASCADE 로 되돌린다(이름 무관 제거 후 재생성)
|
||||
for (const table of ['drive_files', 'drive_folders']) {
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
DECLARE r record;
|
||||
BEGIN
|
||||
FOR r IN
|
||||
SELECT con.conname
|
||||
FROM pg_constraint con
|
||||
JOIN pg_class rel ON rel.oid = con.conrelid
|
||||
JOIN pg_attribute att
|
||||
ON att.attrelid = con.conrelid AND att.attnum = ANY(con.conkey)
|
||||
WHERE rel.relname = '${table}'
|
||||
AND con.contype = 'f'
|
||||
AND att.attname = 'owner_id'
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE "${table}" DROP CONSTRAINT %I', r.conname);
|
||||
END LOOP;
|
||||
ALTER TABLE "${table}"
|
||||
ADD CONSTRAINT "FK_${table}_owner"
|
||||
FOREIGN KEY ("owner_id") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "drive_activities" DROP CONSTRAINT IF EXISTS "FK_drive_activities_actor"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "drive_activities"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 웹 푸시 구독 테이블(push_subscriptions) 추가 — 브라우저 PushSubscription 을 사용자별로 보관.
|
||||
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
|
||||
// 멱등·견고: IF NOT EXISTS / 제약 존재 검사로 재실행·부분적용 상태에서도 실패하지 않는다.
|
||||
export class AddPushSubscriptions1783300000000 implements MigrationInterface {
|
||||
name = 'AddPushSubscriptions1783300000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "push_subscriptions" (
|
||||
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
"endpoint" text NOT NULL,
|
||||
"p256dh" text NOT NULL,
|
||||
"auth" text NOT NULL,
|
||||
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
|
||||
"user_id" uuid,
|
||||
CONSTRAINT "PK_push_subscriptions" PRIMARY KEY ("id")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_push_subscriptions_endpoint" ON "push_subscriptions" ("endpoint")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX IF NOT EXISTS "IDX_push_subscriptions_user" ON "push_subscriptions" ("user_id")`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'FK_push_subscriptions_user'
|
||||
) THEN
|
||||
ALTER TABLE "push_subscriptions"
|
||||
ADD CONSTRAINT "FK_push_subscriptions_user"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE ON UPDATE NO ACTION;
|
||||
END IF;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "push_subscriptions" DROP CONSTRAINT IF EXISTS "FK_push_subscriptions_user"`,
|
||||
);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "push_subscriptions"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 공동 지시자 기능 — 업무별 공동 지시자(task_co_issuers) 다대다 조인 테이블 추가.
|
||||
// 원 지시자(tasks.issuer_id)와 함께 완료 검토(승인/수정요청) 권한을 가진다.
|
||||
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
|
||||
export class AddTaskCoIssuers1783400000000 implements MigrationInterface {
|
||||
name = 'AddTaskCoIssuers1783400000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// 공동 지시자 조인 테이블(task_assignees 와 동일 구조)
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "task_co_issuers" ("task_id" uuid NOT NULL, "user_id" uuid NOT NULL, CONSTRAINT "PK_task_co_issuers" PRIMARY KEY ("task_id", "user_id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_task_co_issuers_task" ON "task_co_issuers" ("task_id")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_task_co_issuers_user" ON "task_co_issuers" ("user_id")`,
|
||||
);
|
||||
|
||||
// FK — 업무/사용자 삭제 시 조인 행도 함께 삭제
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "task_co_issuers" ADD CONSTRAINT "FK_task_co_issuers_task" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE CASCADE`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "task_co_issuers" ADD CONSTRAINT "FK_task_co_issuers_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "task_co_issuers" DROP CONSTRAINT "FK_task_co_issuers_user"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "task_co_issuers" DROP CONSTRAINT "FK_task_co_issuers_task"`,
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_task_co_issuers_user"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_task_co_issuers_task"`);
|
||||
await queryRunner.query(`DROP TABLE "task_co_issuers"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 검토자 기록 — 업무의 최근 검토(승인/수정요청) 수행자(tasks.reviewed_by) 컬럼 추가.
|
||||
// 원 지시자·공동 지시자 중 실제로 검토한 사람을 저장해, 완료/수정요청 카드에 정확한 검토자를 표기한다.
|
||||
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
|
||||
export class AddTaskReviewedBy1783500000000 implements MigrationInterface {
|
||||
name = 'AddTaskReviewedBy1783500000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "tasks" ADD "reviewed_by" uuid`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "tasks" ADD CONSTRAINT "FK_tasks_reviewed_by" FOREIGN KEY ("reviewed_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "tasks" DROP CONSTRAINT "FK_tasks_reviewed_by"`,
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "tasks" DROP COLUMN "reviewed_by"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 마감 임박·초과 알림(cron) 중복 발송 방지 플래그 — tasks 에 발송 시각 컬럼 2개 추가.
|
||||
// 마감이 변경되면 서비스에서 다시 null 로 초기화해 새 마감 기준으로 재알림한다.
|
||||
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
|
||||
export class AddTaskDueNotifyFlags1783600000000 implements MigrationInterface {
|
||||
name = 'AddTaskDueNotifyFlags1783600000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "tasks" ADD "due_soon_notified_at" TIMESTAMP WITH TIME ZONE`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "tasks" ADD "overdue_notified_at" TIMESTAMP WITH TIME ZONE`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "tasks" DROP COLUMN "overdue_notified_at"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "tasks" DROP COLUMN "due_soon_notified_at"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,11 @@ export type ActivityType =
|
||||
| 'task.edited'
|
||||
| 'task.checkpoint_reported'
|
||||
| 'task.file_removed'
|
||||
| 'task.removed';
|
||||
| 'task.removed'
|
||||
// 드라이브(프로젝트 연동 폴더 한정 — 누가 올렸/지웠는지 추적)
|
||||
| 'drive.file_uploaded'
|
||||
| 'drive.file_deleted'
|
||||
| 'drive.folder_deleted';
|
||||
|
||||
// 활동 이벤트 — 행위 타입 + 행위자 + 대상(payload)만 저장하고
|
||||
// 표시 문구/아이콘/톤은 프론트가 조립한다(파생값 규칙).
|
||||
|
||||
@@ -84,10 +84,12 @@ const SUGGEST_SYSTEM_PROMPT = [
|
||||
'',
|
||||
'[규칙]',
|
||||
'- 반드시 아래 JSON 형식으로만 응답하세요. 설명·인사·마크다운 코드펜스 없이 순수 JSON 만 출력합니다.',
|
||||
'- 출력은 완결된 단일 JSON 객체여야 합니다. 모든 객체는 중괄호 {}로 감싸고, 배열 []·문자열 "" 을 정확히 닫으세요. 중간에 끊지 마세요.',
|
||||
'- 카테고리는 정확히 "필수 기능", "관리 기능", "보안 기능", "부가 기능" 4개를 이 순서대로 사용하세요. (다른 이름·추가/누락 카테고리 금지)',
|
||||
'- 각 카테고리에 필요한 항목을 충분히 제안하세요. 항목 수에 제한은 없습니다. 적합한 항목이 없으면 빈 배열([])로 두세요.',
|
||||
'- 각 항목은 한국어로 간결한 한 문장(체크리스트 항목)으로 작성합니다.',
|
||||
'- 업무·프로젝트 맥락에 구체적으로 맞추고 일반론은 피하세요.',
|
||||
'- [프로젝트 문서] 섹션이 주어지면(첨부 문서에서 추출한 내용) 그 내용을 적극 반영해 더 구체적으로 제안하세요.',
|
||||
'',
|
||||
'[형식]',
|
||||
'{"groups":[{"category":"필수 기능","items":["..."]},{"category":"관리 기능","items":["..."]},{"category":"보안 기능","items":["..."]},{"category":"부가 기능","items":["..."]}]}',
|
||||
@@ -199,9 +201,16 @@ export class AiService {
|
||||
// 프로젝트 정보(이름/설명) — 인증 사용자면 열람 가능. 없으면 404.
|
||||
const project = await this.projectService.findOne(input.projectId, userId);
|
||||
const content = (input.content ?? []).join('\n').trim() || '(없음)';
|
||||
// 프로젝트 첨부 문서의 캐싱된 추출 본문(업로드 시 보관) — 있으면 추천에 반영
|
||||
const docsText = await this.projectService.getAttachmentsTextForAi(
|
||||
input.projectId,
|
||||
);
|
||||
const userMsg = [
|
||||
`[프로젝트] 표시 제목: ${project.name}`,
|
||||
`설명: ${project.description ?? '(없음)'}`,
|
||||
...(docsText
|
||||
? ['', '[프로젝트 문서] (첨부 문서에서 추출한 내용)', docsText]
|
||||
: []),
|
||||
'',
|
||||
`[업무] 제목: ${input.title}`,
|
||||
'내용:',
|
||||
@@ -213,34 +222,46 @@ export class AiService {
|
||||
const raw = await this.callClaude(
|
||||
[{ role: 'user', content: userMsg }],
|
||||
SUGGEST_SYSTEM_PROMPT,
|
||||
4096,
|
||||
8000,
|
||||
);
|
||||
return { groups: this.parseSuggestions(raw) };
|
||||
}
|
||||
|
||||
// Claude 의 JSON 응답을 파싱·정규화(코드펜스/잡텍스트 방어)
|
||||
private parseSuggestions(raw: string): ChecklistSuggestionGroup[] {
|
||||
let text = raw
|
||||
const text = raw
|
||||
.trim()
|
||||
.replace(/^```(?:json)?\s*/i, '')
|
||||
.replace(/```$/, '')
|
||||
.replace(/```\s*$/, '')
|
||||
.trim();
|
||||
|
||||
// 1) 표준 파싱 — 첫 { ~ 마지막 } 구간만 잘라 JSON.parse
|
||||
let groups: unknown[] = [];
|
||||
const start = text.indexOf('{');
|
||||
const end = text.lastIndexOf('}');
|
||||
if (start >= 0 && end > start) text = text.slice(start, end + 1);
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
this.logger.warn(
|
||||
`추천 JSON 파싱 실패(응답 잘림 가능) — 원본 ${raw.length}자: ${raw.slice(0, 300)}`,
|
||||
);
|
||||
throw this.suggestFailed();
|
||||
if (start >= 0 && end > start) {
|
||||
try {
|
||||
const parsed = JSON.parse(text.slice(start, end + 1)) as {
|
||||
groups?: unknown;
|
||||
};
|
||||
if (Array.isArray(parsed.groups)) groups = parsed.groups;
|
||||
} catch {
|
||||
// 표준 파싱 실패 → 아래 견고 파싱으로 폴백
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 견고 파싱(폴백) — 모델이 깨진/잘린 JSON(누락 중괄호 등)을 내도
|
||||
// "category"/"items" 패턴을 정규식으로 직접 복구한다.
|
||||
if (groups.length === 0) {
|
||||
const recovered = this.recoverGroups(text);
|
||||
if (recovered.length > 0) {
|
||||
groups = recovered;
|
||||
this.logger.warn(
|
||||
`추천 JSON 표준 파싱 실패 → 견고 파싱으로 복구(${recovered.length}그룹). 원본 ${raw.length}자`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const rawGroups = (parsed as { groups?: unknown }).groups;
|
||||
const groups: unknown[] = Array.isArray(rawGroups) ? rawGroups : [];
|
||||
const result: ChecklistSuggestionGroup[] = [];
|
||||
for (const g of groups.slice(0, 8)) {
|
||||
const obj = g as { category?: unknown; items?: unknown };
|
||||
@@ -263,6 +284,36 @@ export class AiService {
|
||||
return result;
|
||||
}
|
||||
|
||||
// 깨진/잘린 JSON 에서도 카테고리·항목을 정규식으로 복구(중괄호 누락·꼬리 잘림 방어).
|
||||
// 완결된 "category":"...","items":[ ... ] 블록만 추출하므로, 잘린 마지막 그룹은 버리고 나머지는 살린다.
|
||||
private recoverGroups(text: string): { category: string; items: string[] }[] {
|
||||
const out: { category: string; items: string[] }[] = [];
|
||||
const groupRe =
|
||||
/"category"\s*:\s*("(?:[^"\\]|\\.)*")\s*,\s*"items"\s*:\s*\[([\s\S]*?)\]/g;
|
||||
const itemRe = /"(?:[^"\\]|\\.)*"/g;
|
||||
let gm: RegExpExecArray | null;
|
||||
while ((gm = groupRe.exec(text)) !== null) {
|
||||
let category: string;
|
||||
try {
|
||||
category = JSON.parse(gm[1]) as string;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const items: string[] = [];
|
||||
let im: RegExpExecArray | null;
|
||||
while ((im = itemRe.exec(gm[2])) !== null) {
|
||||
try {
|
||||
const s = JSON.parse(im[0]) as unknown;
|
||||
if (typeof s === 'string') items.push(s);
|
||||
} catch {
|
||||
// 깨진 토큰은 건너뛴다
|
||||
}
|
||||
}
|
||||
out.push({ category, items });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private suggestFailed(): BusinessException {
|
||||
return new BusinessException(
|
||||
'BIZ_001',
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import {
|
||||
paginated,
|
||||
resolvePage,
|
||||
type PaginatedResult,
|
||||
} from '../../common/pagination/pagination';
|
||||
import { UserService, type PublicUser } from '../user/user.service';
|
||||
import {
|
||||
DriveActivity,
|
||||
type DriveActivityType,
|
||||
} from './entities/drive-activity.entity';
|
||||
|
||||
// 감사 로그 응답(원시) — 표시 문구/아이콘은 프론트가 type+payload 로 조립
|
||||
export interface DriveActivityResponse {
|
||||
id: string;
|
||||
type: DriveActivityType;
|
||||
actor: PublicUser | null;
|
||||
payload: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// 감사 로그 적재 파라미터
|
||||
export interface RecordDriveActivityParams {
|
||||
actorId: string | null;
|
||||
type: DriveActivityType;
|
||||
payload?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// 드라이브 감사 로그 비즈니스 로직 — DriveService 가 record() 로 행위를 적재하고,
|
||||
// 드라이브 화면이 list() 로 읽어간다. 적재는 베스트 에포트(실패해도 본 기능을 막지 않음).
|
||||
@Injectable()
|
||||
export class DriveAuditService {
|
||||
private readonly logger = new Logger(DriveAuditService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(DriveActivity)
|
||||
private readonly activityRepo: Repository<DriveActivity>,
|
||||
) {}
|
||||
|
||||
// 감사 로그 1건 적재 — 실패해도 호출부 흐름을 깨지 않도록 내부에서 흡수
|
||||
async record(params: RecordDriveActivityParams): Promise<void> {
|
||||
try {
|
||||
const activity = this.activityRepo.create({
|
||||
actor: params.actorId ? { id: params.actorId } : null,
|
||||
type: params.type,
|
||||
payload: params.payload ?? {},
|
||||
});
|
||||
await this.activityRepo.save(activity);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`드라이브 감사 로그 적재 실패: ${params.type}`,
|
||||
err instanceof Error ? err.stack : String(err),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 전역 감사 로그 — 최신순, 오프셋 페이지네이션. 날짜 그룹핑은 프론트.
|
||||
async list(
|
||||
page?: number,
|
||||
size?: number,
|
||||
): Promise<PaginatedResult<DriveActivityResponse>> {
|
||||
const pg = resolvePage(page, size);
|
||||
const [rows, total] = await this.activityRepo.findAndCount({
|
||||
relations: ['actor'],
|
||||
order: { createdAt: 'DESC' },
|
||||
skip: pg.skip,
|
||||
take: pg.take,
|
||||
});
|
||||
return paginated(
|
||||
rows.map((a) => this.toResponse(a)),
|
||||
total,
|
||||
pg,
|
||||
);
|
||||
}
|
||||
|
||||
// 엔티티 → 응답 매핑
|
||||
private toResponse(a: DriveActivity): DriveActivityResponse {
|
||||
return {
|
||||
id: a.id,
|
||||
type: a.type,
|
||||
actor: a.actor ? UserService.toPublic(a.actor) : null,
|
||||
payload: a.payload ?? {},
|
||||
createdAt: a.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import { extname } from 'path';
|
||||
import type { AttachmentKind } from '../task/entities/attachment.entity';
|
||||
|
||||
// 드라이브 파일 1건 최대 크기(기본 200MB) — env 로 조정 가능.
|
||||
// presigned PUT 은 크기를 서명으로 강제하지 못하므로, 최종 검증은 complete 단계의 HEAD 로 한다.
|
||||
export const DRIVE_MAX_FILE_SIZE = (() => {
|
||||
const mb = Number(process.env.DRIVE_MAX_FILE_MB);
|
||||
return Number.isFinite(mb) && mb > 0 ? mb * 1024 * 1024 : 200 * 1024 * 1024;
|
||||
})();
|
||||
|
||||
// 허용 MIME 화이트리스트 — 실행/스크립트(text/html, image/svg+xml 등)는 차단(심층 방어).
|
||||
// 문서 첨부(upload.config) 기준에 드라이브 용도의 미디어 타입을 추가했다.
|
||||
export const DRIVE_ALLOWED_MIME_TYPES: ReadonlySet<string> = new Set<string>([
|
||||
// 이미지
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
// 문서
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'text/csv',
|
||||
'text/markdown',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'application/json',
|
||||
// 압축
|
||||
'application/zip',
|
||||
'application/x-zip-compressed',
|
||||
'application/x-7z-compressed',
|
||||
'application/x-rar-compressed',
|
||||
// 미디어(드라이브 용도)
|
||||
'video/mp4',
|
||||
'video/webm',
|
||||
'video/quicktime',
|
||||
'audio/mpeg',
|
||||
'audio/wav',
|
||||
'audio/ogg',
|
||||
]);
|
||||
|
||||
// 허용 MIME 여부
|
||||
export function isAllowedMime(mime: string): boolean {
|
||||
return DRIVE_ALLOWED_MIME_TYPES.has(mime);
|
||||
}
|
||||
|
||||
// MIME/확장자로 아이콘 종류 파생(첨부와 동일 분류 재사용)
|
||||
export function deriveKind(mime: string, name: string): AttachmentKind {
|
||||
const lower = name.toLowerCase();
|
||||
if (mime.startsWith('image/')) return 'img';
|
||||
if (mime === 'application/pdf' || lower.endsWith('.pdf')) return 'pdf';
|
||||
if (
|
||||
mime.includes('zip') ||
|
||||
mime.includes('compressed') ||
|
||||
lower.endsWith('.zip')
|
||||
) {
|
||||
return 'zip';
|
||||
}
|
||||
return 'doc';
|
||||
}
|
||||
|
||||
// 버킷 객체 키 생성 — {APP_ENV}/drive/{userId}/{uuid}{ext}
|
||||
// 원본 파일명은 DB(name)에만 보관하고, 키는 충돌/인코딩 안전을 위해 uuid 기반으로 만든다.
|
||||
export function buildObjectKey(userId: string, filename: string): string {
|
||||
const env = process.env.APP_ENV || process.env.NODE_ENV || 'development';
|
||||
const ext = extname(filename).toLowerCase().slice(0, 16); // 비정상적으로 긴 확장자 방어
|
||||
return `${env}/drive/${userId}/${randomUUID()}${ext}`;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
DefaultValuePipe,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
import type { PaginatedResult } from '../../common/pagination/pagination';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import type { PublicUser } from '../user/user.service';
|
||||
import {
|
||||
DriveService,
|
||||
type DriveFileResponse,
|
||||
type DriveFolderResponse,
|
||||
type DriveListResponse,
|
||||
type PresignUploadResponse,
|
||||
} from './drive.service';
|
||||
import {
|
||||
DriveAuditService,
|
||||
type DriveActivityResponse,
|
||||
} from './drive-audit.service';
|
||||
import { CreateFolderDto } from './dto/create-folder.dto';
|
||||
import { PresignUploadDto } from './dto/presign-upload.dto';
|
||||
import { RenameDto } from './dto/rename.dto';
|
||||
|
||||
// 드라이브 컨트롤러 — 전사 공유 드라이브(로그인 사용자 전원 공유). 모든 엔드포인트 인증 필요.
|
||||
@ApiTags('Drive')
|
||||
@Controller('drive')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class DriveController {
|
||||
constructor(
|
||||
private readonly driveService: DriveService,
|
||||
private readonly auditService: DriveAuditService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '드라이브 목록(폴더 미지정 시 루트)' })
|
||||
@ApiResponse({ status: 200, description: '목록 조회 성공' })
|
||||
list(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Query('folderId') folderId?: string,
|
||||
): Promise<DriveListResponse> {
|
||||
return this.driveService.list(user.id, folderId);
|
||||
}
|
||||
|
||||
@Get('activity')
|
||||
@ApiOperation({ summary: '드라이브 감사 로그(누가 무엇을 했는지, 최신순)' })
|
||||
@ApiResponse({ status: 200, description: '조회 성공' })
|
||||
activity(
|
||||
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
|
||||
@Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number,
|
||||
): Promise<PaginatedResult<DriveActivityResponse>> {
|
||||
return this.auditService.list(page, size);
|
||||
}
|
||||
|
||||
/* ===================== 폴더 ===================== */
|
||||
|
||||
@Post('folders')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: '폴더 생성' })
|
||||
@ApiResponse({ status: 201, description: '생성 성공' })
|
||||
createFolder(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Body() dto: CreateFolderDto,
|
||||
): Promise<DriveFolderResponse> {
|
||||
return this.driveService.createFolder(user.id, dto);
|
||||
}
|
||||
|
||||
@Patch('folders/:folderId')
|
||||
@ApiOperation({ summary: '폴더 이름 변경' })
|
||||
@ApiResponse({ status: 200, description: '변경 성공' })
|
||||
renameFolder(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Param('folderId', ParseUUIDPipe) folderId: string,
|
||||
@Body() dto: RenameDto,
|
||||
): Promise<DriveFolderResponse> {
|
||||
return this.driveService.renameFolder(user.id, folderId, dto.name);
|
||||
}
|
||||
|
||||
@Delete('folders/:folderId')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '폴더 삭제(하위 폴더·파일 포함)' })
|
||||
@ApiResponse({ status: 200, description: '삭제 성공' })
|
||||
async deleteFolder(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Param('folderId', ParseUUIDPipe) folderId: string,
|
||||
): Promise<null> {
|
||||
await this.driveService.deleteFolder(user.id, folderId);
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ===================== 파일 ===================== */
|
||||
|
||||
@Post('files/presign')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: '업로드용 presigned URL 발급(+pending 메타 생성)' })
|
||||
@ApiResponse({ status: 201, description: '발급 성공' })
|
||||
@ApiResponse({
|
||||
status: 400,
|
||||
description: '허용되지 않는 형식/크기 (VAL_001)',
|
||||
})
|
||||
presignUpload(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Body() dto: PresignUploadDto,
|
||||
): Promise<PresignUploadResponse> {
|
||||
return this.driveService.presignUpload(user.id, dto);
|
||||
}
|
||||
|
||||
@Post('files/:fileId/complete')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '업로드 확정(HEAD 검증 후 active 전환)' })
|
||||
@ApiResponse({ status: 200, description: '확정 성공' })
|
||||
@ApiResponse({ status: 400, description: '업로드 미완료/검증 실패' })
|
||||
completeUpload(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Param('fileId', ParseUUIDPipe) fileId: string,
|
||||
): Promise<DriveFileResponse> {
|
||||
return this.driveService.completeUpload(user.id, fileId);
|
||||
}
|
||||
|
||||
@Get('files/:fileId/download-url')
|
||||
@ApiOperation({ summary: '다운로드용 단기 presigned URL 발급' })
|
||||
@ApiResponse({ status: 200, description: '발급 성공' })
|
||||
@ApiResponse({ status: 404, description: '파일 없음/미확정 (RES_001)' })
|
||||
getDownloadUrl(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Param('fileId', ParseUUIDPipe) fileId: string,
|
||||
): Promise<{ url: string }> {
|
||||
return this.driveService.getDownloadUrl(user.id, fileId);
|
||||
}
|
||||
|
||||
@Patch('files/:fileId')
|
||||
@ApiOperation({ summary: '파일 이름 변경' })
|
||||
@ApiResponse({ status: 200, description: '변경 성공' })
|
||||
renameFile(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Param('fileId', ParseUUIDPipe) fileId: string,
|
||||
@Body() dto: RenameDto,
|
||||
): Promise<DriveFileResponse> {
|
||||
return this.driveService.renameFile(user.id, fileId, dto.name);
|
||||
}
|
||||
|
||||
@Delete('files/:fileId')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '파일 삭제' })
|
||||
@ApiResponse({ status: 200, description: '삭제 성공' })
|
||||
async deleteFile(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Param('fileId', ParseUUIDPipe) fileId: string,
|
||||
): Promise<null> {
|
||||
await this.driveService.deleteFile(user.id, fileId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { ActivityModule } from '../activity/activity.module';
|
||||
import { DriveFolder } from './entities/drive-folder.entity';
|
||||
import { DriveFile } from './entities/drive-file.entity';
|
||||
import { ProjectFolderLink } from './entities/project-folder-link.entity';
|
||||
import { DriveActivity } from './entities/drive-activity.entity';
|
||||
import { DriveService } from './drive.service';
|
||||
import { DriveAuditService } from './drive-audit.service';
|
||||
import { DriveController } from './drive.controller';
|
||||
|
||||
// 드라이브 모듈 — 전사 공유 파일 저장소(로그인 사용자 전원 공유).
|
||||
// 저장소(StorageService)는 전역 StorageModule 에서 주입된다.
|
||||
// ProjectFolderLink 는 프로젝트 연동(AI 검토 등) 부가 기능을 위해 유지한다.
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
DriveFolder,
|
||||
DriveFile,
|
||||
ProjectFolderLink,
|
||||
DriveActivity,
|
||||
]),
|
||||
UserModule,
|
||||
ActivityModule,
|
||||
],
|
||||
controllers: [DriveController],
|
||||
providers: [DriveService, DriveAuditService],
|
||||
exports: [DriveService],
|
||||
})
|
||||
export class DriveModule {}
|
||||
@@ -0,0 +1,538 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { HttpStatus } from '@nestjs/common';
|
||||
import { In, IsNull, LessThan, Repository } from 'typeorm';
|
||||
import { BusinessException } from '../../common/exceptions/business.exception';
|
||||
import { StorageService } from '../../common/storage/storage.service';
|
||||
import type { PresignedUpload } from '../../common/storage/storage.types';
|
||||
import { UserService, type PublicUser } from '../user/user.service';
|
||||
import { User } from '../user/entities/user.entity';
|
||||
import { ActivityService } from '../activity/activity.service';
|
||||
import type { ActivityType } from '../activity/entities/activity.entity';
|
||||
import { DriveAuditService } from './drive-audit.service';
|
||||
import { DriveFolder } from './entities/drive-folder.entity';
|
||||
import { DriveFile } from './entities/drive-file.entity';
|
||||
import { ProjectFolderLink } from './entities/project-folder-link.entity';
|
||||
import type { AttachmentKind } from '../task/entities/attachment.entity';
|
||||
import type { DriveFileStatus } from './entities/drive-file.entity';
|
||||
import {
|
||||
DRIVE_MAX_FILE_SIZE,
|
||||
buildObjectKey,
|
||||
deriveKind,
|
||||
isAllowedMime,
|
||||
} from './drive.config';
|
||||
import { CreateFolderDto } from './dto/create-folder.dto';
|
||||
import { PresignUploadDto } from './dto/presign-upload.dto';
|
||||
|
||||
// --- 응답 형태 ---
|
||||
export interface DriveFolderResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
parentId: string | null;
|
||||
owner: PublicUser | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DriveFileResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
size: number;
|
||||
mime: string;
|
||||
kind: AttachmentKind;
|
||||
status: DriveFileStatus;
|
||||
folderId: string | null;
|
||||
owner: PublicUser | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DriveListResponse {
|
||||
// 현재 폴더(루트면 null) + 루트→현재 경로(브레드크럼)
|
||||
current: DriveFolderResponse | null;
|
||||
breadcrumb: { id: string; name: string }[];
|
||||
folders: DriveFolderResponse[];
|
||||
files: DriveFileResponse[];
|
||||
}
|
||||
|
||||
export interface PresignUploadResponse {
|
||||
fileId: string;
|
||||
file: DriveFileResponse;
|
||||
upload: PresignedUpload;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DriveService {
|
||||
private readonly logger = new Logger(DriveService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(DriveFolder)
|
||||
private readonly folderRepo: Repository<DriveFolder>,
|
||||
@InjectRepository(DriveFile)
|
||||
private readonly fileRepo: Repository<DriveFile>,
|
||||
@InjectRepository(ProjectFolderLink)
|
||||
private readonly linkRepo: Repository<ProjectFolderLink>,
|
||||
private readonly storage: StorageService,
|
||||
private readonly userService: UserService,
|
||||
private readonly activity: ActivityService,
|
||||
private readonly audit: DriveAuditService,
|
||||
) {}
|
||||
|
||||
/* ===================== 조회 ===================== */
|
||||
|
||||
// 드라이브 목록 — folderId 없으면 공유 루트, 있으면 해당 폴더.
|
||||
// 전사 공유 드라이브이므로 로그인 사용자 모두가 동일한 트리를 본다(소유자 필터 없음).
|
||||
async list(_userId: string, folderId?: string): Promise<DriveListResponse> {
|
||||
if (!folderId) {
|
||||
// 루트 — 공유 최상위 폴더/파일 전체
|
||||
const [folders, files] = await Promise.all([
|
||||
this.folderRepo.find({
|
||||
where: { parent: IsNull() },
|
||||
relations: { owner: true },
|
||||
order: { name: 'ASC' },
|
||||
}),
|
||||
this.fileRepo.find({
|
||||
where: { folder: IsNull(), status: 'active' },
|
||||
relations: { owner: true },
|
||||
order: { name: 'ASC' },
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
current: null,
|
||||
breadcrumb: [],
|
||||
folders: folders.map((f) => this.toFolderResponse(f, null)),
|
||||
files: files.map((f) => this.toFileResponse(f, null)),
|
||||
};
|
||||
}
|
||||
|
||||
const folder = await this.loadFolder(folderId);
|
||||
const [folders, files, breadcrumb] = await Promise.all([
|
||||
this.folderRepo.find({
|
||||
where: { parent: { id: folder.id } },
|
||||
relations: { owner: true },
|
||||
order: { name: 'ASC' },
|
||||
}),
|
||||
this.fileRepo.find({
|
||||
where: { folder: { id: folder.id }, status: 'active' },
|
||||
relations: { owner: true },
|
||||
order: { name: 'ASC' },
|
||||
}),
|
||||
this.buildBreadcrumb(folder),
|
||||
]);
|
||||
return {
|
||||
current: this.toFolderResponse(folder, folder.parent?.id ?? null),
|
||||
breadcrumb,
|
||||
folders: folders.map((f) => this.toFolderResponse(f, folder.id)),
|
||||
files: files.map((f) => this.toFileResponse(f, folder.id)),
|
||||
};
|
||||
}
|
||||
|
||||
/* ===================== 폴더 CRUD ===================== */
|
||||
|
||||
// 폴더 생성 — 루트 또는 상위 폴더 하위에 생성(로그인 사용자 전원 가능).
|
||||
async createFolder(
|
||||
userId: string,
|
||||
dto: CreateFolderDto,
|
||||
): Promise<DriveFolderResponse> {
|
||||
let parent: DriveFolder | null = null;
|
||||
if (dto.parentId) {
|
||||
parent = await this.loadFolder(dto.parentId);
|
||||
}
|
||||
const owner = await this.getUserOrThrow(userId);
|
||||
// 1차 저장으로 id 확보 → materialized path 구성 후 재저장
|
||||
const created = await this.folderRepo.save(
|
||||
this.folderRepo.create({
|
||||
owner,
|
||||
parent: parent ? { id: parent.id } : null,
|
||||
name: dto.name,
|
||||
path: '',
|
||||
}),
|
||||
);
|
||||
created.path = `${parent ? parent.path : ''}${created.id}/`;
|
||||
await this.folderRepo.save(created);
|
||||
await this.audit.record({
|
||||
actorId: userId,
|
||||
type: 'folder_created',
|
||||
payload: { name: created.name },
|
||||
});
|
||||
return this.toFolderResponse({ ...created, owner }, parent?.id ?? null);
|
||||
}
|
||||
|
||||
// 폴더 이름 변경 — 로그인 사용자 전원 가능.
|
||||
async renameFolder(
|
||||
userId: string,
|
||||
folderId: string,
|
||||
name: string,
|
||||
): Promise<DriveFolderResponse> {
|
||||
const folder = await this.loadFolder(folderId);
|
||||
const fromName = folder.name;
|
||||
folder.name = name;
|
||||
await this.folderRepo.save(folder);
|
||||
await this.audit.record({
|
||||
actorId: userId,
|
||||
type: 'folder_renamed',
|
||||
payload: { fromName, toName: name },
|
||||
});
|
||||
return this.toFolderResponse(folder, folder.parent?.id ?? null);
|
||||
}
|
||||
|
||||
// 폴더 삭제 — 로그인 사용자 전원 가능. 하위 트리의 객체 스토리지 파일까지 정리.
|
||||
async deleteFolder(userId: string, folderId: string): Promise<void> {
|
||||
const folder = await this.loadFolder(folderId);
|
||||
// 자기 + 하위 폴더에 속한 모든 파일의 객체 키 수집(행은 FK CASCADE 로 삭제됨)
|
||||
const descendantFiles = await this.fileRepo
|
||||
.createQueryBuilder('f')
|
||||
.innerJoin('f.folder', 'fd')
|
||||
.where('fd.path LIKE :prefix', { prefix: `${folder.path}%` })
|
||||
.getMany();
|
||||
// 활동 기록은 제거 전에 — 연동 프로젝트 해석에 폴더 경로(자기+조상)가 필요하다.
|
||||
const projectIds = await this.resolveLinkedProjectIds(
|
||||
this.parseFolderPath(folder.path),
|
||||
);
|
||||
await this.folderRepo.remove(folder);
|
||||
await this.deleteObjects(descendantFiles.map((f) => f.objectKey));
|
||||
await this.audit.record({
|
||||
actorId: userId,
|
||||
type: 'folder_deleted',
|
||||
payload: { name: folder.name, fileCount: descendantFiles.length },
|
||||
});
|
||||
for (const projectId of projectIds) {
|
||||
await this.activity.record({
|
||||
projectId,
|
||||
actorId: userId,
|
||||
type: 'drive.folder_deleted',
|
||||
payload: { folderName: folder.name, fileCount: descendantFiles.length },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================== 파일 업로드(Presigned) ===================== */
|
||||
|
||||
// ① 업로드용 presigned URL 발급 + pending 메타 생성.
|
||||
async presignUpload(
|
||||
userId: string,
|
||||
dto: PresignUploadDto,
|
||||
): Promise<PresignUploadResponse> {
|
||||
if (!isAllowedMime(dto.contentType)) {
|
||||
throw new BusinessException(
|
||||
'VAL_001',
|
||||
'업로드할 수 없는 파일 형식입니다.',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
let folder: DriveFolder | null = null;
|
||||
if (dto.folderId) {
|
||||
folder = await this.loadFolder(dto.folderId);
|
||||
}
|
||||
const owner = await this.getUserOrThrow(userId);
|
||||
const objectKey = buildObjectKey(userId, dto.filename);
|
||||
const saved = await this.fileRepo.save(
|
||||
this.fileRepo.create({
|
||||
owner,
|
||||
folder: folder ? { id: folder.id } : null,
|
||||
name: dto.filename,
|
||||
bucket: this.storage.bucket,
|
||||
objectKey,
|
||||
size: dto.size,
|
||||
mime: dto.contentType,
|
||||
kind: deriveKind(dto.contentType, dto.filename),
|
||||
status: 'pending',
|
||||
}),
|
||||
);
|
||||
const upload = await this.storage.presignUpload({
|
||||
key: objectKey,
|
||||
contentType: dto.contentType,
|
||||
});
|
||||
return {
|
||||
fileId: saved.id,
|
||||
file: this.toFileResponse({ ...saved, owner }, folder?.id ?? null),
|
||||
upload,
|
||||
};
|
||||
}
|
||||
|
||||
// ③ 업로드 확정 — HEAD 로 실제 크기/타입 재검증 후 active 전환. 발급자 본인만 호출.
|
||||
async completeUpload(
|
||||
userId: string,
|
||||
fileId: string,
|
||||
): Promise<DriveFileResponse> {
|
||||
const file = await this.fileRepo.findOne({
|
||||
where: { id: fileId },
|
||||
relations: { owner: true, folder: true },
|
||||
});
|
||||
if (!file) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
// 발급자 본인만 확정 가능(업로드 2단계 핸드셰이크 무결성 — 접근 권한과 무관)
|
||||
if (file.owner?.id !== userId) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
if (file.status === 'deleted') {
|
||||
// 이미 삭제된 묘비 행 — 확정 불가
|
||||
throw new NotFoundException();
|
||||
}
|
||||
if (file.status === 'active') {
|
||||
// 멱등 — 이미 확정된 경우 현재 상태 반환
|
||||
return this.toFileResponse(file, file.folder?.id ?? null);
|
||||
}
|
||||
|
||||
const head = await this.storage.headObject(file.objectKey);
|
||||
if (!head) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'업로드가 완료되지 않았습니다. 파일 전송 후 다시 시도해 주세요.',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
// 실제 저장된 객체 기준으로 검증(클라이언트 신고값을 신뢰하지 않음)
|
||||
if (head.size > DRIVE_MAX_FILE_SIZE) {
|
||||
await this.discardFile(file);
|
||||
throw new BusinessException(
|
||||
'VAL_001',
|
||||
'허용 최대 파일 크기를 초과했습니다.',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
if (head.contentType && !isAllowedMime(head.contentType)) {
|
||||
await this.discardFile(file);
|
||||
throw new BusinessException(
|
||||
'VAL_001',
|
||||
'업로드할 수 없는 파일 형식입니다.',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
file.size = head.size;
|
||||
file.status = 'active';
|
||||
await this.fileRepo.save(file);
|
||||
await this.audit.record({
|
||||
actorId: userId,
|
||||
type: 'file_uploaded',
|
||||
payload: { name: file.name },
|
||||
});
|
||||
// 프로젝트 연동 폴더의 파일이면 해당 프로젝트 활동 피드에도 업로드 기록
|
||||
await this.recordFileActivity(file, userId, 'drive.file_uploaded');
|
||||
return this.toFileResponse(file, file.folder?.id ?? null);
|
||||
}
|
||||
|
||||
/* ===================== 파일 CRUD ===================== */
|
||||
|
||||
// 파일 이름 변경 — 로그인 사용자 전원 가능.
|
||||
async renameFile(
|
||||
userId: string,
|
||||
fileId: string,
|
||||
name: string,
|
||||
): Promise<DriveFileResponse> {
|
||||
const file = await this.loadFile(fileId);
|
||||
const fromName = file.name;
|
||||
file.name = name;
|
||||
await this.fileRepo.save(file);
|
||||
await this.audit.record({
|
||||
actorId: userId,
|
||||
type: 'file_renamed',
|
||||
payload: { fromName, toName: name },
|
||||
});
|
||||
return this.toFileResponse(file, file.folder?.id ?? null);
|
||||
}
|
||||
|
||||
// 파일 삭제 — 로그인 사용자 전원 가능. 객체 스토리지 파일은 즉시 영구 삭제(복구 불가)하고,
|
||||
// 메타 행은 누가·언제 지웠는지 추적할 수 있도록 묘비(tombstone)로 보존(소프트 삭제)한다.
|
||||
async deleteFile(userId: string, fileId: string): Promise<void> {
|
||||
const file = await this.loadFile(fileId);
|
||||
file.status = 'deleted';
|
||||
file.deletedAt = new Date();
|
||||
file.deletedBy = { id: userId } as User;
|
||||
await this.fileRepo.save(file);
|
||||
await this.deleteObjects([file.objectKey]);
|
||||
await this.audit.record({
|
||||
actorId: userId,
|
||||
type: 'file_deleted',
|
||||
payload: { name: file.name },
|
||||
});
|
||||
// 프로젝트 연동 폴더의 파일이면 해당 프로젝트 활동 피드에도 삭제 기록
|
||||
await this.recordFileActivity(file, userId, 'drive.file_deleted');
|
||||
}
|
||||
|
||||
// 다운로드용 단기 presigned GET URL 발급 — 로그인 사용자 전원 가능.
|
||||
async getDownloadUrl(
|
||||
_userId: string,
|
||||
fileId: string,
|
||||
): Promise<{ url: string }> {
|
||||
const file = await this.loadFile(fileId);
|
||||
if (file.status !== 'active') {
|
||||
// 아직 업로드 확정 전 — 다운로드 불가
|
||||
throw new NotFoundException();
|
||||
}
|
||||
const url = await this.storage.presignDownload({
|
||||
key: file.objectKey,
|
||||
filename: file.name,
|
||||
contentType: file.mime,
|
||||
});
|
||||
return { url };
|
||||
}
|
||||
|
||||
/* ===================== 로딩 ===================== */
|
||||
|
||||
// 폴더 로딩(미존재 404). 전사 공유라 권한 검사는 없다.
|
||||
private async loadFolder(folderId: string): Promise<DriveFolder> {
|
||||
const folder = await this.folderRepo.findOne({
|
||||
where: { id: folderId },
|
||||
relations: { owner: true, parent: true },
|
||||
});
|
||||
if (!folder) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return folder;
|
||||
}
|
||||
|
||||
// 파일 로딩(미존재/소프트 삭제 묘비는 404 — 목록·다운로드·재삭제 차단).
|
||||
private async loadFile(fileId: string): Promise<DriveFile> {
|
||||
const file = await this.fileRepo.findOne({
|
||||
where: { id: fileId },
|
||||
relations: { owner: true, folder: true },
|
||||
});
|
||||
if (!file || file.status === 'deleted') {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
// "rootId/childId/selfId/" → ["rootId","childId","selfId"] (자기 포함 + 조상)
|
||||
private parseFolderPath(path: string): string[] {
|
||||
return path.split('/').filter(Boolean);
|
||||
}
|
||||
|
||||
// 브레드크럼(루트→현재) — path 의 폴더들을 이름과 함께 순서대로 반환
|
||||
private async buildBreadcrumb(
|
||||
folder: DriveFolder,
|
||||
): Promise<{ id: string; name: string }[]> {
|
||||
const ids = this.parseFolderPath(folder.path);
|
||||
if (ids.length === 0) return [];
|
||||
const rows = await this.folderRepo.find({
|
||||
where: { id: In(ids) },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const byId = new Map(rows.map((r) => [r.id, r.name]));
|
||||
return ids
|
||||
.filter((id) => byId.has(id))
|
||||
.map((id) => ({ id, name: byId.get(id) as string }));
|
||||
}
|
||||
|
||||
/* ===================== 내부 유틸 ===================== */
|
||||
|
||||
// 파일이 프로젝트 연동 폴더(또는 그 하위)에 속하면 해당 프로젝트(들) 활동 피드에 기록한다.
|
||||
// (전역 감사 로그와는 별개로, 프로젝트 화면의 활동 피드 표시를 위한 부가 기록)
|
||||
private async recordFileActivity(
|
||||
file: DriveFile,
|
||||
actorId: string,
|
||||
type: ActivityType,
|
||||
): Promise<void> {
|
||||
const folderIds = file.folder ? this.parseFolderPath(file.folder.path) : [];
|
||||
const projectIds = await this.resolveLinkedProjectIds(folderIds);
|
||||
for (const projectId of projectIds) {
|
||||
await this.activity.record({
|
||||
projectId,
|
||||
actorId,
|
||||
type,
|
||||
payload: { fileName: file.name },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 폴더 id 집합(자기+조상)이 연동된 프로젝트 id 목록(중복 제거)을 반환.
|
||||
private async resolveLinkedProjectIds(
|
||||
folderIds: string[],
|
||||
): Promise<string[]> {
|
||||
if (folderIds.length === 0) return [];
|
||||
const links = await this.linkRepo.find({
|
||||
where: { folder: { id: In(folderIds) } },
|
||||
relations: { project: true },
|
||||
});
|
||||
return [...new Set(links.map((l) => l.project.id))];
|
||||
}
|
||||
|
||||
private async getUserOrThrow(userId: string): Promise<User> {
|
||||
const user = await this.userService.findById(userId);
|
||||
if (!user) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
// 검증 실패한 업로드 폐기 — 객체 + 메타 행 제거
|
||||
private async discardFile(file: DriveFile): Promise<void> {
|
||||
await this.fileRepo.remove(file);
|
||||
await this.deleteObjects([file.objectKey]);
|
||||
}
|
||||
|
||||
// 객체 스토리지 파일 삭제(베스트 에포트 — 실패해도 본 흐름을 막지 않음)
|
||||
private async deleteObjects(keys: string[]): Promise<void> {
|
||||
for (const key of keys) {
|
||||
try {
|
||||
await this.storage.deleteObject(key);
|
||||
} catch (err: unknown) {
|
||||
this.logger.warn(
|
||||
`객체 삭제 실패(무시): ${key} — ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 미완료(pending) 업로드 정리 — 1시간 이상 확정되지 않은 행/객체 제거
|
||||
@Cron(CronExpression.EVERY_HOUR)
|
||||
async cleanupStalePending(): Promise<void> {
|
||||
try {
|
||||
const cutoff = new Date(Date.now() - 60 * 60 * 1000);
|
||||
const stale = await this.fileRepo.find({
|
||||
where: { status: 'pending', createdAt: LessThan(cutoff) },
|
||||
});
|
||||
if (stale.length === 0) return;
|
||||
await this.deleteObjects(stale.map((f) => f.objectKey));
|
||||
await this.fileRepo.remove(stale);
|
||||
this.logger.log(`미완료 업로드 ${stale.length}건 정리`);
|
||||
} catch (err: unknown) {
|
||||
this.logger.warn(
|
||||
`미완료 업로드 정리 실패: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================== 매핑 ===================== */
|
||||
|
||||
private toFolderResponse(
|
||||
folder: DriveFolder,
|
||||
parentId: string | null,
|
||||
): DriveFolderResponse {
|
||||
return {
|
||||
id: folder.id,
|
||||
name: folder.name,
|
||||
parentId,
|
||||
owner: folder.owner ? UserService.toPublic(folder.owner) : null,
|
||||
createdAt: folder.createdAt.toISOString(),
|
||||
updatedAt: folder.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private toFileResponse(
|
||||
file: DriveFile,
|
||||
folderId: string | null,
|
||||
): DriveFileResponse {
|
||||
return {
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
mime: file.mime,
|
||||
kind: file.kind,
|
||||
status: file.status,
|
||||
folderId,
|
||||
owner: file.owner ? UserService.toPublic(file.owner) : null,
|
||||
createdAt: file.createdAt.toISOString(),
|
||||
updatedAt: file.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { IsOptional, IsString, IsUUID, Length } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
// 폴더 생성 요청
|
||||
export class CreateFolderDto {
|
||||
@ApiProperty({ description: '폴더명', example: '디자인 시안' })
|
||||
@IsString()
|
||||
@Length(1, 255, { message: '폴더명은 1~255자로 입력해 주세요.' })
|
||||
name!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '상위 폴더 ID (생략 시 드라이브 루트)',
|
||||
format: 'uuid',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID('4', { message: '올바른 상위 폴더 ID가 아닙니다.' })
|
||||
parentId?: string;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Length,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { DRIVE_MAX_FILE_SIZE } from '../drive.config';
|
||||
|
||||
// 업로드용 presigned URL 발급 요청
|
||||
export class PresignUploadDto {
|
||||
@ApiProperty({ description: '원본 파일명', example: '기획서.pdf' })
|
||||
@IsString()
|
||||
@Length(1, 255, { message: '파일명은 1~255자로 입력해 주세요.' })
|
||||
filename!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'MIME 타입(업로드 시 동일 Content-Type 으로 전송해야 함)',
|
||||
example: 'application/pdf',
|
||||
})
|
||||
@IsString()
|
||||
@Length(1, 255)
|
||||
contentType!: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: '파일 크기(바이트)',
|
||||
example: 1048576,
|
||||
maximum: DRIVE_MAX_FILE_SIZE,
|
||||
})
|
||||
@IsInt({ message: '파일 크기가 올바르지 않습니다.' })
|
||||
@Min(1, { message: '빈 파일은 업로드할 수 없습니다.' })
|
||||
@Max(DRIVE_MAX_FILE_SIZE, { message: '허용 최대 파일 크기를 초과했습니다.' })
|
||||
size!: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '업로드 대상 폴더 ID (생략 시 드라이브 루트)',
|
||||
format: 'uuid',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID('4', { message: '올바른 폴더 ID가 아닙니다.' })
|
||||
folderId?: string;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { IsString, Length } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
// 폴더/파일 이름 변경 요청 (공용)
|
||||
export class RenameDto {
|
||||
@ApiProperty({ description: '변경할 이름', example: '최종_시안.pdf' })
|
||||
@IsString()
|
||||
@Length(1, 255, { message: '이름은 1~255자로 입력해 주세요.' })
|
||||
name!: string;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
|
||||
// 드라이브 감사 로그 이벤트 타입 — 추가/이름변경/삭제 행위에서 자동 적재(읽기 전용)
|
||||
export type DriveActivityType =
|
||||
| 'file_uploaded'
|
||||
| 'file_renamed'
|
||||
| 'file_deleted'
|
||||
| 'folder_created'
|
||||
| 'folder_renamed'
|
||||
| 'folder_deleted';
|
||||
|
||||
// 드라이브 감사 로그 — 전사 공유 드라이브에서 "누가 무엇을 했는지" 전역 추적.
|
||||
// 프로젝트 활동(activities)은 project NOT NULL 이라 전사 로그로 못 쓰므로 별도 테이블로 둔다.
|
||||
// 표시 문구는 프론트가 type+payload 로 조립한다(파생값 규칙).
|
||||
@Entity('drive_activities')
|
||||
export class DriveActivity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// 행위자 (사용자 삭제 시에도 기록은 유지 — SET NULL)
|
||||
@Index()
|
||||
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
|
||||
@JoinColumn({ name: 'actor_id' })
|
||||
actor!: User | null;
|
||||
|
||||
// 이벤트 타입
|
||||
@Column({ type: 'varchar' })
|
||||
type!: DriveActivityType;
|
||||
|
||||
// 대상 정보(자유형) — 예: name, fromName/toName(이름변경), folderName(상위 폴더 컨텍스트), fileCount(폴더 삭제)
|
||||
@Column({ type: 'jsonb', default: () => "'{}'" })
|
||||
payload!: Record<string, unknown>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
type Relation,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
import { DriveFolder } from './drive-folder.entity';
|
||||
import { numericColumnTransformer } from '../../../common/database/numeric-column.transformer';
|
||||
import type { AttachmentKind } from '../../task/entities/attachment.entity';
|
||||
|
||||
// 드라이브 파일 업로드 상태
|
||||
// pending: presign 발급됨(아직 실제 업로드/검증 전) / active: 업로드 완료·검증 통과
|
||||
// deleted: 소프트 삭제(객체는 영구 제거됨, 메타 행은 감사용 묘비로 보존 — 누가·언제 삭제했는지)
|
||||
export type DriveFileStatus = 'pending' | 'active' | 'deleted';
|
||||
|
||||
// AI 텍스트 추출 상태(프로젝트 연동 문서의 AI 검토용 본문 캐시)
|
||||
// pending: 미추출 / done: 추출 완료 / unsupported: 추출 불가 형식 / failed: 추출 시도 실패
|
||||
export type DriveExtractStatus = 'pending' | 'done' | 'unsupported' | 'failed';
|
||||
|
||||
// 드라이브 파일 — 실제 바이너리는 오브젝트 스토리지에 저장하고 메타데이터만 보관.
|
||||
// 업로드는 presigned URL 직접 전송 후 complete 로 확정한다.
|
||||
@Entity('drive_files')
|
||||
export class DriveFile {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// 업로더 (전사 공유 드라이브 — 업로더가 탈퇴해도 공유 파일은 보존하므로 SET NULL)
|
||||
@Index()
|
||||
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
|
||||
@JoinColumn({ name: 'owner_id' })
|
||||
owner!: User | null;
|
||||
|
||||
// 소속 폴더 (null = 드라이브 루트). 폴더 삭제 시 파일 메타도 함께 삭제(CASCADE).
|
||||
@Index()
|
||||
@ManyToOne(() => DriveFolder, { onDelete: 'CASCADE', nullable: true })
|
||||
@JoinColumn({ name: 'folder_id' })
|
||||
folder!: Relation<DriveFolder> | null;
|
||||
|
||||
// 원본 파일명(다운로드 시 표시)
|
||||
@Column()
|
||||
name!: string;
|
||||
|
||||
// 저장 버킷 — 추후 버킷 이전 대비해 메타에 함께 보관
|
||||
@Column()
|
||||
bucket!: string;
|
||||
|
||||
// 버킷 내 객체 키(고유). 예: development/drive/{userId}/{uuid}-name.pdf
|
||||
@Index({ unique: true })
|
||||
@Column({ name: 'object_key' })
|
||||
objectKey!: string;
|
||||
|
||||
// 파일 크기(바이트) — 대용량 대비 bigint(문자열) → number 변환
|
||||
@Column({ type: 'bigint', transformer: numericColumnTransformer })
|
||||
size!: number;
|
||||
|
||||
// MIME 타입
|
||||
@Column()
|
||||
mime!: string;
|
||||
|
||||
// 아이콘 종류(MIME/확장자에서 파생) — 업무/프로젝트 첨부와 동일 분류 재사용
|
||||
@Column({ type: 'varchar' })
|
||||
kind!: AttachmentKind;
|
||||
|
||||
// 업로드 상태 — pending 은 미완료(목록/다운로드 비노출), 정리 크론 대상
|
||||
@Index()
|
||||
@Column({ type: 'varchar', default: 'pending' })
|
||||
status!: DriveFileStatus;
|
||||
|
||||
// AI 검토용 추출 본문(프로젝트 연동 시 1회 추출·캐싱) — 본문은 응답에 노출하지 않음
|
||||
@Column({
|
||||
name: 'extracted_text',
|
||||
type: 'text',
|
||||
nullable: true,
|
||||
select: false,
|
||||
})
|
||||
extractedText!: string | null;
|
||||
|
||||
// AI 추출 상태 — 연동 문서의 AI 반영 배지 산출 + 재추출 방지에 사용
|
||||
@Column({ name: 'extract_status', type: 'varchar', default: 'pending' })
|
||||
extractStatus!: DriveExtractStatus;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt!: Date;
|
||||
|
||||
// 삭제 시각(소프트 삭제) — null 이면 미삭제. 객체는 즉시 영구 삭제되며 이 행만 묘비로 남는다.
|
||||
@Column({ name: 'deleted_at', type: 'timestamp', nullable: true })
|
||||
deletedAt!: Date | null;
|
||||
|
||||
// 삭제한 사용자 — 소유자가 아닌 연동 멤버가 지울 수 있으므로 별도 기록(사용자 삭제 시 SET NULL)
|
||||
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
|
||||
@JoinColumn({ name: 'deleted_by_id' })
|
||||
deletedBy!: User | null;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
type Relation,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
|
||||
// 드라이브 폴더 — 전사 공유 폴더 트리(adjacency list). 로그인 사용자 모두가 공유한다.
|
||||
// ProjectFolderLink 로 프로젝트에 연동(AI 검토 등)할 수 있으나 접근은 전원 공유다.
|
||||
@Entity('drive_folders')
|
||||
export class DriveFolder {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// 생성자(업로더) — 전사 공유라 생성자가 탈퇴해도 폴더는 보존하므로 SET NULL
|
||||
@Index()
|
||||
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
|
||||
@JoinColumn({ name: 'owner_id' })
|
||||
owner!: User | null;
|
||||
|
||||
// 상위 폴더 (null = 드라이브 루트). 상위 삭제 시 하위도 함께 삭제(CASCADE).
|
||||
// Relation<> 래퍼: 자기참조 순환 회피
|
||||
@Index()
|
||||
@ManyToOne(() => DriveFolder, { onDelete: 'CASCADE', nullable: true })
|
||||
@JoinColumn({ name: 'parent_id' })
|
||||
parent!: Relation<DriveFolder> | null;
|
||||
|
||||
// 폴더명
|
||||
@Column()
|
||||
name!: string;
|
||||
|
||||
// 머터리얼라이즈드 패스 — 루트부터 자기 자신까지의 폴더 id 를 '/' 로 이어 저장(자기 포함, 끝에 '/').
|
||||
// 예: "rootId/childId/" . 조상 연동 여부 판정과 하위 트리 조회(LIKE)에 사용한다.
|
||||
// (parent_id 가 트리의 SSOT 이고, path 는 빠른 조회를 위한 파생값)
|
||||
@Index()
|
||||
@Column({ type: 'text' })
|
||||
path!: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
type Relation,
|
||||
Unique,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
import { Project } from '../../project/entities/project.entity';
|
||||
import { DriveFolder } from './drive-folder.entity';
|
||||
|
||||
// 연동 권한 — read: 보기/다운로드만, read-write: 프로젝트 멤버도 업로드 가능
|
||||
export type DriveLinkPermission = 'read' | 'read-write';
|
||||
|
||||
// 프로젝트-폴더 연동 — 드라이브 폴더를 프로젝트에 공유한다.
|
||||
// 연동된 폴더와 그 하위 트리 전체가 해당 프로젝트 멤버에게 노출된다.
|
||||
@Entity('project_folder_links')
|
||||
@Unique('UQ_project_folder_link', ['project', 'folder'])
|
||||
export class ProjectFolderLink {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// 대상 프로젝트 (프로젝트 삭제 시 연동 해제)
|
||||
@Index()
|
||||
@ManyToOne(() => Project, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'project_id' })
|
||||
project!: Relation<Project>;
|
||||
|
||||
// 연동된 드라이브 폴더 (폴더 삭제 시 연동도 함께 삭제)
|
||||
@Index()
|
||||
@ManyToOne(() => DriveFolder, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'folder_id' })
|
||||
folder!: Relation<DriveFolder>;
|
||||
|
||||
// 연동을 수행한 사용자(폴더 소유자) — 사용자 삭제 시에도 연동은 유지(SET NULL)
|
||||
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
|
||||
@JoinColumn({ name: 'linked_by_id' })
|
||||
linkedBy!: User | null;
|
||||
|
||||
// 연동 권한 — 기본 read(안전)
|
||||
@Column({ type: 'varchar', default: 'read' })
|
||||
permission!: DriveLinkPermission;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsISO8601,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Length,
|
||||
Matches,
|
||||
} from 'class-validator';
|
||||
|
||||
// 화상회의 방 생성 요청
|
||||
export class CreateMeetingRoomDto {
|
||||
@ApiProperty({
|
||||
description: '방 이름(한글/영문/숫자/-/_ 허용, 공백 불가, 고유)',
|
||||
example: '주간-회의',
|
||||
})
|
||||
@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[];
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, Length, Matches } from 'class-validator';
|
||||
|
||||
// 화상회의 입장 토큰 발급 요청 — 참여할 방의 입장 코드를 전달한다.
|
||||
export class CreateMeetingTokenDto {
|
||||
@ApiProperty({
|
||||
description: '참여할 회의 방 입장 코드(영문/숫자)',
|
||||
example: 'a1b2c3d4e5',
|
||||
})
|
||||
@IsString()
|
||||
@Length(6, 32, { message: '입장 코드 형식이 올바르지 않습니다.' })
|
||||
@Matches(/^[A-Za-z0-9]+$/, {
|
||||
message: '입장 코드 형식이 올바르지 않습니다.',
|
||||
})
|
||||
code!: string;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { CreateMeetingRoomDto } from './create-meeting-room.dto';
|
||||
|
||||
// 화상회의 방 수정 요청 — 생성과 동일한 필드(진행자가 이름·예약 시각·참여 제한을 수정).
|
||||
// 폼이 현재 값을 그대로 채워 전체 상태를 보내므로 별도 부분 갱신(Partial) 의미는 두지 않는다.
|
||||
export class UpdateMeetingRoomDto extends CreateMeetingRoomDto {}
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
type Relation,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
|
||||
// 화상회의 방 — 방 이름과 진행자(생성자)를 보관한다.
|
||||
// 단계 2(경량): 방 이름으로 토큰을 처음 발급받은 사용자가 진행자(host)가 되고,
|
||||
// 이후 입장자는 일반 참여자가 된다. (방 목록/예약/참여 제한 UI 는 단계 4)
|
||||
@Entity('meeting_rooms')
|
||||
export class MeetingRoom {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// 방 입장 코드(고유·불변) — 입장 URL·LiveKit 방 식별자·초대 링크의 기준.
|
||||
// 이름과 분리해, 이름을 바꿔도 링크/세션이 유지된다.
|
||||
@Index({ unique: true })
|
||||
@Column()
|
||||
code!: string;
|
||||
|
||||
// 방 이름(표시용 라벨) — 고유하지 않으며 진행자가 자유롭게 변경할 수 있다.
|
||||
@Column()
|
||||
name!: string;
|
||||
|
||||
// 진행자(생성자). 사용자 삭제 시에도 방 기록은 유지(SET NULL)
|
||||
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
|
||||
@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;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
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,
|
||||
type MeetingRoomResponse,
|
||||
} 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('화상회의')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('meetings')
|
||||
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);
|
||||
}
|
||||
|
||||
@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: '회의 방 삭제(진행자만)' })
|
||||
@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: '토큰 발급 성공' })
|
||||
issueToken(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Body() dto: CreateMeetingTokenDto,
|
||||
): Promise<MeetingTokenResult> {
|
||||
return this.meetingService.issueToken(user, dto.code);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
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, User])],
|
||||
controllers: [MeetingController],
|
||||
providers: [MeetingService],
|
||||
exports: [MeetingService],
|
||||
})
|
||||
export class MeetingModule {}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
import { HttpStatus, Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/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';
|
||||
|
||||
// 방 목록/생성 응답 — 진행자·예약·참여제한·접근 가능 여부 포함
|
||||
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;
|
||||
}
|
||||
|
||||
// 화상회의 토큰 발급 결과 — 브라우저는 자체 VITE_LIVEKIT_URL 로 접속하므로 URL 은 반환하지 않는다.
|
||||
export interface MeetingTokenResult {
|
||||
token: string; // LiveKit 입장 JWT
|
||||
roomName: string;
|
||||
identity: string; // 참여자 식별자(사용자 ID)
|
||||
role: MeetingRole; // 이번 입장자의 역할
|
||||
}
|
||||
|
||||
// 화상회의 서비스(단계 2: 권한 분기) — LiveKit 입장 토큰(JWT) 발급을 담당한다.
|
||||
// 미디어 전송은 LiveKit(SFU)에 위임하고, 여기서는 인증된 사용자에게 역할별 권한이 담긴 토큰을 만든다.
|
||||
// 역할: 방을 처음 만든 사용자가 진행자(host), 이후 입장자는 일반 참여자(participant).
|
||||
@Injectable()
|
||||
export class MeetingService {
|
||||
private readonly logger = new Logger(MeetingService.name);
|
||||
private readonly apiKey: string;
|
||||
private readonly apiSecret: string;
|
||||
|
||||
constructor(
|
||||
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();
|
||||
if (!this.enabled) {
|
||||
this.logger.warn(
|
||||
'LIVEKIT_API_KEY/LIVEKIT_API_SECRET 미설정 — 화상회의 토큰 발급 비활성화',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
get enabled(): boolean {
|
||||
return this.apiKey.length > 0 && this.apiSecret.length > 0;
|
||||
}
|
||||
|
||||
// LiveKit 입장 토큰 발급 — 역할에 따라 권한(grant)을 차등 부여한다.
|
||||
// · 진행자(host): roomAdmin + 발행/구독 (단계 3의 화면 승격 등 진행자 기능 토대)
|
||||
// · 일반(participant): 발행/구독
|
||||
// 역할은 토큰 metadata 에도 실어 다른 참여자가 진행자를 식별할 수 있게 한다.
|
||||
async issueToken(
|
||||
user: PublicUser,
|
||||
code: string,
|
||||
): Promise<MeetingTokenResult> {
|
||||
if (!this.enabled) {
|
||||
throw new BusinessException(
|
||||
'SYS_001',
|
||||
'화상회의 서비스가 아직 설정되지 않았습니다. 잠시 후 다시 시도해 주세요.',
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
|
||||
const { room, role } = await this.resolveRoomAccess(user.id, code);
|
||||
const isHost = role === 'host';
|
||||
|
||||
const at = new AccessToken(this.apiKey, this.apiSecret, {
|
||||
identity: user.id,
|
||||
name: user.name,
|
||||
// 장시간 회의 중 네트워크 재연결 시 토큰 만료로 재입장이 실패하지 않도록 넉넉히(12h).
|
||||
ttl: '12h',
|
||||
// role 은 진행자 식별용, avatarUrl 은 참여자/채팅 아바타 표시용
|
||||
metadata: JSON.stringify({ role, avatarUrl: user.avatarUrl }),
|
||||
});
|
||||
at.addGrant({
|
||||
roomJoin: true,
|
||||
// LiveKit 방 식별자 = 코드(이름이 아니라 불변 코드라 이름을 바꿔도 같은 방으로 연결).
|
||||
room: room.code,
|
||||
canPublish: true,
|
||||
canSubscribe: true,
|
||||
canPublishData: true,
|
||||
roomAdmin: isHost,
|
||||
});
|
||||
|
||||
const token = await at.toJwt();
|
||||
return { token, roomName: room.name, identity: user.id, role };
|
||||
}
|
||||
|
||||
// 코드로 방을 찾아 입장 권한을 판정한다(자동 생성 없음 — 명시적으로 생성된 방만 입장 가능).
|
||||
// · 방이 없으면 404. · 참여 제한(allow-list) 검사 후 생성자 일치로 역할을 판정.
|
||||
private async resolveRoomAccess(
|
||||
userId: string,
|
||||
code: string,
|
||||
): Promise<{ room: MeetingRoom; role: MeetingRole }> {
|
||||
const room = await this.roomRepo.findOne({
|
||||
where: { code },
|
||||
relations: ['host'],
|
||||
});
|
||||
if (!room) {
|
||||
throw new BusinessException(
|
||||
'RES_001',
|
||||
'회의 방을 찾을 수 없습니다. 링크를 확인해 주세요.',
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
const isHost = room.host?.id === userId;
|
||||
if (
|
||||
!isHost &&
|
||||
this.isRestricted(room) &&
|
||||
!room.allowedUserIds!.includes(userId)
|
||||
) {
|
||||
throw new BusinessException(
|
||||
'AUTH_003',
|
||||
'이 회의에 참여할 권한이 없습니다.',
|
||||
HttpStatus.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
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 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,
|
||||
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 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);
|
||||
}
|
||||
|
||||
// 방 삭제 — 진행자(생성자)만 가능.
|
||||
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 async toRoomResponse(
|
||||
room: MeetingRoom,
|
||||
userId: string,
|
||||
): 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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsNotEmpty, IsString, ValidateNested } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
// 브라우저 PushSubscription.keys
|
||||
class PushKeysDto {
|
||||
@ApiProperty({ description: '구독 공개키(p256dh)' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
p256dh!: string;
|
||||
|
||||
@ApiProperty({ description: '인증 시크릿(auth)' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
auth!: string;
|
||||
}
|
||||
|
||||
// 푸시 구독 등록 — 브라우저 PushSubscription.toJSON() 형태
|
||||
export class SubscribePushDto {
|
||||
@ApiProperty({ description: '푸시 서비스 엔드포인트 URL' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
endpoint!: string;
|
||||
|
||||
@ApiProperty({ description: '구독 키(p256dh/auth)' })
|
||||
@ValidateNested()
|
||||
@Type(() => PushKeysDto)
|
||||
keys!: PushKeysDto;
|
||||
}
|
||||
|
||||
// 푸시 구독 해제
|
||||
export class UnsubscribePushDto {
|
||||
@ApiProperty({ description: '해제할 구독 엔드포인트 URL' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
endpoint!: string;
|
||||
}
|
||||
@@ -18,9 +18,12 @@ export type NotificationType =
|
||||
| 'task.changes_requested' // 내가 담당한 업무에 수정이 요청됨
|
||||
| 'task.started' // 내가 지시한 업무를 담당자가 시작/재개함
|
||||
| 'task.due_changed' // 내가 담당한 업무의 마감이 변경됨
|
||||
| 'task.due_soon' // 내가 담당한 업무의 마감이 하루 앞으로 다가옴
|
||||
| 'task.overdue' // 내가 담당한 업무의 마감이 지남(미완료)
|
||||
| 'task.checkpoint_reported' // 내가 지시한 업무의 중간 점검 보고가 옴
|
||||
| 'task.removed' // 내 관련 업무가 삭제됨
|
||||
| 'task.commented' // 내 관련 업무에 댓글/답글이 달림
|
||||
| 'task.file_attached' // 내 관련 업무에 파일이 첨부됨
|
||||
| 'member.invited' // 프로젝트에 멤버로 초대됨
|
||||
| 'member.role_changed' // 프로젝트 내 내 역할이 변경됨
|
||||
| 'member.removed' // 프로젝트에서 제외됨
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
|
||||
// 웹 푸시 구독 — 브라우저(PushManager)가 발급한 구독 정보를 사용자별로 보관한다.
|
||||
// 한 사용자가 여러 기기/브라우저를 가질 수 있으므로 userId당 N개. endpoint 가 고유 키.
|
||||
@Entity('push_subscriptions')
|
||||
export class PushSubscription {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// 구독 소유자 (사용자 삭제 시 구독도 함께 삭제 — Notification.recipient 와 동일 패턴)
|
||||
@Index()
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user!: User;
|
||||
|
||||
// 브라우저 푸시 엔드포인트(푸시 서비스 URL) — 구독의 고유 식별자
|
||||
@Index({ unique: true })
|
||||
@Column({ type: 'text' })
|
||||
endpoint!: string;
|
||||
|
||||
// 암호화 키(브라우저 PushSubscription.keys)
|
||||
@Column({ type: 'text' })
|
||||
p256dh!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
auth!: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
DefaultValuePipe,
|
||||
Get,
|
||||
@@ -22,13 +23,18 @@ import {
|
||||
NotificationService,
|
||||
type NotificationListResult,
|
||||
} from './notification.service';
|
||||
import { WebPushService } from './web-push.service';
|
||||
import { SubscribePushDto, UnsubscribePushDto } from './dto/subscribe-push.dto';
|
||||
|
||||
// 알림 컨트롤러 — 모든 엔드포인트 인증 필요(본인 수신함 전용)
|
||||
@ApiTags('Notification')
|
||||
@Controller('notifications')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class NotificationController {
|
||||
constructor(private readonly notificationService: NotificationService) {}
|
||||
constructor(
|
||||
private readonly notificationService: NotificationService,
|
||||
private readonly webPush: WebPushService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: '내 알림 목록 (페이지네이션 + 미읽음 수)' })
|
||||
@@ -62,6 +68,35 @@ export class NotificationController {
|
||||
return this.notificationService.markAllRead(user.id);
|
||||
}
|
||||
|
||||
@Post('push/subscribe')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '웹 푸시 구독 등록(브라우저 PushSubscription)' })
|
||||
@ApiResponse({ status: 200, description: '등록 성공' })
|
||||
async subscribePush(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Body() dto: SubscribePushDto,
|
||||
): Promise<null> {
|
||||
await this.webPush.subscribe(
|
||||
user.id,
|
||||
dto.endpoint,
|
||||
dto.keys.p256dh,
|
||||
dto.keys.auth,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@Post('push/unsubscribe')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '웹 푸시 구독 해제' })
|
||||
@ApiResponse({ status: 200, description: '해제 성공' })
|
||||
async unsubscribePush(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Body() dto: UnsubscribePushDto,
|
||||
): Promise<null> {
|
||||
await this.webPush.unsubscribe(user.id, dto.endpoint);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 실시간 스트림(SSE) — 표준 응답 래퍼를 건너뛰고 text/event-stream 으로 흘려보낸다.
|
||||
// 인증은 access_token 쿠키(EventSource withCredentials)로 연결 시점에 1회 검증된다.
|
||||
@Sse('stream')
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Notification } from './entities/notification.entity';
|
||||
import { PushSubscription } from './entities/push-subscription.entity';
|
||||
import { NotificationService } from './notification.service';
|
||||
import { WebPushService } from './web-push.service';
|
||||
import { NotificationController } from './notification.controller';
|
||||
|
||||
// 알림 모듈 — 다른 도메인 모듈(task/repo)이 import 해 NotificationService 로 알림을 적재한다.
|
||||
// (Notification 은 User 만 참조하므로 순환 의존 없음)
|
||||
// 알림 모듈 — 다른 도메인 모듈(task/project/schedule)이 import 해 NotificationService 로 알림을 적재한다.
|
||||
// 인앱(DB) + 실시간(SSE) + 웹 푸시(WebPushService) 3경로로 전달한다.
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Notification])],
|
||||
imports: [TypeOrmModule.forFeature([Notification, PushSubscription])],
|
||||
controllers: [NotificationController],
|
||||
providers: [NotificationService],
|
||||
providers: [NotificationService, WebPushService],
|
||||
exports: [NotificationService],
|
||||
})
|
||||
export class NotificationModule {}
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
type OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { LessThan, Repository } from 'typeorm';
|
||||
import { Subject, merge, interval, type Observable } from 'rxjs';
|
||||
import { filter, map } from 'rxjs/operators';
|
||||
import Redis from 'ioredis';
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
Notification,
|
||||
type NotificationType,
|
||||
} from './entities/notification.entity';
|
||||
import { WebPushService } from './web-push.service';
|
||||
|
||||
// 알림 응답(원시) — 표시 문구/아이콘/톤은 프론트가 type+payload 로 조립
|
||||
export interface NotificationResponse {
|
||||
@@ -64,10 +66,13 @@ export class NotificationService implements OnModuleInit, OnModuleDestroy {
|
||||
private subscriber: Redis | null = null;
|
||||
// 실시간 알림 채널명
|
||||
private static readonly CHANNEL = 'relay:notifications';
|
||||
// 읽은 알림 보관 기간(일) — 이보다 오래된 '읽음' 알림은 주기적으로 정리해 무한 증가를 막는다.
|
||||
private static readonly READ_RETENTION_DAYS = 90;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Notification)
|
||||
private readonly notificationRepo: Repository<Notification>,
|
||||
private readonly webPush: WebPushService,
|
||||
) {}
|
||||
|
||||
// 기동 시 Redis pub/sub 연결 — REDIS_HOST 있을 때만. 실패해도 인메모리로 폴백.
|
||||
@@ -156,12 +161,19 @@ export class NotificationService implements OnModuleInit, OnModuleDestroy {
|
||||
);
|
||||
const saved = await this.notificationRepo.save(entities);
|
||||
|
||||
// 저장 성공분을 각 수신자에게 실시간 전달(Redis 발행 또는 로컬)
|
||||
// 푸시 표시 내용은 type+payload 로 1회만 산출(수신자 공통)
|
||||
const pushContent = this.webPush.notificationContent(
|
||||
params.type,
|
||||
params.payload ?? {},
|
||||
);
|
||||
|
||||
// 저장 성공분을 각 수신자에게 실시간 전달(Redis/로컬 SSE) + 웹 푸시(사이트 닫힘 대비)
|
||||
for (let i = 0; i < saved.length; i++) {
|
||||
this.dispatch({
|
||||
userId: recipients[i],
|
||||
data: this.toResponse(saved[i]),
|
||||
});
|
||||
void this.webPush.sendToUser(recipients[i], pushContent);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
@@ -215,6 +227,29 @@ export class NotificationService implements OnModuleInit, OnModuleDestroy {
|
||||
return { unreadCount: 0 };
|
||||
}
|
||||
|
||||
// 오래된 '읽음' 알림 정리 — 매일 새벽 4시. 무한 증가 방지(미읽음은 보존).
|
||||
// best-effort: 실패해도 서비스에 영향 없음(다음 주기에 재시도).
|
||||
@Cron(CronExpression.EVERY_DAY_AT_4AM)
|
||||
async cleanupOldRead(): Promise<void> {
|
||||
try {
|
||||
const cutoff = new Date(
|
||||
Date.now() -
|
||||
NotificationService.READ_RETENTION_DAYS * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
const result = await this.notificationRepo.delete({
|
||||
read: true,
|
||||
createdAt: LessThan(cutoff),
|
||||
});
|
||||
if (result.affected) {
|
||||
this.logger.log(`오래된 읽음 알림 ${result.affected}건 정리`);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`읽음 알림 정리 실패: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 사용자별 실시간 스트림(SSE) — 본인 알림만 필터 + 주기적 하트비트(프록시 idle 차단 방지)
|
||||
streamFor(userId: string): Observable<MessageEvent> {
|
||||
const events = this.stream$.asObservable().pipe(
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { WebPushService } from './web-push.service';
|
||||
import type { NotificationType } from './entities/notification.entity';
|
||||
|
||||
// 모든 NotificationType 에 대해 푸시 문구가 조립되는지 검증한다.
|
||||
// (NotificationType 을 추가하고 notificationContent 의 case 를 빠뜨리면 기본 문구가 남아 실패)
|
||||
//
|
||||
// SSOT 주의: 인앱 표시 문구는 프론트 notification.store.ts 의 toView() 가 별도로 담당한다.
|
||||
// 새 타입을 추가하면 이 목록과 양쪽(백엔드 notificationContent, 프론트 toView)을 함께 갱신할 것.
|
||||
const ALL_TYPES: NotificationType[] = [
|
||||
'task.assigned',
|
||||
'task.unassigned',
|
||||
'task.approval_requested',
|
||||
'task.approved',
|
||||
'task.changes_requested',
|
||||
'task.started',
|
||||
'task.due_changed',
|
||||
'task.due_soon',
|
||||
'task.overdue',
|
||||
'task.checkpoint_reported',
|
||||
'task.removed',
|
||||
'task.commented',
|
||||
'task.file_attached',
|
||||
'member.invited',
|
||||
'member.role_changed',
|
||||
'member.removed',
|
||||
'schedule.invited',
|
||||
'schedule.updated',
|
||||
'schedule.removed',
|
||||
];
|
||||
|
||||
// 서버 기본 문구(케이스 누락 시 남는 값) — 이 문구가 남으면 매핑이 빠진 것.
|
||||
const DEFAULT_BODY = '새 알림이 도착했습니다';
|
||||
|
||||
describe('WebPushService.notificationContent', () => {
|
||||
// notificationContent 는 repo/config 를 쓰지 않으므로 의존성 없이 인스턴스화한다.
|
||||
const service = new WebPushService(
|
||||
null as unknown as never,
|
||||
null as unknown as never,
|
||||
);
|
||||
|
||||
// 표시 문구 조립에 쓰이는 대표 payload(모든 필드 채워 어떤 case 든 자연스럽게)
|
||||
const payload = {
|
||||
actorName: '홍길동',
|
||||
projectName: '테스트 프로젝트',
|
||||
projectId: 'p-1',
|
||||
taskTitle: '샘플 업무',
|
||||
taskSeq: 12,
|
||||
eventTitle: '주간 회의',
|
||||
roleType: 'admin',
|
||||
isReply: true,
|
||||
fileName: '보고서.pdf',
|
||||
dueDate: '2026-07-10T00:00:00.000Z',
|
||||
resumed: true,
|
||||
};
|
||||
|
||||
it.each(ALL_TYPES)('%s 타입의 본문이 기본 문구가 아니어야 한다', (type) => {
|
||||
const content = service.notificationContent(type, payload);
|
||||
expect(content.title).toBeTruthy();
|
||||
expect(content.body).not.toBe(DEFAULT_BODY);
|
||||
expect(content.body.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('타입 목록이 19종(현재 정의)과 일치해야 한다', () => {
|
||||
// NotificationType 추가 시 이 테스트가 알려주도록 개수를 고정한다.
|
||||
expect(ALL_TYPES).toHaveLength(19);
|
||||
expect(new Set(ALL_TYPES).size).toBe(ALL_TYPES.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as webpush from 'web-push';
|
||||
import { PushSubscription } from './entities/push-subscription.entity';
|
||||
import type { NotificationType } from './entities/notification.entity';
|
||||
|
||||
// 푸시 표시 내용 — 서비스워커가 showNotification 에 그대로 사용한다.
|
||||
export interface PushContent {
|
||||
title: string;
|
||||
body: string;
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
// payload(자유형) 안전 추출
|
||||
function pstr(p: Record<string, unknown>, k: string): string {
|
||||
const v = p[k];
|
||||
return typeof v === 'string' ? v : '';
|
||||
}
|
||||
function pnum(p: Record<string, unknown>, k: string): number | null {
|
||||
const v = p[k];
|
||||
return typeof v === 'number' ? v : null;
|
||||
}
|
||||
function pbool(p: Record<string, unknown>, k: string): boolean {
|
||||
return p[k] === true;
|
||||
}
|
||||
|
||||
// 마감일(ISO) → 한국식 날짜 라벨(한국 시간대 고정). 파싱 실패 시 빈 문자열.
|
||||
function fmtDueKo(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return new Intl.DateTimeFormat('ko-KR', {
|
||||
dateStyle: 'long',
|
||||
timeZone: 'Asia/Seoul',
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
// 웹 푸시 발송 + 구독 관리.
|
||||
// VAPID 키 미설정 시 비활성(발송 skip) — storage 미설정 패턴과 동일한 graceful degrade.
|
||||
@Injectable()
|
||||
export class WebPushService implements OnModuleInit {
|
||||
private readonly logger = new Logger(WebPushService.name);
|
||||
private enabled = false;
|
||||
// 푸시 서비스가 오프라인 기기에 보관·재시도하는 최대 시간(초). 하루 지나면 폐기.
|
||||
private static readonly PUSH_TTL_SECONDS = 60 * 60 * 24;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(PushSubscription)
|
||||
private readonly subRepo: Repository<PushSubscription>,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
const publicKey = this.config.get<string>('VAPID_PUBLIC_KEY');
|
||||
const privateKey = this.config.get<string>('VAPID_PRIVATE_KEY');
|
||||
const subject =
|
||||
this.config.get<string>('VAPID_SUBJECT') || 'mailto:admin@example.com';
|
||||
if (!publicKey || !privateKey) {
|
||||
this.logger.log('웹 푸시: 비활성(VAPID 키 미설정) — 푸시 발송 건너뜀');
|
||||
return;
|
||||
}
|
||||
webpush.setVapidDetails(subject, publicKey, privateKey);
|
||||
this.enabled = true;
|
||||
this.logger.log('웹 푸시: 활성화됨(VAPID)');
|
||||
}
|
||||
|
||||
// 구독 등록(endpoint 기준 upsert — 같은 브라우저 재구독 시 키 갱신)
|
||||
async subscribe(
|
||||
userId: string,
|
||||
endpoint: string,
|
||||
p256dh: string,
|
||||
auth: string,
|
||||
): Promise<void> {
|
||||
const existing = await this.subRepo.findOne({ where: { endpoint } });
|
||||
if (existing) {
|
||||
existing.user = { id: userId } as PushSubscription['user'];
|
||||
existing.p256dh = p256dh;
|
||||
existing.auth = auth;
|
||||
await this.subRepo.save(existing);
|
||||
return;
|
||||
}
|
||||
await this.subRepo.save(
|
||||
this.subRepo.create({
|
||||
user: { id: userId } as PushSubscription['user'],
|
||||
endpoint,
|
||||
p256dh,
|
||||
auth,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 구독 해제(본인 구독만)
|
||||
async unsubscribe(userId: string, endpoint: string): Promise<void> {
|
||||
await this.subRepo.delete({ endpoint, user: { id: userId } });
|
||||
}
|
||||
|
||||
// type+payload → 푸시 표시 내용(제목=행위자/맥락, 본문=행위, url=이동 경로).
|
||||
// 프론트 toView 의 푸시 전용 plain 버전(서비스워커가 번들 모듈을 import 못하므로 서버에서 완성).
|
||||
//
|
||||
// SSOT 주의: 인앱 표시 문구는 프론트 `notification.store.ts` 의 `toView()` 가 별도로 조립한다
|
||||
// (인앱=HTML/톤, 푸시=평문). NotificationType 을 추가하면 **두 곳 모두** case 를 넣어야 하며,
|
||||
// 여기 커버리지는 `web-push.service.spec.ts` 가 기본 문구 잔존 여부로 검증한다.
|
||||
notificationContent(
|
||||
type: NotificationType,
|
||||
payload: Record<string, unknown>,
|
||||
): PushContent {
|
||||
const actor = pstr(payload, 'actorName') || '누군가';
|
||||
const projectName = pstr(payload, 'projectName');
|
||||
const taskTitle = pstr(payload, 'taskTitle');
|
||||
const eventTitle = pstr(payload, 'eventTitle');
|
||||
const projectId = pstr(payload, 'projectId');
|
||||
const taskSeq = pnum(payload, 'taskSeq');
|
||||
const taskUrl =
|
||||
projectId && taskSeq !== null
|
||||
? `/projects/${projectId}/tasks/${taskSeq}`
|
||||
: null;
|
||||
const projectUrl = projectId ? `/projects/${projectId}` : null;
|
||||
|
||||
// 기본값 — 대부분 제목=행위자, 본문=행위 문구
|
||||
let title = actor;
|
||||
let body = '새 알림이 도착했습니다';
|
||||
let url: string | null = taskUrl;
|
||||
|
||||
switch (type) {
|
||||
case 'task.assigned':
|
||||
body = `'${taskTitle}' 업무를 지시했습니다`;
|
||||
break;
|
||||
case 'task.unassigned':
|
||||
body = `'${taskTitle}' 업무 담당에서 회원님을 제외했습니다`;
|
||||
break;
|
||||
case 'task.started':
|
||||
// 수정 요청 후 재개(resumed)면 '재개', 최초 시작이면 '시작'
|
||||
body = `'${taskTitle}' 업무를 ${pbool(payload, 'resumed') ? '재개' : '시작'}했습니다`;
|
||||
break;
|
||||
case 'task.due_changed': {
|
||||
const label = fmtDueKo(pstr(payload, 'dueDate'));
|
||||
body = label
|
||||
? `'${taskTitle}' 업무의 마감기한을 ${label}(으)로 변경했습니다`
|
||||
: `'${taskTitle}' 업무의 마감기한을 해제했습니다`;
|
||||
break;
|
||||
}
|
||||
case 'task.due_soon': {
|
||||
const label = fmtDueKo(pstr(payload, 'dueDate'));
|
||||
title = '마감 임박';
|
||||
body = label
|
||||
? `'${taskTitle}' 업무의 마감이 다가옵니다 (마감: ${label})`
|
||||
: `'${taskTitle}' 업무의 마감이 다가옵니다`;
|
||||
break;
|
||||
}
|
||||
case 'task.overdue': {
|
||||
const label = fmtDueKo(pstr(payload, 'dueDate'));
|
||||
title = '마감 초과';
|
||||
body = label
|
||||
? `'${taskTitle}' 업무가 마감기한을 넘겼습니다 (마감: ${label})`
|
||||
: `'${taskTitle}' 업무가 마감기한을 넘겼습니다`;
|
||||
break;
|
||||
}
|
||||
case 'task.checkpoint_reported':
|
||||
body = `'${taskTitle}' 업무의 중간 점검을 보고했습니다`;
|
||||
break;
|
||||
case 'task.removed':
|
||||
body = `'${taskTitle}' 업무를 삭제했습니다`;
|
||||
url = projectUrl; // 업무가 삭제되므로 프로젝트로 이동
|
||||
break;
|
||||
case 'task.approval_requested':
|
||||
body = `'${taskTitle}' 승인을 요청했습니다`;
|
||||
break;
|
||||
case 'task.approved':
|
||||
title = '업무 승인';
|
||||
body = `'${taskTitle}' 업무가 승인되었습니다`;
|
||||
break;
|
||||
case 'task.changes_requested':
|
||||
body = `'${taskTitle}' 수정을 요청했습니다`;
|
||||
break;
|
||||
case 'task.commented':
|
||||
body = `'${taskTitle}'에 ${payload.isReply === true ? '답글' : '댓글'}을 남겼습니다`;
|
||||
break;
|
||||
case 'task.file_attached': {
|
||||
const fileName = pstr(payload, 'fileName');
|
||||
body = fileName
|
||||
? `'${taskTitle}'에 파일 '${fileName}'을(를) 첨부했습니다`
|
||||
: `'${taskTitle}'에 파일을 첨부했습니다`;
|
||||
break;
|
||||
}
|
||||
case 'member.invited':
|
||||
body = `'${projectName}' 프로젝트에 초대했습니다`;
|
||||
url = projectUrl;
|
||||
break;
|
||||
case 'member.role_changed':
|
||||
body = `'${projectName}'에서 회원님의 역할을 ${pstr(payload, 'roleType') === 'admin' ? '관리자' : '멤버'}(으)로 변경했습니다`;
|
||||
url = projectUrl;
|
||||
break;
|
||||
case 'member.removed':
|
||||
body = `'${projectName}' 프로젝트에서 회원님을 제외했습니다`;
|
||||
url = projectUrl;
|
||||
break;
|
||||
case 'schedule.invited':
|
||||
body = `'${eventTitle}' 일정에 회원님을 참석자로 지정했습니다`;
|
||||
url = '/schedule';
|
||||
break;
|
||||
case 'schedule.updated':
|
||||
body = `참석 일정 '${eventTitle}'을(를) 수정했습니다`;
|
||||
url = '/schedule';
|
||||
break;
|
||||
case 'schedule.removed':
|
||||
body = `참석 일정 '${eventTitle}'을(를) 삭제했습니다`;
|
||||
url = '/schedule';
|
||||
break;
|
||||
}
|
||||
return { title, body, url };
|
||||
}
|
||||
|
||||
// 한 사용자의 모든 구독(기기)에 발송 — 만료(404/410) 구독은 정리. best-effort.
|
||||
// 다기기 병렬 발송(allSettled) — 하나가 실패/지연돼도 다른 기기 발송을 막지 않는다.
|
||||
async sendToUser(userId: string, content: PushContent): Promise<void> {
|
||||
if (!this.enabled) return;
|
||||
const subs = await this.subRepo.find({ where: { user: { id: userId } } });
|
||||
if (subs.length === 0) return;
|
||||
const json = JSON.stringify(content);
|
||||
await Promise.allSettled(subs.map((s) => this.sendOne(s, json)));
|
||||
}
|
||||
|
||||
// 단일 구독 발송 — 만료 구독 정리 포함. 예외는 흡수(호출부 흐름 보호).
|
||||
private async sendOne(s: PushSubscription, json: string): Promise<void> {
|
||||
try {
|
||||
await webpush.sendNotification(
|
||||
{ endpoint: s.endpoint, keys: { p256dh: s.p256dh, auth: s.auth } },
|
||||
json,
|
||||
// 오프라인 기기용 보관 시간 + 일반 우선순위(배터리 절약 정책 존중)
|
||||
{ TTL: WebPushService.PUSH_TTL_SECONDS, urgency: 'normal' },
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
const statusCode = (err as { statusCode?: number })?.statusCode;
|
||||
if (statusCode === 404 || statusCode === 410) {
|
||||
// 만료/해지된 구독 — 정리
|
||||
await this.subRepo.delete({ id: s.id });
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`푸시 발송 실패(무시): ${statusCode ?? ''} ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { IsIn, IsOptional, IsUUID } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import type { DriveLinkPermission } from '../../drive/entities/project-folder-link.entity';
|
||||
|
||||
// 프로젝트에 드라이브 폴더를 연동하는 요청
|
||||
export class LinkDriveFolderDto {
|
||||
@ApiProperty({ description: '연동할 드라이브 폴더 ID', format: 'uuid' })
|
||||
@IsUUID('4', { message: '올바른 폴더 ID가 아닙니다.' })
|
||||
folderId!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: "연동 권한 ('read' 보기/다운로드 | 'read-write' 멤버 업로드 허용)",
|
||||
default: 'read',
|
||||
enum: ['read', 'read-write'],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn(['read', 'read-write'], { message: '올바른 권한 값이 아닙니다.' })
|
||||
permission?: DriveLinkPermission;
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { readFile } from 'fs/promises';
|
||||
import { PDFParse } from 'pdf-parse';
|
||||
import { extractRawText } from 'mammoth';
|
||||
import { Workbook } from 'exceljs';
|
||||
|
||||
// 문서 1건당 추출 텍스트 상한(자) — DB 용량과 AI 토큰 사용량을 함께 보호한다.
|
||||
export const PER_DOC_TEXT_LIMIT = 8000;
|
||||
|
||||
// 문서의 AI 반영 상태 — 목록 배지 표기에 사용
|
||||
// ready: 추출 성공(반영 가능) / failed: 지원 형식이나 추출 실패 / unsupported: 미지원 형식
|
||||
export type AttachmentAiStatus = 'ready' | 'failed' | 'unsupported';
|
||||
|
||||
// 텍스트 추출 대상 형식인지 여부(txt·csv·pdf·docx·xlsx). 미지원/추출실패 배지 구분에 사용한다.
|
||||
export function isExtractableType(mime: string, name: string): boolean {
|
||||
const lower = name.toLowerCase();
|
||||
return (
|
||||
mime === 'text/plain' ||
|
||||
mime === 'text/csv' ||
|
||||
lower.endsWith('.txt') ||
|
||||
lower.endsWith('.csv') ||
|
||||
mime === 'application/pdf' ||
|
||||
lower.endsWith('.pdf') ||
|
||||
mime ===
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
|
||||
lower.endsWith('.docx') ||
|
||||
mime ===
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
|
||||
lower.endsWith('.xlsx')
|
||||
);
|
||||
}
|
||||
|
||||
// 첨부 문서에서 평문 텍스트를 추출한다(업로드 시 1회). 추출 불가 형식/실패/빈 결과는 null.
|
||||
// 디스크 경로 버전 — 버퍼를 읽어 공용 추출기에 위임한다.
|
||||
export async function extractAttachmentText(
|
||||
absPath: string,
|
||||
mime: string,
|
||||
name: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const buf = await readFile(absPath);
|
||||
return extractTextFromBuffer(buf, mime, name);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 버퍼 버전 — 오브젝트 스토리지에서 받은 바이트에서 텍스트를 추출한다(드라이브 연동 문서용).
|
||||
// 지원 형식: txt·csv(직접 디코드), pdf(pdf-parse), docx(mammoth), xlsx(exceljs). 그 외는 null.
|
||||
export async function extractTextFromBuffer(
|
||||
buf: Buffer,
|
||||
mime: string,
|
||||
name: string,
|
||||
): Promise<string | null> {
|
||||
const lower = name.toLowerCase();
|
||||
try {
|
||||
// 1) 평문 텍스트
|
||||
if (
|
||||
mime === 'text/plain' ||
|
||||
mime === 'text/csv' ||
|
||||
lower.endsWith('.txt') ||
|
||||
lower.endsWith('.csv')
|
||||
) {
|
||||
return finalize(buf.toString('utf8'));
|
||||
}
|
||||
|
||||
// 2) PDF — pdf-parse v2(PDFParse 클래스)
|
||||
if (mime === 'application/pdf' || lower.endsWith('.pdf')) {
|
||||
const parser = new PDFParse({ data: buf });
|
||||
try {
|
||||
const result = await parser.getText();
|
||||
return finalize(result.text);
|
||||
} finally {
|
||||
await parser.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Word(.docx) — mammoth (버퍼 입력)
|
||||
if (
|
||||
mime ===
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
|
||||
lower.endsWith('.docx')
|
||||
) {
|
||||
const result = await extractRawText({ buffer: buf });
|
||||
return finalize(result.value);
|
||||
}
|
||||
|
||||
// 4) Excel(.xlsx) — exceljs 로 시트별 셀 텍스트 추출(행=탭 구분)
|
||||
if (
|
||||
mime ===
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
|
||||
lower.endsWith('.xlsx')
|
||||
) {
|
||||
const wb = new Workbook();
|
||||
// exceljs 타입(Buffer)과 Node Buffer 제네릭(ArrayBufferLike) 마찰 회피 —
|
||||
// load 의 실제 파라미터 타입으로 캐스팅한다.
|
||||
await wb.xlsx.load(buf as unknown as Parameters<typeof wb.xlsx.load>[0]);
|
||||
const lines: string[] = [];
|
||||
wb.eachSheet((sheet) => {
|
||||
const sheetLines: string[] = [];
|
||||
sheet.eachRow({ includeEmpty: false }, (row) => {
|
||||
const cells: string[] = [];
|
||||
row.eachCell({ includeEmpty: false }, (cell) => {
|
||||
const t = (cell.text ?? '').toString().trim();
|
||||
if (t) cells.push(t);
|
||||
});
|
||||
if (cells.length) sheetLines.push(cells.join('\t'));
|
||||
});
|
||||
// 내용 있는 시트만 제목과 함께 포함
|
||||
if (sheetLines.length) lines.push(`# ${sheet.name}`, ...sheetLines);
|
||||
});
|
||||
return finalize(lines.join('\n'));
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
// 추출 실패는 무시 — 호출부가 상태만 'failed' 로 기록한다.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 공백·개행 정리 후 길이 상한 적용. 내용이 없으면 null.
|
||||
function finalize(text: string): string | null {
|
||||
const cleaned = text
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
if (!cleaned) return null;
|
||||
return cleaned.length > PER_DOC_TEXT_LIMIT
|
||||
? cleaned.slice(0, PER_DOC_TEXT_LIMIT)
|
||||
: cleaned;
|
||||
}
|
||||
@@ -19,9 +19,15 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import type { PublicUser } from '../user/user.service';
|
||||
import type { PaginatedResult } from '../../common/pagination/pagination';
|
||||
import { ProjectService, type ProjectResponse } from './project.service';
|
||||
import {
|
||||
ProjectService,
|
||||
type ProjectResponse,
|
||||
type ProjectDriveLinkResponse,
|
||||
type ProjectDocumentResponse,
|
||||
} from './project.service';
|
||||
import { CreateProjectDto } from './dto/create-project.dto';
|
||||
import { UpdateProjectDto } from './dto/update-project.dto';
|
||||
import { LinkDriveFolderDto } from './dto/link-drive-folder.dto';
|
||||
|
||||
// 프로젝트 컨트롤러 — 모든 엔드포인트 인증 필요
|
||||
@ApiTags('Project')
|
||||
@@ -81,7 +87,7 @@ export class ProjectController {
|
||||
|
||||
@Delete(':projectId')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '프로젝트 삭제 (소유자, 시스템 프로젝트 제외)' })
|
||||
@ApiOperation({ summary: '프로젝트 삭제 (소유자)' })
|
||||
@ApiResponse({ status: 200, description: '삭제 성공' })
|
||||
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
|
||||
async remove(
|
||||
@@ -91,4 +97,51 @@ export class ProjectController {
|
||||
await this.projectService.remove(projectId, user.id);
|
||||
return null;
|
||||
}
|
||||
|
||||
/* ===================== 드라이브 연동 폴더 / AI 검토 문서 ===================== */
|
||||
|
||||
@Get(':projectId/drive-links')
|
||||
@ApiOperation({ summary: '연동된 드라이브 폴더 목록 (멤버)' })
|
||||
@ApiResponse({ status: 200, description: '조회 성공' })
|
||||
listDriveLinks(
|
||||
@Param('projectId', ParseUUIDPipe) projectId: string,
|
||||
): Promise<ProjectDriveLinkResponse[]> {
|
||||
return this.projectService.listDriveLinks(projectId);
|
||||
}
|
||||
|
||||
@Post(':projectId/drive-links')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@ApiOperation({ summary: '드라이브 폴더 연동 (admin, 본인 소유 폴더만)' })
|
||||
@ApiResponse({ status: 201, description: '연동 성공' })
|
||||
@ApiResponse({ status: 403, description: '권한 없음 (AUTH_003)' })
|
||||
@ApiResponse({ status: 409, description: '이미 연동된 폴더 (BIZ_001)' })
|
||||
linkDriveFolder(
|
||||
@Param('projectId', ParseUUIDPipe) projectId: string,
|
||||
@Body() dto: LinkDriveFolderDto,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<ProjectDriveLinkResponse> {
|
||||
return this.projectService.linkDriveFolder(projectId, user.id, dto);
|
||||
}
|
||||
|
||||
@Delete(':projectId/drive-links/:linkId')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '드라이브 폴더 연동 해제 (admin)' })
|
||||
@ApiResponse({ status: 200, description: '해제 성공' })
|
||||
async unlinkDriveFolder(
|
||||
@Param('projectId', ParseUUIDPipe) projectId: string,
|
||||
@Param('linkId', ParseUUIDPipe) linkId: string,
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<null> {
|
||||
await this.projectService.unlinkDriveFolder(projectId, user.id, linkId);
|
||||
return null;
|
||||
}
|
||||
|
||||
@Get(':projectId/documents')
|
||||
@ApiOperation({ summary: 'AI 검토 대상 문서 목록 (연동 폴더 내 파일)' })
|
||||
@ApiResponse({ status: 200, description: '조회 성공' })
|
||||
listDocuments(
|
||||
@Param('projectId', ParseUUIDPipe) projectId: string,
|
||||
): Promise<ProjectDocumentResponse[]> {
|
||||
return this.projectService.listDocuments(projectId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import { UserModule } from '../user/user.module';
|
||||
import { Project } from './entities/project.entity';
|
||||
import { ProjectMember } from './entities/project-member.entity';
|
||||
import { Task } from '../task/entities/task.entity';
|
||||
import { DriveFolder } from '../drive/entities/drive-folder.entity';
|
||||
import { DriveFile } from '../drive/entities/drive-file.entity';
|
||||
import { ProjectFolderLink } from '../drive/entities/project-folder-link.entity';
|
||||
import { ActivityModule } from '../activity/activity.module';
|
||||
import { NotificationModule } from '../notification/notification.module';
|
||||
import { ProjectService } from './project.service';
|
||||
@@ -15,7 +18,14 @@ import { ProjectMemberController } from './project-member.controller';
|
||||
// ProjectService 의 권한 헬퍼(assertProjectMember/Admin)를 다른 모듈이 주입해 사용한다.
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Project, ProjectMember, Task]),
|
||||
TypeOrmModule.forFeature([
|
||||
Project,
|
||||
ProjectMember,
|
||||
Task,
|
||||
DriveFolder,
|
||||
DriveFile,
|
||||
ProjectFolderLink,
|
||||
]),
|
||||
UserModule,
|
||||
ActivityModule,
|
||||
NotificationModule,
|
||||
|
||||
@@ -1,21 +1,59 @@
|
||||
import {
|
||||
ForbiddenException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { ILike, Repository, type FindOptionsOrder } from 'typeorm';
|
||||
import { ILike, In, Like, Repository, type FindOptionsOrder } from 'typeorm';
|
||||
import { UserService, type PublicUser } from '../user/user.service';
|
||||
import {
|
||||
paginated,
|
||||
resolvePage,
|
||||
type PaginatedResult,
|
||||
} from '../../common/pagination/pagination';
|
||||
import { BusinessException } from '../../common/exceptions/business.exception';
|
||||
import { StorageService } from '../../common/storage/storage.service';
|
||||
import { Project } from './entities/project.entity';
|
||||
import { ProjectMember } from './entities/project-member.entity';
|
||||
import { Task } from '../task/entities/task.entity';
|
||||
import type { AttachmentKind } from '../task/entities/attachment.entity';
|
||||
import { DriveFolder } from '../drive/entities/drive-folder.entity';
|
||||
import { DriveFile } from '../drive/entities/drive-file.entity';
|
||||
import {
|
||||
ProjectFolderLink,
|
||||
type DriveLinkPermission,
|
||||
} from '../drive/entities/project-folder-link.entity';
|
||||
import {
|
||||
extractTextFromBuffer,
|
||||
isExtractableType,
|
||||
type AttachmentAiStatus,
|
||||
} from './extract-text';
|
||||
import { CreateProjectDto } from './dto/create-project.dto';
|
||||
import { UpdateProjectDto } from './dto/update-project.dto';
|
||||
import { LinkDriveFolderDto } from './dto/link-drive-folder.dto';
|
||||
|
||||
// 문서의 AI 반영 상태 — 'pending'(추출 대기) 추가
|
||||
export type DocumentAiStatus = AttachmentAiStatus | 'pending';
|
||||
|
||||
// 프로젝트에 연동된 드라이브 폴더 응답
|
||||
export interface ProjectDriveLinkResponse {
|
||||
id: string; // 연동(link) ID
|
||||
folder: { id: string; name: string; path: string };
|
||||
permission: DriveLinkPermission;
|
||||
linkedBy: PublicUser | null;
|
||||
documentCount: number; // 연동 폴더(하위 포함) 내 활성 파일 수
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// AI 검토 대상 문서(연동 폴더 내 드라이브 파일) 응답 — 본문은 노출하지 않음
|
||||
export interface ProjectDocumentResponse {
|
||||
id: string; // 드라이브 파일 ID(다운로드는 드라이브 API 사용)
|
||||
name: string;
|
||||
size: number;
|
||||
kind: AttachmentKind;
|
||||
aiStatus: DocumentAiStatus;
|
||||
}
|
||||
|
||||
// 프로젝트 응답 형태 — 파생 라벨/진행률은 프론트에서 계산
|
||||
export interface ProjectResponse {
|
||||
@@ -27,9 +65,11 @@ export interface ProjectResponse {
|
||||
doneCount: number; // 완료 업무 수
|
||||
totalCount: number; // 전체 업무 수
|
||||
updatedAt: string;
|
||||
// 단건(findOne)에서만 — 현재 사용자 권한
|
||||
// 단건(findOne)에서만 — 현재 사용자 권한 + 연동 폴더/문서
|
||||
canManage?: boolean; // admin
|
||||
isOwner?: boolean; // 소유자
|
||||
driveLinks?: ProjectDriveLinkResponse[];
|
||||
documents?: ProjectDocumentResponse[];
|
||||
}
|
||||
|
||||
// 프로젝트 비즈니스 로직 + 권한 헬퍼(다른 모듈이 주입해 사용)
|
||||
@@ -42,6 +82,13 @@ export class ProjectService {
|
||||
private readonly memberRepo: Repository<ProjectMember>,
|
||||
@InjectRepository(Task)
|
||||
private readonly taskRepo: Repository<Task>,
|
||||
@InjectRepository(ProjectFolderLink)
|
||||
private readonly linkRepo: Repository<ProjectFolderLink>,
|
||||
@InjectRepository(DriveFolder)
|
||||
private readonly folderRepo: Repository<DriveFolder>,
|
||||
@InjectRepository(DriveFile)
|
||||
private readonly fileRepo: Repository<DriveFile>,
|
||||
private readonly storage: StorageService,
|
||||
private readonly userService: UserService,
|
||||
) {}
|
||||
|
||||
@@ -94,6 +141,8 @@ export class ProjectService {
|
||||
...base,
|
||||
canManage: membership?.roleType === 'admin',
|
||||
isOwner: membership?.isOwner ?? false,
|
||||
driveLinks: await this.listDriveLinks(project.id),
|
||||
documents: await this.listDocuments(project.id),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -181,6 +230,223 @@ export class ProjectService {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 드라이브 연동 폴더 / AI 검토 문서 ---
|
||||
|
||||
// 프로젝트에 연동된 드라이브 폴더 목록(최신순) — 프로젝트 상세에 포함
|
||||
async listDriveLinks(projectId: string): Promise<ProjectDriveLinkResponse[]> {
|
||||
const links = await this.linkRepo.find({
|
||||
where: { project: { id: projectId } },
|
||||
relations: { folder: true, linkedBy: true },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
const out: ProjectDriveLinkResponse[] = [];
|
||||
for (const l of links) {
|
||||
out.push(
|
||||
this.toDriveLinkResponse(
|
||||
l,
|
||||
await this.countFilesInFolderTree(l.folder),
|
||||
),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// 드라이브 폴더를 프로젝트에 연동(admin). 전사 공유 드라이브이므로 모든 폴더를 연동할 수 있다.
|
||||
async linkDriveFolder(
|
||||
projectId: string,
|
||||
userId: string,
|
||||
dto: LinkDriveFolderDto,
|
||||
): Promise<ProjectDriveLinkResponse> {
|
||||
const project = await this.getProjectOrThrow(projectId);
|
||||
await this.assertProjectAdmin(project.id, userId);
|
||||
const folder = await this.folderRepo.findOne({
|
||||
where: { id: dto.folderId },
|
||||
relations: { owner: true },
|
||||
});
|
||||
if (!folder) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
const existing = await this.linkRepo.findOne({
|
||||
where: { project: { id: project.id }, folder: { id: folder.id } },
|
||||
});
|
||||
if (existing) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'이미 연동된 폴더입니다.',
|
||||
HttpStatus.CONFLICT,
|
||||
);
|
||||
}
|
||||
const user = await this.userService.findById(userId);
|
||||
const saved = await this.linkRepo.save(
|
||||
this.linkRepo.create({
|
||||
project: { id: project.id } as Project,
|
||||
folder: { id: folder.id } as DriveFolder,
|
||||
linkedBy: user ?? null,
|
||||
permission: dto.permission ?? 'read',
|
||||
}),
|
||||
);
|
||||
return this.toDriveLinkResponse(
|
||||
{ ...saved, folder, linkedBy: user ?? null },
|
||||
await this.countFilesInFolderTree(folder),
|
||||
);
|
||||
}
|
||||
|
||||
// 연동 해제(admin)
|
||||
async unlinkDriveFolder(
|
||||
projectId: string,
|
||||
userId: string,
|
||||
linkId: string,
|
||||
): Promise<void> {
|
||||
const project = await this.getProjectOrThrow(projectId);
|
||||
await this.assertProjectAdmin(project.id, userId);
|
||||
const link = await this.linkRepo.findOne({
|
||||
where: { id: linkId, project: { id: project.id } },
|
||||
});
|
||||
if (!link) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
await this.linkRepo.remove(link);
|
||||
}
|
||||
|
||||
// AI 검토 대상 문서 목록 — 연동 폴더(하위 포함) 내 활성 파일 + 추출 상태
|
||||
async listDocuments(projectId: string): Promise<ProjectDocumentResponse[]> {
|
||||
const files = await this.collectLinkedFiles(projectId, false);
|
||||
return files.map((f) => ({
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
size: f.size,
|
||||
kind: f.kind,
|
||||
aiStatus: this.driveAiStatus(f),
|
||||
}));
|
||||
}
|
||||
|
||||
// AI 업무 추천용 — 연동 폴더 내 문서들의 추출 본문을 문서명과 함께 합쳐 반환한다.
|
||||
// 미추출(pending) 문서는 이 시점에 NCP 에서 받아 추출·캐싱한다(지연 추출). 총량 상한 적용.
|
||||
async getAttachmentsTextForAi(projectId: string): Promise<string> {
|
||||
const TOTAL_LIMIT = 16000;
|
||||
const files = await this.collectLinkedFiles(projectId, true);
|
||||
const parts: string[] = [];
|
||||
let used = 0;
|
||||
for (const f of files) {
|
||||
let text = f.extractedText?.trim() ?? null;
|
||||
if (!text && f.extractStatus === 'pending') {
|
||||
text = await this.extractDriveFile(f);
|
||||
}
|
||||
if (!text) continue;
|
||||
const remaining = TOTAL_LIMIT - used;
|
||||
if (remaining <= 0) break;
|
||||
const slice = text.length > remaining ? text.slice(0, remaining) : text;
|
||||
parts.push(`### ${f.name}\n${slice}`);
|
||||
used += slice.length;
|
||||
}
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
// 연동 폴더(자기+하위 트리) 내 활성 파일 수집. withText=true 면 추출 본문도 선택.
|
||||
private async collectLinkedFiles(
|
||||
projectId: string,
|
||||
withText: boolean,
|
||||
): Promise<DriveFile[]> {
|
||||
const folderIds = await this.collectLinkedFolderIds(projectId);
|
||||
if (folderIds.length === 0) return [];
|
||||
const qb = this.fileRepo
|
||||
.createQueryBuilder('f')
|
||||
.leftJoin('f.folder', 'fd')
|
||||
.where('fd.id IN (:...folderIds)', { folderIds })
|
||||
.andWhere("f.status = 'active'")
|
||||
.orderBy('f.name', 'ASC');
|
||||
if (withText) qb.addSelect('f.extractedText');
|
||||
return qb.getMany();
|
||||
}
|
||||
|
||||
// 프로젝트에 연동된 모든 폴더 + 그 하위 폴더 ID 집합(materialized path 기반)
|
||||
private async collectLinkedFolderIds(projectId: string): Promise<string[]> {
|
||||
const links = await this.linkRepo.find({
|
||||
where: { project: { id: projectId } },
|
||||
relations: { folder: true },
|
||||
});
|
||||
if (links.length === 0) return [];
|
||||
const ids = new Set<string>();
|
||||
for (const l of links) {
|
||||
const tree = await this.folderRepo.find({
|
||||
where: { path: Like(`${l.folder.path}%`) },
|
||||
select: { id: true },
|
||||
});
|
||||
tree.forEach((f) => ids.add(f.id));
|
||||
ids.add(l.folder.id);
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
// 폴더(자기+하위) 내 활성 파일 수
|
||||
private async countFilesInFolderTree(folder: DriveFolder): Promise<number> {
|
||||
const tree = await this.folderRepo.find({
|
||||
where: { path: Like(`${folder.path}%`) },
|
||||
select: { id: true },
|
||||
});
|
||||
const ids = tree.map((f) => f.id);
|
||||
if (folder.id && !ids.includes(folder.id)) ids.push(folder.id);
|
||||
if (ids.length === 0) return 0;
|
||||
return this.fileRepo.count({
|
||||
where: { folder: { id: In(ids) }, status: 'active' },
|
||||
});
|
||||
}
|
||||
|
||||
// 드라이브 파일 1건 텍스트 추출(NCP 다운로드 → 추출 → 캐싱). 결과 상태를 함께 기록.
|
||||
private async extractDriveFile(f: DriveFile): Promise<string | null> {
|
||||
if (!isExtractableType(f.mime, f.name)) {
|
||||
await this.fileRepo.update(f.id, { extractStatus: 'unsupported' });
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const buf = await this.storage.getObjectBuffer(f.objectKey);
|
||||
if (!buf) {
|
||||
await this.fileRepo.update(f.id, { extractStatus: 'failed' });
|
||||
return null;
|
||||
}
|
||||
const text = await extractTextFromBuffer(buf, f.mime, f.name);
|
||||
if (text) {
|
||||
await this.fileRepo.update(f.id, {
|
||||
extractedText: text,
|
||||
extractStatus: 'done',
|
||||
});
|
||||
return text;
|
||||
}
|
||||
await this.fileRepo.update(f.id, { extractStatus: 'failed' });
|
||||
return null;
|
||||
} catch {
|
||||
await this.fileRepo.update(f.id, { extractStatus: 'failed' });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private toDriveLinkResponse(
|
||||
link: ProjectFolderLink,
|
||||
documentCount: number,
|
||||
): ProjectDriveLinkResponse {
|
||||
return {
|
||||
id: link.id,
|
||||
folder: {
|
||||
id: link.folder.id,
|
||||
name: link.folder.name,
|
||||
path: link.folder.path,
|
||||
},
|
||||
permission: link.permission,
|
||||
linkedBy: link.linkedBy ? UserService.toPublic(link.linkedBy) : null,
|
||||
documentCount,
|
||||
createdAt: link.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// 드라이브 문서의 AI 반영 상태 — 추출 상태/형식으로 판별
|
||||
private driveAiStatus(f: DriveFile): DocumentAiStatus {
|
||||
if (f.extractStatus === 'done') return 'ready';
|
||||
if (f.extractStatus === 'unsupported') return 'unsupported';
|
||||
if (f.extractStatus === 'failed') return 'failed';
|
||||
// pending — 추출 가능 형식이면 '대기', 아니면 미지원
|
||||
return isExtractableType(f.mime, f.name) ? 'pending' : 'unsupported';
|
||||
}
|
||||
|
||||
// 프로젝트별 업무 완료/전체 수 — 진행률 집계용
|
||||
private async taskCountsByProject(
|
||||
ids: string[],
|
||||
|
||||
@@ -145,16 +145,14 @@ export class ScheduleService {
|
||||
});
|
||||
const saved = await this.eventRepo.save(event);
|
||||
|
||||
// 참석자로 지정된 사용자에게 알림(전 직원 일정은 개별 알림 생략)
|
||||
await this.notifyEvent(
|
||||
'schedule.invited',
|
||||
this.idsOf(dto.attendeeIds),
|
||||
actor,
|
||||
{
|
||||
eventTitle: event.title,
|
||||
start: event.startDate,
|
||||
},
|
||||
);
|
||||
// 참석자에게 초대 알림 — 전 직원 일정이면 전 사용자, 아니면 지정 참석자
|
||||
const recipients = event.allHands
|
||||
? await this.userService.findAllIds()
|
||||
: this.idsOf(dto.attendeeIds);
|
||||
await this.notifyEvent('schedule.invited', recipients, actor, {
|
||||
eventTitle: event.title,
|
||||
start: event.startDate,
|
||||
});
|
||||
return this.findEventOrThrow(saved.id);
|
||||
}
|
||||
|
||||
@@ -169,8 +167,9 @@ export class ScheduleService {
|
||||
});
|
||||
if (!event) throw new NotFoundException('일정을 찾을 수 없습니다.');
|
||||
|
||||
// 수정 전 참석자(알림 대상 비교용)
|
||||
// 수정 전 참석자·전직원 여부(알림 대상 비교용) — 아래 필드 변경 전에 캡처
|
||||
const oldIds = new Set((event.attendees ?? []).map((a) => a.id));
|
||||
const wasAllHands = event.allHands;
|
||||
|
||||
if (dto.categoryId !== undefined) {
|
||||
const category = await this.categoryRepo.findOne({
|
||||
@@ -195,10 +194,16 @@ export class ScheduleService {
|
||||
}
|
||||
await this.eventRepo.save(event);
|
||||
|
||||
// 알림: 새로 추가된 참석자 → 초대, 기존 참석자 → 수정됨
|
||||
const newIds = (event.attendees ?? []).map((a) => a.id);
|
||||
const invited = newIds.filter((i) => !oldIds.has(i));
|
||||
const stillThere = newIds.filter((i) => oldIds.has(i));
|
||||
// 알림: 전 직원 여부를 반영한 '실효 참석자' 기준으로 신규→초대, 기존→수정됨.
|
||||
// (전 직원 일정이면 전 사용자가 실효 참석자 — 수정 전/후 어느 쪽이든 반영)
|
||||
const allIds =
|
||||
wasAllHands || event.allHands ? await this.userService.findAllIds() : [];
|
||||
const oldEffective = new Set(wasAllHands ? allIds : [...oldIds]);
|
||||
const newEffective = event.allHands
|
||||
? allIds
|
||||
: (event.attendees ?? []).map((a) => a.id);
|
||||
const invited = newEffective.filter((i) => !oldEffective.has(i));
|
||||
const stillThere = newEffective.filter((i) => oldEffective.has(i));
|
||||
const payload = { eventTitle: event.title, start: event.startDate };
|
||||
await this.notifyEvent('schedule.invited', invited, actor, payload);
|
||||
await this.notifyEvent('schedule.updated', stillThere, actor, payload);
|
||||
@@ -213,7 +218,10 @@ export class ScheduleService {
|
||||
relations: ['attendees'],
|
||||
});
|
||||
if (!event) throw new NotFoundException('일정을 찾을 수 없습니다.');
|
||||
const recipients = (event.attendees ?? []).map((a) => a.id);
|
||||
// 전 직원 일정이면 전 사용자에게 삭제 알림
|
||||
const recipients = event.allHands
|
||||
? await this.userService.findAllIds()
|
||||
: (event.attendees ?? []).map((a) => a.id);
|
||||
|
||||
await this.eventRepo.delete({ id });
|
||||
|
||||
|
||||
@@ -73,6 +73,19 @@ export class CreateTaskDto {
|
||||
@IsUUID('4', { each: true })
|
||||
assigneeIds!: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'공동 지시자 사용자 ID 배열(선택, 프로젝트 멤버만 가능) — 원 지시자와 함께 완료 검토 권한을 가진다',
|
||||
type: [String],
|
||||
required: false,
|
||||
example: ['b2c3d4e5-...'],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(20, { message: '공동 지시자가 너무 많습니다.' })
|
||||
@IsUUID('4', { each: true })
|
||||
coIssuerIds?: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '마감 기한(ISO8601, 선택)',
|
||||
required: false,
|
||||
|
||||
@@ -80,6 +80,18 @@ export class UpdateTaskDto {
|
||||
@IsUUID('4', { each: true })
|
||||
assigneeIds?: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'공동 지시자 사용자 ID 배열(프로젝트 멤버만 가능). 제공 시 전체 교체, 빈 배열이면 모두 해제',
|
||||
type: [String],
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(20, { message: '공동 지시자가 너무 많습니다.' })
|
||||
@IsUUID('4', { each: true })
|
||||
coIssuerIds?: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '마감 기한(ISO8601). null 이면 마감 해제',
|
||||
required: false,
|
||||
|
||||
@@ -60,6 +60,16 @@ export class Task {
|
||||
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
|
||||
issuer!: User | null;
|
||||
|
||||
// 공동 지시자 목록 (프로젝트 멤버만 지정 가능 — 서비스에서 검증).
|
||||
// 원 지시자와 동일하게 완료 검토(승인/수정요청) 권한을 가진다.
|
||||
@ManyToMany(() => User)
|
||||
@JoinTable({
|
||||
name: 'task_co_issuers',
|
||||
joinColumn: { name: 'task_id', referencedColumnName: 'id' },
|
||||
inverseJoinColumn: { name: 'user_id', referencedColumnName: 'id' },
|
||||
})
|
||||
coIssuers!: User[];
|
||||
|
||||
// 담당자 목록 (프로젝트 멤버만 지정 가능 — 서비스에서 검증)
|
||||
@ManyToMany(() => User)
|
||||
@JoinTable({
|
||||
@@ -96,6 +106,23 @@ export class Task {
|
||||
@Column({ name: 'changes_note', type: 'text', nullable: true })
|
||||
changesNote!: string | null;
|
||||
|
||||
// 최근 검토(승인/수정요청)를 수행한 사용자 — 원 지시자 또는 공동 지시자.
|
||||
// 완료/수정요청 카드의 '검토자' 표기에 사용(사용자 삭제 시 SET NULL)
|
||||
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
|
||||
@JoinColumn({ name: 'reviewed_by' })
|
||||
reviewedBy!: User | null;
|
||||
|
||||
// --- 마감 임박·초과 알림(cron) 발송 여부 플래그 ---
|
||||
// 중복 발송을 막기 위해 발송 시각을 기록하고, 마감일이 변경되면 다시 null 로 초기화한다.
|
||||
|
||||
// 마감 임박(하루 앞) 알림 발송 시각 — 미발송이면 null
|
||||
@Column({ name: 'due_soon_notified_at', type: 'timestamptz', nullable: true })
|
||||
dueSoonNotifiedAt!: Date | null;
|
||||
|
||||
// 마감 초과 알림 발송 시각 — 미발송이면 null
|
||||
@Column({ name: 'overdue_notified_at', type: 'timestamptz', nullable: true })
|
||||
overdueNotifiedAt!: Date | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
|
||||
@@ -2,10 +2,18 @@ import {
|
||||
ForbiddenException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, IsNull, Repository, type SelectQueryBuilder } from 'typeorm';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import {
|
||||
Brackets,
|
||||
In,
|
||||
IsNull,
|
||||
Repository,
|
||||
type SelectQueryBuilder,
|
||||
} from 'typeorm';
|
||||
import { UserService, type PublicUser } from '../user/user.service';
|
||||
import { BusinessException } from '../../common/exceptions/business.exception';
|
||||
import { Project } from '../project/entities/project.entity';
|
||||
@@ -161,6 +169,8 @@ export interface TaskDetailResponse {
|
||||
checklist: ChecklistItemResponse[];
|
||||
assignees: PublicUser[];
|
||||
issuer: PublicUser | null;
|
||||
// 공동 지시자 — 원 지시자와 함께 완료 검토 권한을 가진다
|
||||
coIssuers: PublicUser[];
|
||||
dueDate: string | null;
|
||||
createdAt: string;
|
||||
comments: CommentResponse[];
|
||||
@@ -168,6 +178,8 @@ export interface TaskDetailResponse {
|
||||
files: AttachmentResponse[];
|
||||
approval: ApprovalInfo | null;
|
||||
changesNote: string | null;
|
||||
// 최근 검토(승인/수정요청)를 수행한 사용자 — 완료/수정요청 카드의 검토자 표기용
|
||||
reviewer: PublicUser | null;
|
||||
// 중간 점검일 + 작업자 보고 이력
|
||||
checkpoints: CheckpointResponse[];
|
||||
checkpointReports: CheckpointReportResponse[];
|
||||
@@ -194,6 +206,11 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
// 업무 비즈니스 로직
|
||||
@Injectable()
|
||||
export class TaskService {
|
||||
private readonly logger = new Logger(TaskService.name);
|
||||
|
||||
// 마감 임박 판정 창(시간) — 미완료 업무의 마감이 이 시간 이내로 다가오면 임박 알림 1회 발송
|
||||
private static readonly DUE_SOON_WINDOW_HOURS = 24;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Task)
|
||||
private readonly taskRepo: Repository<Task>,
|
||||
@@ -230,6 +247,99 @@ export class TaskService {
|
||||
};
|
||||
}
|
||||
|
||||
// 마감 알림 payload — 행위자 없는 시스템 알림. 마감 라벨 표시를 위해 dueDate 동봉
|
||||
private dueNotifyPayload(task: Task): Record<string, unknown> {
|
||||
return {
|
||||
projectId: task.project.id,
|
||||
projectName: task.project.name,
|
||||
taskSeq: task.seq,
|
||||
taskTitle: task.title,
|
||||
dueDate: task.dueDate ? task.dueDate.toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
// 마감 임박·초과 알림 — 매일 오전 9시. 미완료(done 아님) 업무 중
|
||||
// (1) 마감이 DUE_SOON_WINDOW_HOURS 이내로 다가온 건 → 담당자에게 '마감 임박' 1회
|
||||
// (2) 마감이 이미 지난 건 → 담당자에게 '마감 초과' 1회
|
||||
// 각각 발송 플래그(dueSoonNotifiedAt/overdueNotifiedAt)로 중복 발송을 막는다.
|
||||
// best-effort: 실패해도 서비스에 영향 없음(다음 주기 재시도).
|
||||
// 서버 타임존과 무관하게 한국 시간 오전 9시에 발송되도록 timeZone 고정.
|
||||
@Cron(CronExpression.EVERY_DAY_AT_9AM, { timeZone: 'Asia/Seoul' })
|
||||
async notifyDueTasks(): Promise<void> {
|
||||
const now = new Date();
|
||||
try {
|
||||
await this.notifyDueSoon(now);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`마감 임박 알림 실패: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
await this.notifyOverdue(now);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`마감 초과 알림 실패: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 마감 임박(하루 앞) 미발송 업무 → 담당자에게 알림. 다중 인스턴스 중복 발송을 막기 위해
|
||||
// 플래그를 원자적으로 선점(UPDATE ... WHERE ... IS NULL RETURNING)한 인스턴스만 발송한다.
|
||||
private async notifyDueSoon(now: Date): Promise<void> {
|
||||
const soonEnd = new Date(
|
||||
now.getTime() + TaskService.DUE_SOON_WINDOW_HOURS * 60 * 60 * 1000,
|
||||
);
|
||||
const claimed = await this.taskRepo
|
||||
.createQueryBuilder()
|
||||
.update(Task)
|
||||
.set({ dueSoonNotifiedAt: now })
|
||||
.where('status != :done', { done: 'done' })
|
||||
.andWhere('due_date BETWEEN :now AND :soonEnd', { now, soonEnd })
|
||||
.andWhere('due_soon_notified_at IS NULL')
|
||||
.returning('id')
|
||||
.execute();
|
||||
await this.notifyClaimedDue(claimed, 'task.due_soon', '마감 임박');
|
||||
}
|
||||
|
||||
// 마감 초과 미발송 업무 → 담당자에게 알림(위와 동일하게 선점 후 발송)
|
||||
private async notifyOverdue(now: Date): Promise<void> {
|
||||
const claimed = await this.taskRepo
|
||||
.createQueryBuilder()
|
||||
.update(Task)
|
||||
.set({ overdueNotifiedAt: now })
|
||||
.where('status != :done', { done: 'done' })
|
||||
.andWhere('due_date < :now', { now })
|
||||
.andWhere('overdue_notified_at IS NULL')
|
||||
.returning('id')
|
||||
.execute();
|
||||
await this.notifyClaimedDue(claimed, 'task.overdue', '마감 초과');
|
||||
}
|
||||
|
||||
// 선점(UPDATE RETURNING id)한 업무들을 로드해 담당자에게 마감 알림을 발송
|
||||
private async notifyClaimedDue(
|
||||
claimed: { raw: unknown },
|
||||
type: 'task.due_soon' | 'task.overdue',
|
||||
label: string,
|
||||
): Promise<void> {
|
||||
const ids = Array.isArray(claimed.raw)
|
||||
? (claimed.raw as { id: string }[]).map((r) => r.id)
|
||||
: [];
|
||||
if (ids.length === 0) return;
|
||||
const tasks = await this.taskRepo.find({
|
||||
where: { id: In(ids) },
|
||||
relations: ['assignees', 'project'],
|
||||
});
|
||||
for (const task of tasks) {
|
||||
await this.notification.notify({
|
||||
recipientIds: (task.assignees ?? []).map((a) => a.id),
|
||||
actorId: null,
|
||||
type,
|
||||
payload: this.dueNotifyPayload(task),
|
||||
});
|
||||
}
|
||||
this.logger.log(`${label} 알림 ${tasks.length}건 발송`);
|
||||
}
|
||||
|
||||
// 업무 목록 — 세그먼트(all/prog/todo/done)·검색·담당자 필터 + 오프셋 페이지네이션 + 세그먼트 카운트
|
||||
async list(
|
||||
projectId: string,
|
||||
@@ -369,6 +479,13 @@ export class TaskService {
|
||||
dto.assigneeIds,
|
||||
);
|
||||
|
||||
// 공동 지시자(선택) — 프로젝트 멤버만 가능, 원 지시자 본인은 제외
|
||||
const coIssuers = await this.resolveCoIssuers(
|
||||
project.id,
|
||||
dto.coIssuerIds,
|
||||
issuer.user.id,
|
||||
);
|
||||
|
||||
// 프로젝트 내 다음 순번 채번
|
||||
const last = await this.taskRepo.findOne({
|
||||
where: { project: { id: project.id } },
|
||||
@@ -384,6 +501,7 @@ export class TaskService {
|
||||
status: 'todo',
|
||||
dueDate: dto.dueDate ? new Date(dto.dueDate) : null,
|
||||
issuer: issuer.user,
|
||||
coIssuers,
|
||||
assignees,
|
||||
checklist: (dto.checklist ?? []).map((c, idx) =>
|
||||
this.checklistRepo.create({
|
||||
@@ -443,7 +561,15 @@ export class TaskService {
|
||||
if (dto.title !== undefined) task.title = dto.title;
|
||||
if (dto.content !== undefined) task.content = dto.content;
|
||||
if (dto.dueDate !== undefined) {
|
||||
task.dueDate = dto.dueDate ? new Date(dto.dueDate) : null;
|
||||
const newDue = dto.dueDate ? new Date(dto.dueDate) : null;
|
||||
const changed =
|
||||
(task.dueDate?.getTime() ?? null) !== (newDue?.getTime() ?? null);
|
||||
task.dueDate = newDue;
|
||||
// 마감일이 실제로 바뀌면 임박·초과 알림을 새 마감 기준으로 다시 보낼 수 있게 플래그 초기화
|
||||
if (changed) {
|
||||
task.dueSoonNotifiedAt = null;
|
||||
task.overdueNotifiedAt = null;
|
||||
}
|
||||
}
|
||||
if (dto.assigneeIds !== undefined) {
|
||||
task.assignees = await this.resolveMemberAssignees(
|
||||
@@ -451,6 +577,13 @@ export class TaskService {
|
||||
dto.assigneeIds,
|
||||
);
|
||||
}
|
||||
if (dto.coIssuerIds !== undefined) {
|
||||
task.coIssuers = await this.resolveCoIssuers(
|
||||
project.id,
|
||||
dto.coIssuerIds,
|
||||
task.issuer?.id ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
await this.taskRepo.save(task);
|
||||
|
||||
@@ -851,6 +984,20 @@ export class TaskService {
|
||||
taskSeq: task.seq,
|
||||
});
|
||||
|
||||
// 알림 — 업무 관련자(지시자 + 담당자, 업로더 본인 제외)에게 '파일 첨부'
|
||||
await this.notification.notify({
|
||||
recipientIds: [
|
||||
...(task.issuer ? [task.issuer.id] : []),
|
||||
...task.assignees.map((a) => a.id),
|
||||
],
|
||||
actorId: actingUserId,
|
||||
type: 'task.file_attached',
|
||||
payload: {
|
||||
...this.taskNotifyPayload(project, task, membership.user.name),
|
||||
fileName: name,
|
||||
},
|
||||
});
|
||||
|
||||
return this.toAttachmentResponse(
|
||||
{ ...saved, uploader: membership.user },
|
||||
project,
|
||||
@@ -1058,7 +1205,7 @@ export class TaskService {
|
||||
);
|
||||
}
|
||||
|
||||
// 내가 지시(생성)한 업무 — 검색/프로젝트/정렬 필터 + 오프셋 페이지네이션
|
||||
// 내가 지시(생성)한 업무 — 원 지시자 또는 공동 지시자. 검색/프로젝트/정렬 필터 + 오프셋 페이지네이션
|
||||
async listIssued(
|
||||
userId: string,
|
||||
page?: number,
|
||||
@@ -1072,7 +1219,16 @@ export class TaskService {
|
||||
.leftJoinAndSelect('task.assignees', 'assignee')
|
||||
.leftJoinAndSelect('task.issuer', 'issuer')
|
||||
.leftJoinAndSelect('task.checklist', 'checklist')
|
||||
.where('task.issuer = :userId', { userId });
|
||||
// 원 지시자이거나 공동 지시자(task_co_issuers)면 '내가 지시한 업무'에 포함.
|
||||
// 공동 지시자는 EXISTS 서브쿼리로 판정해 조인으로 인한 행 중복(페이지네이션 왜곡)을 피한다.
|
||||
.where(
|
||||
new Brackets((w) => {
|
||||
w.where('task.issuer = :userId').orWhere(
|
||||
'EXISTS (SELECT 1 FROM task_co_issuers tci WHERE tci.task_id = task.id AND tci.user_id = :userId)',
|
||||
);
|
||||
}),
|
||||
)
|
||||
.setParameter('userId', userId);
|
||||
this.applyMyTaskFilters(qb, filters);
|
||||
const [tasks, total] = await qb
|
||||
.skip(pg.skip)
|
||||
@@ -1127,17 +1283,26 @@ export class TaskService {
|
||||
|
||||
const isAdmin = membership.roleType === 'admin';
|
||||
const isIssuer = task.issuer?.id === actingUserId;
|
||||
// 공동 지시자도 원 지시자와 동일한 검토 권한을 가진다
|
||||
const isCoIssuer = (task.coIssuers ?? []).some(
|
||||
(u) => u.id === actingUserId,
|
||||
);
|
||||
const isAssignee = task.assignees.some((a) => a.id === actingUserId);
|
||||
// 승인/수정요청(review 출발)은 지시자(또는 admin), 그 외 작업 전이는 담당자(또는 admin/지시자)
|
||||
// 승인/수정요청(review 출발)은 지시자·공동 지시자(또는 admin), 그 외 작업 전이는 담당자(또는 admin/지시자)
|
||||
const permitted =
|
||||
from === 'review'
|
||||
? isAdmin || isIssuer
|
||||
: isAdmin || isIssuer || isAssignee;
|
||||
? isAdmin || isIssuer || isCoIssuer
|
||||
: isAdmin || isIssuer || isCoIssuer || isAssignee;
|
||||
if (!permitted) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
task.status = next;
|
||||
// 검토 전이(승인→done / 수정요청→changes) 시 실제 검토자 기록 — 완료/수정요청 카드의 검토자 표기용.
|
||||
// 원 지시자·공동 지시자·admin 누구든 검토한 사람이 남는다.
|
||||
if (next === 'done' || next === 'changes') {
|
||||
task.reviewedBy = membership.user;
|
||||
}
|
||||
await this.taskRepo.save(task);
|
||||
|
||||
// 승인 완료(→done) 시 체크리스트 항목을 모두 완료 처리 + 보완 내용 비움(부수효과로 영속)
|
||||
@@ -1158,11 +1323,16 @@ export class TaskService {
|
||||
taskSeq: task.seq,
|
||||
});
|
||||
|
||||
// 알림 — 전이 종류별 수신자(승인요청→지시자, 승인/수정요청→담당자). 시작(→prog)은 알림 없음
|
||||
// 알림 — 전이 종류별 수신자(승인요청→지시자·공동지시자, 승인/수정요청→담당자). 시작(→prog)은 알림 없음
|
||||
const actorName = membership.user.name;
|
||||
// 검토자(원 지시자 + 공동 지시자) — 승인요청·시작 알림 수신 대상. 행위자는 notify 에서 자동 제외
|
||||
const reviewerIds = [
|
||||
...(task.issuer ? [task.issuer.id] : []),
|
||||
...(task.coIssuers ?? []).map((u) => u.id),
|
||||
];
|
||||
if (next === 'review') {
|
||||
await this.notification.notify({
|
||||
recipientIds: task.issuer ? [task.issuer.id] : [],
|
||||
recipientIds: reviewerIds,
|
||||
actorId: actingUserId,
|
||||
type: 'task.approval_requested',
|
||||
payload: this.taskNotifyPayload(project, task, actorName),
|
||||
@@ -1182,12 +1352,16 @@ export class TaskService {
|
||||
payload: this.taskNotifyPayload(project, task, actorName),
|
||||
});
|
||||
} else if (next === 'prog') {
|
||||
// 시작(todo→prog)·재개(changes→prog) — 담당자가 작업을 시작했음을 지시자에게
|
||||
// 시작(todo→prog)·재개(changes→prog) — 담당자가 작업을 시작했음을 지시자·공동 지시자에게.
|
||||
// resumed=true 면 '재개', 아니면 '시작'으로 문구 분기(수정 요청 후 재개 구분).
|
||||
await this.notification.notify({
|
||||
recipientIds: task.issuer ? [task.issuer.id] : [],
|
||||
recipientIds: reviewerIds,
|
||||
actorId: actingUserId,
|
||||
type: 'task.started',
|
||||
payload: this.taskNotifyPayload(project, task, actorName),
|
||||
payload: {
|
||||
...this.taskNotifyPayload(project, task, actorName),
|
||||
resumed: from === 'changes',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1207,7 +1381,14 @@ export class TaskService {
|
||||
private async getTaskOrThrow(projectId: string, seq: number): Promise<Task> {
|
||||
const task = await this.taskRepo.findOne({
|
||||
where: { project: { id: projectId }, seq },
|
||||
relations: ['assignees', 'issuer', 'checklist', 'approvalRequestedBy'],
|
||||
relations: [
|
||||
'assignees',
|
||||
'issuer',
|
||||
'coIssuers',
|
||||
'checklist',
|
||||
'approvalRequestedBy',
|
||||
'reviewedBy',
|
||||
],
|
||||
});
|
||||
if (!task) {
|
||||
throw new NotFoundException();
|
||||
@@ -1282,6 +1463,31 @@ export class TaskService {
|
||||
return memberships.map((m) => m.user);
|
||||
}
|
||||
|
||||
// 공동 지시자 ID 목록 → User[] 로 변환. 프로젝트 멤버만 허용하며 원 지시자(excludeUserId)는 제외.
|
||||
// 목록이 없거나 비면 빈 배열.
|
||||
private async resolveCoIssuers(
|
||||
projectId: string,
|
||||
coIssuerIds: string[] | undefined,
|
||||
excludeUserId: string | null,
|
||||
): Promise<ProjectMember['user'][]> {
|
||||
const uniqueIds = [...new Set(coIssuerIds ?? [])].filter(
|
||||
(id) => id !== excludeUserId,
|
||||
);
|
||||
if (uniqueIds.length === 0) return [];
|
||||
const memberships = await this.memberRepo.find({
|
||||
where: { project: { id: projectId }, user: { id: In(uniqueIds) } },
|
||||
relations: ['user'],
|
||||
});
|
||||
if (memberships.length !== uniqueIds.length) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'공동 지시자는 프로젝트 멤버만 지정할 수 있습니다.',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
return memberships.map((m) => m.user);
|
||||
}
|
||||
|
||||
// 업무별 댓글 수(답글 포함) 집계 — 목록 commentCount 용
|
||||
private async commentCountsByTask(
|
||||
taskIds: string[],
|
||||
@@ -1540,9 +1746,10 @@ export class TaskService {
|
||||
reported: reportedCheckpointIds.has(c.id),
|
||||
}));
|
||||
|
||||
// 보고 '내용'은 지시자 + 해당 업무 담당자만 열람 가능(그 외에는 빈 배열)
|
||||
// 보고 '내용'은 지시자 + 공동 지시자 + 해당 업무 담당자만 열람 가능(그 외에는 빈 배열)
|
||||
const canViewReports =
|
||||
task.issuer?.id === viewerId ||
|
||||
(task.coIssuers ?? []).some((u) => u.id === viewerId) ||
|
||||
(task.assignees ?? []).some((a) => a.id === viewerId);
|
||||
const checkpointReports: CheckpointReportResponse[] = canViewReports
|
||||
? reportRows.map((r) => ({
|
||||
@@ -1580,6 +1787,7 @@ export class TaskService {
|
||||
checklist,
|
||||
assignees: (task.assignees ?? []).map((u) => UserService.toPublic(u)),
|
||||
issuer: task.issuer ? UserService.toPublic(task.issuer) : null,
|
||||
coIssuers: (task.coIssuers ?? []).map((u) => UserService.toPublic(u)),
|
||||
dueDate: task.dueDate ? task.dueDate.toISOString() : null,
|
||||
createdAt: task.createdAt.toISOString(),
|
||||
comments,
|
||||
@@ -1587,6 +1795,7 @@ export class TaskService {
|
||||
files,
|
||||
approval,
|
||||
changesNote: task.status === 'changes' ? task.changesNote : null,
|
||||
reviewer: task.reviewedBy ? UserService.toPublic(task.reviewedBy) : null,
|
||||
checkpoints,
|
||||
checkpointReports,
|
||||
};
|
||||
|
||||
@@ -76,6 +76,12 @@ export class UserService implements OnApplicationBootstrap {
|
||||
});
|
||||
}
|
||||
|
||||
// 전체 사용자 id 목록 — 전사 알림(예: 전 직원 일정)의 수신자 산출용
|
||||
async findAllIds(): Promise<string[]> {
|
||||
const rows = await this.userRepository.find({ select: { id: true } });
|
||||
return rows.map((u) => u.id);
|
||||
}
|
||||
|
||||
// 로그인 검증용 — passwordHash 를 명시적으로 포함하여 조회
|
||||
findByEmailWithPassword(email: string): Promise<User | null> {
|
||||
return this.userRepository
|
||||
|
||||
@@ -10,6 +10,11 @@ services:
|
||||
environment:
|
||||
- CHOKIDAR_USEPOLLING=true
|
||||
- WATCHPACK_POLLING=true
|
||||
# 화상회의 — 브라우저가 접속할 LiveKit URL(개발은 LiveKit Cloud 사용).
|
||||
# 런타임 env 로 주입해 이미지 재빌드 없이 .env 변경 + 컨테이너 재생성만으로 반영된다.
|
||||
- VITE_LIVEKIT_URL=${LIVEKIT_WS_URL}
|
||||
# 웹 푸시(VAPID) 공개키 — dev 는 Vite dev 서버 런타임 env 로 주입
|
||||
- VITE_VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
|
||||
volumes:
|
||||
- ./${FRONTEND_DIR}:/app
|
||||
- /app/node_modules
|
||||
@@ -36,6 +41,14 @@ services:
|
||||
networks: !override
|
||||
- app-network
|
||||
|
||||
# 화상회의는 개발에서 LiveKit Cloud 를 사용한다(로컬 SFU 컨테이너 없음).
|
||||
# WSL2/Docker Desktop 의 WebRTC 네트워킹 제약을 우회하기 위함.
|
||||
# base 의 livekit 은 운영 기본 서비스라, dev 에서는 selfhost-livekit 프로파일로 가두어
|
||||
# 기동되지 않게 한다(이 프로파일은 dev 에서 켜지 않으므로 컨테이너 미생성).
|
||||
livekit:
|
||||
profiles:
|
||||
- selfhost-livekit
|
||||
|
||||
db:
|
||||
# dev에서는 Docker Desktop 시작 시 자동 실행 방지 (base 의 always 를 덮어씀)
|
||||
restart: "no"
|
||||
|
||||
@@ -11,6 +11,10 @@ services:
|
||||
- BUILD_MODE=${BUILD_MODE}
|
||||
- VITE_API_BASE_URL=${BACKEND_API_URL}
|
||||
- VITE_AI_TIMEOUT_MS=${AI_TIMEOUT_MS}
|
||||
# 브라우저가 접속할 LiveKit WebSocket URL
|
||||
- VITE_LIVEKIT_URL=${LIVEKIT_WS_URL}
|
||||
# 웹 푸시(VAPID) 공개키 — 브라우저 푸시 구독에 사용
|
||||
- VITE_VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
|
||||
expose:
|
||||
- "80"
|
||||
environment:
|
||||
@@ -62,6 +66,17 @@ services:
|
||||
- REDIS_PASSWORD=${REDIS_PASSWORD}
|
||||
# AI 요청 타임아웃(ms) — 프론트(VITE_AI_TIMEOUT_MS)와 동일 값을 루트 .env 에서 공급
|
||||
- AI_TIMEOUT_MS=${AI_TIMEOUT_MS}
|
||||
# 화상회의(LiveKit) — 토큰 발급에 키/시크릿, 서버 API 호출에 내부 URL 사용
|
||||
- LIVEKIT_API_KEY=${LIVEKIT_API_KEY}
|
||||
- LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET}
|
||||
- LIVEKIT_URL=${LIVEKIT_URL}
|
||||
# 드라이브(NCP Object Storage) — 비시크릿만 루트에서 주입(ACCESS/SECRET 키는 backend env_file)
|
||||
- OBJECT_STORAGE_ENDPOINT=${OBJECT_STORAGE_ENDPOINT}
|
||||
- OBJECT_STORAGE_REGION=${OBJECT_STORAGE_REGION}
|
||||
- OBJECT_STORAGE_BUCKET=${OBJECT_STORAGE_BUCKET}
|
||||
# 웹 푸시(VAPID) — 비밀 아닌 값만 루트에서 주입(PRIVATE_KEY 는 backend env_file)
|
||||
- VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY}
|
||||
- VAPID_SUBJECT=${VAPID_SUBJECT}
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- app-network
|
||||
@@ -144,6 +159,42 @@ services:
|
||||
labels:
|
||||
- "com.centurylinklabs.watchtower.enable=true"
|
||||
|
||||
# ----------------------------------------
|
||||
# 5. LiveKit (화상회의 SFU) — 셀프호스팅 (운영 기본 서비스, 소스 직접 빌드)
|
||||
# SFU 코어(pkg/sfu) 커스터마이즈를 위해 공식 이미지 대신 livekit-server/ 소스를
|
||||
# 직접 빌드한다(git subtree, v1.8.4 기준). 소스·수정은 이 레포에서 관리한다.
|
||||
# 운영(base)에서는 db·redis 처럼 항상 함께 기동된다.
|
||||
# 개발(dev)에서는 docker-compose.dev.yml 이 selfhost-livekit 프로파일로 가두어
|
||||
# 생성되지 않는다(개발은 LiveKit Cloud 사용).
|
||||
# 배포·클러스터링 가이드: livekit/DEPLOY.md
|
||||
# ----------------------------------------
|
||||
livekit:
|
||||
build:
|
||||
context: ./livekit-server
|
||||
dockerfile: Dockerfile
|
||||
container_name: ${PROJECT_NAME}-livekit
|
||||
command: --config /etc/livekit/livekit.yaml
|
||||
environment:
|
||||
# 키/시크릿은 "key: secret" 형식으로 주입(콜론+공백 때문에 전체를 따옴표로 감쌈)
|
||||
- "LIVEKIT_KEYS=${LIVEKIT_API_KEY}: ${LIVEKIT_API_SECRET}"
|
||||
expose:
|
||||
- "7880" # 시그널링/HTTP API — 리버스 프록시(TLS)에서 wss 로 라우팅
|
||||
ports:
|
||||
- "7881:7881" # ICE/TCP 폴백
|
||||
- "7882:7882/udp" # ICE/UDP (mux)
|
||||
volumes:
|
||||
# 클러스터링 시에는 redis 비밀번호가 담긴 사본을 마운트(DEPLOY.md 참고)
|
||||
- ./livekit/livekit.prod.yaml:/etc/livekit/livekit.yaml:ro
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- app-network
|
||||
- proxy
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2.0'
|
||||
memory: 2g
|
||||
|
||||
networks:
|
||||
app-network:
|
||||
driver: bridge
|
||||
|
||||
@@ -3,5 +3,6 @@
|
||||
# VITE_ 변수는 docker-compose 가 루트 .env.development 값을 build arg 로 주입한다:
|
||||
# VITE_API_BASE_URL ← BACKEND_API_URL
|
||||
# VITE_AI_TIMEOUT_MS ← AI_TIMEOUT_MS
|
||||
# VITE_LIVEKIT_URL ← LIVEKIT_WS_URL
|
||||
# 따라서 이 파일에 별도로 작성할 값은 없다.
|
||||
# ==========================================
|
||||
|
||||
@@ -3,5 +3,6 @@
|
||||
# VITE_ 변수는 docker-compose 가 루트 .env.production 값을 build arg 로 주입한다:
|
||||
# VITE_API_BASE_URL ← BACKEND_API_URL
|
||||
# VITE_AI_TIMEOUT_MS ← AI_TIMEOUT_MS
|
||||
# VITE_LIVEKIT_URL ← LIVEKIT_WS_URL
|
||||
# 따라서 이 파일에 별도로 작성할 값은 없다.
|
||||
# ==========================================
|
||||
|
||||
@@ -14,6 +14,10 @@ ARG VITE_API_BASE_URL
|
||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||
ARG VITE_AI_TIMEOUT_MS
|
||||
ENV VITE_AI_TIMEOUT_MS=$VITE_AI_TIMEOUT_MS
|
||||
ARG VITE_LIVEKIT_URL
|
||||
ENV VITE_LIVEKIT_URL=$VITE_LIVEKIT_URL
|
||||
ARG VITE_VAPID_PUBLIC_KEY
|
||||
ENV VITE_VAPID_PUBLIC_KEY=$VITE_VAPID_PUBLIC_KEY
|
||||
|
||||
RUN npm run build-only -- --mode ${BUILD_MODE}
|
||||
|
||||
|
||||
Vendored
+4
@@ -5,6 +5,10 @@ interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string
|
||||
/** AI 요청 타임아웃(ms) — 백엔드 AI_TIMEOUT_MS 와 동일 값 */
|
||||
readonly VITE_AI_TIMEOUT_MS: string
|
||||
/** 브라우저가 접속할 LiveKit WebSocket URL (예: ws://localhost:7880) */
|
||||
readonly VITE_LIVEKIT_URL: string
|
||||
/** 웹 푸시(VAPID) 공개키 — 푸시 구독에 사용. 미설정 시 푸시 비활성 */
|
||||
readonly VITE_VAPID_PUBLIC_KEY: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
+2
-1
@@ -9,7 +9,8 @@ server {
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
|
||||
# 화상회의(LiveKit)용 카메라/마이크는 자기 출처(self)에 허용. 위치정보는 차단 유지.
|
||||
add_header Permissions-Policy "geolocation=(), microphone=(self), camera=(self)" always;
|
||||
|
||||
# ----------------------------------------
|
||||
# 압축 (Gzip)
|
||||
|
||||
Generated
+143
-60
@@ -9,6 +9,7 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"axios": "^1.14.0",
|
||||
"livekit-client": "^2.19.2",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.31",
|
||||
"vue-router": "^4.4.5"
|
||||
@@ -556,6 +557,12 @@
|
||||
"specificity": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@bufbuild/protobuf": {
|
||||
"version": "1.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz",
|
||||
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
|
||||
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
|
||||
@@ -720,12 +727,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
@@ -1074,6 +1082,21 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@livekit/mutex": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz",
|
||||
"integrity": "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@livekit/protocol": {
|
||||
"version": "1.45.8",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.45.8.tgz",
|
||||
"integrity": "sha512-Q+l57E7w/xxOBFVWzdX5rkAZO7ffyF+rlDzNUYq2SU114+5aTyCq+PK4unaEVDNd4952Af7wteKr3sOgasGuaA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
||||
@@ -1275,9 +1298,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1295,9 +1315,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1315,9 +1332,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1335,9 +1349,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1355,9 +1366,6 @@
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1375,9 +1383,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1395,9 +1400,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1415,9 +1417,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1606,9 +1605,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1626,9 +1622,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1646,9 +1639,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1666,9 +1656,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1686,9 +1673,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1706,9 +1690,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1754,6 +1735,17 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz",
|
||||
@@ -1838,6 +1830,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/dom-mediacapture-record": {
|
||||
"version": "1.0.22",
|
||||
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz",
|
||||
"integrity": "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
||||
@@ -3747,6 +3746,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/events": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.x"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||
@@ -4347,6 +4355,15 @@
|
||||
"@pkgjs/parseargs": "^0.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz",
|
||||
"integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/js-beautify": {
|
||||
"version": "1.15.4",
|
||||
"resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz",
|
||||
@@ -4717,9 +4734,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4741,9 +4755,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4765,9 +4776,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4789,9 +4797,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -4847,6 +4852,26 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/livekit-client": {
|
||||
"version": "2.19.2",
|
||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.19.2.tgz",
|
||||
"integrity": "sha512-Kvk07QYDWRAbmYNLRll04ZIuxMQobW/oLPYnmR1kCy8GGHpU0gqyHf704Rz+29zfy8IJZRjKqeVbzGSKn9sumw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@livekit/mutex": "1.1.1",
|
||||
"@livekit/protocol": "1.45.8",
|
||||
"events": "^3.3.0",
|
||||
"jose": "^6.1.0",
|
||||
"loglevel": "^1.9.2",
|
||||
"sdp-transform": "^2.15.0",
|
||||
"tslib": "2.8.1",
|
||||
"typed-emitter": "^2.1.0",
|
||||
"webrtc-adapter": "9.0.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/dom-mediacapture-record": "^1"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
@@ -4877,6 +4902,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loglevel": {
|
||||
"version": "1.9.2",
|
||||
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
|
||||
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/loglevel"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -5690,6 +5728,16 @@
|
||||
"queue-microtask": "^1.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
@@ -5703,6 +5751,21 @@
|
||||
"node": ">=v12.22.7"
|
||||
}
|
||||
},
|
||||
"node_modules/sdp": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.2.tgz",
|
||||
"integrity": "sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sdp-transform": {
|
||||
"version": "2.15.0",
|
||||
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz",
|
||||
"integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"sdp-verify": "checker.js"
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||
@@ -6136,9 +6199,7 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/type-check": {
|
||||
"version": "0.4.0",
|
||||
@@ -6166,6 +6227,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/typed-emitter": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz",
|
||||
"integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"rxjs": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
@@ -6833,6 +6903,19 @@
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/webrtc-adapter": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.5.tgz",
|
||||
"integrity": "sha512-U9vjByy/sK2OMXu5mmfuZFKTMIUQe34c0JXRO+oDrxJTsntdYT2iIFwYMOV7HhMTuktcZLGf2W1N/OcSf9ssWg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"sdp": "^3.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0",
|
||||
"npm": ">=3.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/whatwg-mimetype": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.14.0",
|
||||
"livekit-client": "^2.19.2",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.31",
|
||||
"vue-router": "^4.4.5"
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/* eslint-disable */
|
||||
// Relay 서비스워커 — 웹 푸시(Level2): 사이트/탭을 닫아도 OS 토스트를 띄운다.
|
||||
// 푸시 페이로드는 백엔드 WebPushService 가 { title, body, url } 로 보낸다.
|
||||
|
||||
self.addEventListener('install', () => {
|
||||
// 새 워커를 즉시 활성화
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
self.addEventListener('activate', (event) => {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
if (!event.data) return
|
||||
let payload = {}
|
||||
try {
|
||||
payload = event.data.json()
|
||||
} catch (e) {
|
||||
payload = { title: 'Relay', body: '새 알림이 도착했습니다' }
|
||||
}
|
||||
const title = payload.title || 'Relay'
|
||||
const body = payload.body || ''
|
||||
const url = payload.url || null
|
||||
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
// 열린 클라이언트(탭)가 있으면 페이지의 SSE 가 이미 토스트를 처리 → 중복 표시 생략
|
||||
const clients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
includeUncontrolled: true,
|
||||
})
|
||||
if (clients.length > 0) return
|
||||
await self.registration.showNotification(title, {
|
||||
body,
|
||||
icon: '/relay-icon-256.png',
|
||||
badge: '/relay-icon-256.png',
|
||||
data: { url },
|
||||
})
|
||||
})(),
|
||||
)
|
||||
})
|
||||
|
||||
// 브라우저가 푸시 구독을 자체 회전(만료·교체)시키면 발생 — 새 구독으로 재등록해
|
||||
// 사용자가 앱을 다시 열지 않아도 푸시가 끊기지 않게 한다. best-effort(실패 시 앱 재진입의
|
||||
// enablePush(true) 가 최신화). 백엔드 경로는 기본 프록시('/api') 기준.
|
||||
self.addEventListener('pushsubscriptionchange', (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
try {
|
||||
// 브라우저가 새 구독을 넘겨줬으면 그대로, 아니면 이전 구독의 VAPID 키로 재구독
|
||||
let sub = event.newSubscription || null
|
||||
if (!sub) {
|
||||
const opts =
|
||||
event.oldSubscription && event.oldSubscription.options
|
||||
? event.oldSubscription.options
|
||||
: null
|
||||
const appServerKey = opts && opts.applicationServerKey
|
||||
// 키가 없으면 재구독 불가 — 앱 재진입 때 복구되도록 조용히 종료
|
||||
if (!appServerKey) return
|
||||
sub = await self.registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: appServerKey,
|
||||
})
|
||||
}
|
||||
if (!sub) return
|
||||
const json = sub.toJSON()
|
||||
await fetch('/api/notifications/push/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
endpoint: sub.endpoint,
|
||||
keys: {
|
||||
p256dh: (json.keys && json.keys.p256dh) || '',
|
||||
auth: (json.keys && json.keys.auth) || '',
|
||||
},
|
||||
}),
|
||||
})
|
||||
} catch (e) {
|
||||
/* 재구독/재등록 실패 무시 — 앱 재진입 시 최신화 */
|
||||
}
|
||||
})(),
|
||||
)
|
||||
})
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close()
|
||||
const url = (event.notification.data && event.notification.data.url) || '/'
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const clients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
includeUncontrolled: true,
|
||||
})
|
||||
// 이미 열린 창이 있으면 포커스 후 해당 경로로 이동
|
||||
for (const client of clients) {
|
||||
if ('focus' in client) {
|
||||
await client.focus()
|
||||
if ('navigate' in client && url) {
|
||||
try {
|
||||
await client.navigate(url)
|
||||
} catch (e) {
|
||||
/* 이동 실패 무시 */
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
// 열린 창이 없으면 새 창
|
||||
if (self.clients.openWindow) await self.clients.openWindow(url)
|
||||
})(),
|
||||
)
|
||||
})
|
||||
@@ -52,6 +52,21 @@
|
||||
--shadow-sm: 0 1px 2px rgba(20, 24, 33, 0.05);
|
||||
--shadow-md: 0 4px 16px rgba(20, 24, 33, 0.1), 0 1px 3px rgba(20, 24, 33, 0.06);
|
||||
--shadow-pop: 0 8px 28px rgba(20, 24, 33, 0.16), 0 2px 6px rgba(20, 24, 33, 0.08);
|
||||
|
||||
/* 다크 캔버스(화상회의 회의실/프리조인 전용) */
|
||||
--d-bg: #0e1014;
|
||||
--d-panel: #16191f;
|
||||
--d-panel-2: #1b1f27;
|
||||
--d-border: #2a2f39;
|
||||
--d-text: #eceef2;
|
||||
--d-text-2: #aab2bf;
|
||||
--d-text-3: #717a88;
|
||||
--tile: #1b1d23;
|
||||
/* 다크 위 상태색(라이트 토큰을 다크에 쓰면 탁해져 밝은 변형을 별도 정의) */
|
||||
--d-red: #ff7a8a;
|
||||
--d-green: #46d18a;
|
||||
--d-amber: #f0c478;
|
||||
--d-accent-soft: #c7c2ff;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
|
||||
@@ -126,6 +126,8 @@ function select(value: T): void {
|
||||
}
|
||||
.dd.block .dd-label {
|
||||
flex: 1;
|
||||
/* button 기본 text-align:center 상속으로 가운데 보이는 것을 폼 셀렉트답게 좌측 정렬 */
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* 트리거 — 기존 정렬/필터 pill 과 동일한 외형 */
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
<script setup lang="ts">
|
||||
// 공용 활동 피드 — 날짜 그룹 단위로 "원형 아이콘 + 아바타 + 본문 + 시간" 행을 렌더한다.
|
||||
// 프로젝트 활동 페이지와 드라이브 기록 패널이 동일한 디자인을 공유하기 위한 컴포넌트.
|
||||
// 본문(item.html)은 스토어에서 사용자 입력을 escapeHtml 로 이스케이프한 뒤 신뢰 태그만 조립한 마크업이다.
|
||||
import { defineComponent, h } from 'vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import type { ActivityIcon, ProjectActivityDay } from '@/mock/relay.mock'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
days: ProjectActivityDay[]
|
||||
// 카드 테두리/배경 없이(모달 등 내부에 임베드할 때) 본문만 렌더
|
||||
bare?: boolean
|
||||
}>(),
|
||||
{ bare: false },
|
||||
)
|
||||
|
||||
// 활동 아이콘 — name 으로 SVG path 분기
|
||||
const ACT_PATHS: Record<ActivityIcon, string> = {
|
||||
check: '<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
|
||||
pencil: '<path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/>',
|
||||
'member-add': '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M19 8v6M22 11h-6"/>',
|
||||
'member-check': '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="m17 11 2 2 4-4"/>',
|
||||
'member-remove': '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 11h-6"/>',
|
||||
lock: '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
|
||||
repo: '<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/>',
|
||||
}
|
||||
const ActIcon = defineComponent({
|
||||
props: { name: { type: String, required: true } },
|
||||
setup(props) {
|
||||
return () =>
|
||||
h('svg', {
|
||||
viewBox: '0 0 24 24',
|
||||
fill: 'none',
|
||||
stroke: 'currentColor',
|
||||
'stroke-width': '2',
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
innerHTML: ACT_PATHS[props.name as ActivityIcon] ?? '',
|
||||
})
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- act-text 의 v-html 은 스토어에서 사용자 입력을 escapeHtml 로 이스케이프한 뒤 신뢰 태그만 조립한 마크업이다 (XSS 위험 없음) -->
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div
|
||||
class="feed"
|
||||
:class="{ bare }"
|
||||
>
|
||||
<div class="feed-inner">
|
||||
<template
|
||||
v-for="group in days"
|
||||
:key="group.day"
|
||||
>
|
||||
<div class="day">
|
||||
{{ group.day }}
|
||||
</div>
|
||||
<div
|
||||
v-for="(item, i) in group.items"
|
||||
:key="group.day + i"
|
||||
class="act"
|
||||
:class="{ 'last-in-group': i === group.items.length - 1 }"
|
||||
>
|
||||
<span
|
||||
class="act-ico"
|
||||
:class="item.tone"
|
||||
><ActIcon :name="item.icon" /></span>
|
||||
<div class="act-main">
|
||||
<span class="act-av"><UserAvatar :url="item.avatarUrl" /></span>
|
||||
<span
|
||||
class="act-text"
|
||||
v-html="item.html"
|
||||
/>
|
||||
</div>
|
||||
<span class="act-time">{{ item.time }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 활동 피드 */
|
||||
.feed {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.625rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 0.375rem 1.25rem 1.125rem;
|
||||
}
|
||||
/* 모달 등 내부 임베드 — 카드 외곽 제거 */
|
||||
.feed.bare {
|
||||
background: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
.feed-inner {
|
||||
position: relative;
|
||||
}
|
||||
.day {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-3);
|
||||
letter-spacing: 0.0125rem;
|
||||
padding: 1.125rem 0 0.375rem;
|
||||
}
|
||||
/* bare 모드의 첫 날짜 헤더는 위 여백 축소(모달 본문 padding 과 중복 방지) */
|
||||
.feed.bare .feed-inner > .day:first-child {
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
.act {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.8125rem;
|
||||
padding: 0.5625rem 0;
|
||||
position: relative;
|
||||
}
|
||||
.act::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0.969rem;
|
||||
top: 50%;
|
||||
bottom: -0.5625rem;
|
||||
width: 1.5px;
|
||||
background: var(--border);
|
||||
z-index: 0;
|
||||
}
|
||||
.act.last-in-group::before {
|
||||
display: none;
|
||||
}
|
||||
.act-ico {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
.act-ico svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
.act-ico.task {
|
||||
background: var(--blue-weak);
|
||||
color: var(--blue);
|
||||
}
|
||||
.act-ico.member {
|
||||
background: var(--green-weak);
|
||||
color: var(--green);
|
||||
}
|
||||
.act-ico.setting {
|
||||
background: var(--amber-weak);
|
||||
color: var(--amber);
|
||||
}
|
||||
.act-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.act-av {
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.act-text {
|
||||
font-size: 0.844rem;
|
||||
color: var(--text-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.act-text :deep(b) {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.act-text :deep(.tgt) {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.act-text :deep(.tgt:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.act-text :deep(.st) {
|
||||
font-weight: 600;
|
||||
}
|
||||
.act-text :deep(.st.prog) {
|
||||
color: var(--blue);
|
||||
}
|
||||
.act-text :deep(.st.done) {
|
||||
color: var(--green);
|
||||
}
|
||||
.act-time {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
// 프로젝트 문서의 AI 추천 반영 상태 배지
|
||||
// ready: 반영됨 / pending: 추출 대기 / failed: 추출 실패 / unsupported: 미지원 형식
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
status: 'ready' | 'pending' | 'failed' | 'unsupported'
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// 상태별 라벨·툴팁·스타일 클래스
|
||||
const META: Record<Props['status'], { label: string; title: string; cls: string }> = {
|
||||
ready: {
|
||||
label: 'AI 반영',
|
||||
title: 'AI 업무 추천에 이 문서 내용이 반영됩니다.',
|
||||
cls: 'ok',
|
||||
},
|
||||
pending: {
|
||||
label: '추출 대기',
|
||||
title: '다음 AI 업무 추천 시 문서 내용을 추출해 반영합니다.',
|
||||
cls: 'muted',
|
||||
},
|
||||
failed: {
|
||||
label: '추출 실패',
|
||||
title: '문서에서 텍스트를 추출하지 못해 AI 추천에 반영되지 않습니다.',
|
||||
cls: 'warn',
|
||||
},
|
||||
unsupported: {
|
||||
label: 'AI 반영 불가',
|
||||
title: '미지원 형식이라 AI 추천에 반영되지 않습니다. (txt·csv·pdf·docx 지원)',
|
||||
cls: 'muted',
|
||||
},
|
||||
}
|
||||
|
||||
const meta = computed(() => META[props.status])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="ai-badge"
|
||||
:class="meta.cls"
|
||||
:title="meta.title"
|
||||
>
|
||||
{{ meta.label }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ai-badge {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 1.25rem;
|
||||
padding: 0 0.4375rem;
|
||||
border-radius: 0.3125rem;
|
||||
border: 1px solid transparent;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
cursor: default;
|
||||
}
|
||||
.ai-badge.ok {
|
||||
color: var(--green);
|
||||
background: var(--green-weak);
|
||||
border-color: var(--green-border);
|
||||
}
|
||||
.ai-badge.warn {
|
||||
color: var(--amber);
|
||||
background: var(--amber-weak);
|
||||
border-color: var(--amber-border);
|
||||
}
|
||||
.ai-badge.muted {
|
||||
color: var(--text-3);
|
||||
background: #f3f4f6;
|
||||
border-color: var(--border);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,208 @@
|
||||
<script setup lang="ts">
|
||||
// 드라이브 폴더 선택 모달 — 내 드라이브를 탐색해 프로젝트에 연동할 폴더를 고른다.
|
||||
import { ref } from 'vue'
|
||||
import BaseModal from '@/components/common/BaseModal.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import { useDrive } from '@/composables/useDrive'
|
||||
import type { DriveCrumb, DriveFolder } from '@/types/drive'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', folder: { id: string; name: string }): void
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
const drive = useDrive()
|
||||
const loading = ref(false)
|
||||
const breadcrumb = ref<DriveCrumb[]>([])
|
||||
const folders = ref<DriveFolder[]>([])
|
||||
|
||||
async function load(id: string | null): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await drive.list(id ?? undefined)
|
||||
breadcrumb.value = res.breadcrumb
|
||||
folders.value = res.folders
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
function pick(f: DriveFolder): void {
|
||||
emit('select', { id: f.id, name: f.name })
|
||||
}
|
||||
|
||||
void load(null)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal
|
||||
title="연동할 폴더 선택"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<template #subtitle>
|
||||
<b>내 드라이브</b>의 폴더(하위 포함)를 프로젝트의 AI 검토 문서로 연동합니다.
|
||||
</template>
|
||||
|
||||
<nav class="dfp-crumb">
|
||||
<a
|
||||
v-if="breadcrumb.length"
|
||||
href="#"
|
||||
@click.prevent="load(null)"
|
||||
>드라이브</a>
|
||||
<b v-else>드라이브</b>
|
||||
<template
|
||||
v-for="(c, i) in breadcrumb"
|
||||
:key="c.id"
|
||||
>
|
||||
<span class="dfp-sep">›</span>
|
||||
<a
|
||||
v-if="i < breadcrumb.length - 1"
|
||||
href="#"
|
||||
@click.prevent="load(c.id)"
|
||||
>{{ c.name }}</a>
|
||||
<b v-else>{{ c.name }}</b>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<p
|
||||
v-if="loading"
|
||||
class="dfp-msg"
|
||||
>
|
||||
불러오는 중…
|
||||
</p>
|
||||
<EmptyState v-else-if="folders.length === 0">
|
||||
이 위치에 하위 폴더가 없습니다. 드라이브에서 폴더를 먼저 만들어 주세요.
|
||||
</EmptyState>
|
||||
<div
|
||||
v-else
|
||||
class="dfp-list"
|
||||
>
|
||||
<div
|
||||
v-for="f in folders"
|
||||
:key="f.id"
|
||||
class="dfp-row"
|
||||
>
|
||||
<button
|
||||
class="dfp-open"
|
||||
type="button"
|
||||
title="폴더 열기"
|
||||
@click="load(f.id)"
|
||||
>
|
||||
<span class="dfp-ico">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" /></svg>
|
||||
</span>
|
||||
<span class="dfp-name">{{ f.name }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn sm"
|
||||
type="button"
|
||||
@click="pick(f)"
|
||||
>
|
||||
연동
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #foot>
|
||||
<button
|
||||
class="mbtn"
|
||||
@click="emit('close')"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dfp-crumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.dfp-crumb a {
|
||||
color: var(--text-2);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.dfp-crumb a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
.dfp-sep {
|
||||
color: var(--text-3);
|
||||
opacity: 0.5;
|
||||
}
|
||||
.dfp-msg {
|
||||
font-size: 0.844rem;
|
||||
color: var(--text-3);
|
||||
padding: 1rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
.dfp-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.625rem;
|
||||
overflow: hidden;
|
||||
max-height: 18rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.dfp-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.dfp-row:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
.dfp-row:hover {
|
||||
background: #fafbfc;
|
||||
}
|
||||
.dfp-open {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
padding: 0.125rem 0;
|
||||
}
|
||||
.dfp-ico {
|
||||
flex: 0 0 auto;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.5rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--accent);
|
||||
background: var(--accent-weak);
|
||||
}
|
||||
.dfp-ico svg {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
}
|
||||
.dfp-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
// 드라이브 이름 입력 모달 — 폴더 생성 / 폴더·파일 이름 변경 공용
|
||||
import { computed, ref } from 'vue'
|
||||
import BaseModal from '@/components/common/BaseModal.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
label?: string
|
||||
placeholder?: string
|
||||
init?: string
|
||||
confirmText?: string
|
||||
}>(),
|
||||
{ label: '이름', placeholder: '', init: '', confirmText: '저장' },
|
||||
)
|
||||
const emit = defineEmits<{
|
||||
(e: 'save', name: string): void
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
const name = ref(props.init)
|
||||
const valid = computed(() => {
|
||||
const v = name.value.trim()
|
||||
return v.length >= 1 && v.length <= 255
|
||||
})
|
||||
|
||||
function submit(): void {
|
||||
if (!valid.value) return
|
||||
emit('save', name.value.trim())
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal
|
||||
:title="title"
|
||||
size="sm"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<label class="form-field"><span class="form-label"><span class="req">*</span> {{ label }}</span>
|
||||
<input
|
||||
v-model="name"
|
||||
class="form-input"
|
||||
:placeholder="placeholder"
|
||||
maxlength="255"
|
||||
autofocus
|
||||
@keydown.enter="submit"
|
||||
>
|
||||
</label>
|
||||
|
||||
<template #foot>
|
||||
<button
|
||||
class="mbtn"
|
||||
@click="emit('close')"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
class="mbtn primary"
|
||||
:disabled="!valid"
|
||||
@click="submit"
|
||||
>
|
||||
{{ confirmText }}
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
@@ -0,0 +1,257 @@
|
||||
<script setup lang="ts">
|
||||
// 회의실 채팅 — 데이터 채널 메시지 목록 + 입력. 새 메시지 도착 시 맨 아래로 스크롤.
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import MeetingIcon from './MeetingIcon.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { MAX_CHAT_LENGTH } from '@/composables/useLiveKitRoom'
|
||||
import type { ChatMessage } from '@/types/meeting'
|
||||
|
||||
interface Props {
|
||||
messages: ChatMessage[]
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{ (e: 'send', text: string): void }>()
|
||||
|
||||
const text = ref('')
|
||||
const listRef = ref<HTMLElement | null>(null)
|
||||
const inputRef = ref<HTMLTextAreaElement | null>(null)
|
||||
|
||||
// 입력 높이 자동 확장 — 최대 약 7.5rem까지는 스크롤바 없이 늘어나고, 초과 시에만 스크롤.
|
||||
const MAX_INPUT_HEIGHT = 120
|
||||
function autoGrow() {
|
||||
const el = inputRef.value
|
||||
if (!el) return
|
||||
el.style.height = 'auto'
|
||||
const full = el.scrollHeight
|
||||
el.style.height = `${Math.min(full, MAX_INPUT_HEIGHT)}px`
|
||||
el.style.overflowY = full > MAX_INPUT_HEIGHT ? 'auto' : 'hidden'
|
||||
}
|
||||
|
||||
// 시각(HH:MM) 표시
|
||||
function fmtTime(ts: number): string {
|
||||
const d = new Date(ts)
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function submit() {
|
||||
const body = text.value.trim()
|
||||
if (!body) return
|
||||
emit('send', body)
|
||||
text.value = ''
|
||||
void nextTick(autoGrow) // 전송 후 높이 초기화
|
||||
}
|
||||
|
||||
// 메시지 증가 시 맨 아래로
|
||||
watch(
|
||||
() => props.messages.length,
|
||||
async () => {
|
||||
await nextTick()
|
||||
const el = listRef.value
|
||||
if (el) el.scrollTop = el.scrollHeight
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat">
|
||||
<div
|
||||
ref="listRef"
|
||||
class="chat-list"
|
||||
>
|
||||
<div
|
||||
v-if="messages.length === 0"
|
||||
class="chat-empty"
|
||||
>
|
||||
아직 메시지가 없습니다. 첫 메시지를 보내보세요.
|
||||
</div>
|
||||
<div
|
||||
v-for="m in messages"
|
||||
:key="m.id"
|
||||
class="chat-msg"
|
||||
:class="{ self: m.self }"
|
||||
>
|
||||
<span
|
||||
v-if="!m.self"
|
||||
class="chat-av"
|
||||
><UserAvatar
|
||||
:url="m.senderAvatarUrl"
|
||||
:name="m.senderName"
|
||||
/></span>
|
||||
<div class="chat-col">
|
||||
<div class="chat-meta">
|
||||
<span
|
||||
v-if="!m.self"
|
||||
class="chat-name"
|
||||
>{{ m.senderName }}</span>
|
||||
<span class="chat-time">{{ fmtTime(m.ts) }}</span>
|
||||
</div>
|
||||
<div class="chat-bubble">
|
||||
{{ m.text }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chat-input">
|
||||
<textarea
|
||||
ref="inputRef"
|
||||
v-model="text"
|
||||
rows="1"
|
||||
:maxlength="MAX_CHAT_LENGTH"
|
||||
placeholder="메시지 보내기… (Shift+Enter 줄바꿈)"
|
||||
aria-label="채팅 메시지"
|
||||
@input="autoGrow"
|
||||
@keydown.enter.exact.prevent="submit"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="chat-send"
|
||||
:disabled="!text.trim()"
|
||||
title="보내기"
|
||||
@click="submit"
|
||||
>
|
||||
<MeetingIcon name="send" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.chat-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem;
|
||||
padding: 1rem 0.875rem;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--d-border) transparent;
|
||||
}
|
||||
.chat-list::-webkit-scrollbar {
|
||||
width: 0.4375rem;
|
||||
}
|
||||
.chat-list::-webkit-scrollbar-thumb {
|
||||
background: var(--d-border);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.chat-empty {
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--d-text-3);
|
||||
}
|
||||
.chat-msg {
|
||||
display: flex;
|
||||
gap: 0.5625rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.chat-msg.self {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
.chat-av {
|
||||
width: 1.875rem;
|
||||
height: 1.875rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chat-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
max-width: 80%;
|
||||
min-width: 0;
|
||||
}
|
||||
.chat-msg.self .chat-col {
|
||||
align-items: flex-end;
|
||||
}
|
||||
.chat-meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4375rem;
|
||||
}
|
||||
.chat-name {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--d-text-2);
|
||||
}
|
||||
.chat-time {
|
||||
font-size: 0.65625rem;
|
||||
color: var(--d-text-3);
|
||||
}
|
||||
.chat-bubble {
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
color: var(--d-text);
|
||||
background: var(--d-panel-2);
|
||||
border: 1px solid var(--d-border);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
border-top-left-radius: 0.25rem;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.chat-msg.self .chat-bubble {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
border-top-left-radius: 0.75rem;
|
||||
border-top-right-radius: 0.25rem;
|
||||
}
|
||||
.chat-input {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
border-top: 1px solid var(--d-border);
|
||||
}
|
||||
.chat-input textarea {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 2.5rem;
|
||||
max-height: 7.5rem;
|
||||
padding: 0.5625rem 0.8125rem;
|
||||
border: 1px solid var(--d-border);
|
||||
border-radius: 0.625rem;
|
||||
background: var(--d-panel-2);
|
||||
color: var(--d-text);
|
||||
font-family: inherit;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.4;
|
||||
outline: none;
|
||||
resize: none;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
.chat-input textarea:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.chat-input textarea::placeholder {
|
||||
color: var(--d-text-3);
|
||||
}
|
||||
.chat-send {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
border-radius: 0.625rem;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.chat-send svg {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
}
|
||||
.chat-send:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.chat-send:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,257 @@
|
||||
<script setup lang="ts">
|
||||
// 회의실 컨트롤 바 — 마이크/카메라/화면공유(준비중)/채팅/참여자/나가기.
|
||||
// 위치(pos)는 하단 중앙(bottom) 또는 상단 우측(top).
|
||||
import MeetingIcon from './MeetingIcon.vue'
|
||||
|
||||
interface Props {
|
||||
pos: 'bottom' | 'top'
|
||||
micEnabled: boolean
|
||||
camEnabled: boolean
|
||||
chatOpen: boolean
|
||||
peopleOpen: boolean
|
||||
unread: number
|
||||
}
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggle-mic'): void
|
||||
(e: 'toggle-cam'): void
|
||||
(e: 'toggle-chat'): void
|
||||
(e: 'toggle-people'): void
|
||||
(e: 'leave'): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="controlbar"
|
||||
:class="`pos-${pos}`"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="cb-btn"
|
||||
:class="{ 'danger-state': !micEnabled }"
|
||||
:title="micEnabled ? '마이크 끄기' : '마이크 켜기'"
|
||||
@click="emit('toggle-mic')"
|
||||
>
|
||||
<MeetingIcon :name="micEnabled ? 'mic' : 'micOff'" />
|
||||
<span class="cb-lbl">{{ micEnabled ? '마이크' : '음소거' }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cb-btn"
|
||||
:class="{ 'danger-state': !camEnabled }"
|
||||
:title="camEnabled ? '카메라 끄기' : '카메라 켜기'"
|
||||
@click="emit('toggle-cam')"
|
||||
>
|
||||
<MeetingIcon :name="camEnabled ? 'cam' : 'camOff'" />
|
||||
<span class="cb-lbl">{{ camEnabled ? '카메라' : '카메라 꺼짐' }}</span>
|
||||
</button>
|
||||
|
||||
<span class="cb-div" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="cb-btn soon"
|
||||
disabled
|
||||
title="화면 공유 (준비 중)"
|
||||
>
|
||||
<MeetingIcon name="screen" />
|
||||
<span class="cb-lbl">화면 공유</span>
|
||||
<span class="cb-soon">곧</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cb-btn"
|
||||
:class="{ on: chatOpen }"
|
||||
title="채팅"
|
||||
@click="emit('toggle-chat')"
|
||||
>
|
||||
<MeetingIcon name="chat" />
|
||||
<span class="cb-lbl">채팅</span>
|
||||
<span
|
||||
v-if="unread > 0"
|
||||
class="cb-badge"
|
||||
>{{ unread > 9 ? '9+' : unread }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cb-btn"
|
||||
:class="{ on: peopleOpen }"
|
||||
title="참여자"
|
||||
@click="emit('toggle-people')"
|
||||
>
|
||||
<MeetingIcon name="users" />
|
||||
<span class="cb-lbl">참여자</span>
|
||||
</button>
|
||||
|
||||
<span class="cb-div" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="cb-leave"
|
||||
title="나가기"
|
||||
@click="emit('leave')"
|
||||
>
|
||||
<MeetingIcon name="hangup" />
|
||||
<span class="cb-lbl">나가기</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.controlbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
z-index: 30;
|
||||
}
|
||||
.controlbar.pos-bottom {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: 1.375rem;
|
||||
transform: translateX(-50%);
|
||||
padding: 0.5625rem;
|
||||
background: rgba(22, 25, 31, 0.86);
|
||||
border: 1px solid var(--d-border);
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.5);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
.controlbar.pos-top {
|
||||
position: absolute;
|
||||
right: 1.125rem;
|
||||
top: 4.25rem;
|
||||
padding: 0.3125rem;
|
||||
background: rgba(22, 25, 31, 0.92);
|
||||
border: 1px solid var(--d-border);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
.cb-btn {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.1875rem;
|
||||
min-width: 3.75rem;
|
||||
height: 3.25rem;
|
||||
padding: 0 0.75rem;
|
||||
border: none;
|
||||
border-radius: 0.6875rem;
|
||||
background: transparent;
|
||||
color: var(--d-text);
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cb-btn svg {
|
||||
width: 1.3125rem;
|
||||
height: 1.3125rem;
|
||||
}
|
||||
.cb-lbl {
|
||||
font-size: 0.65625rem;
|
||||
font-weight: 600;
|
||||
color: var(--d-text-2);
|
||||
}
|
||||
.cb-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.cb-btn.danger-state,
|
||||
.cb-btn.danger-state .cb-lbl {
|
||||
color: var(--d-red);
|
||||
}
|
||||
.cb-btn.on {
|
||||
background: rgba(79, 70, 229, 0.22);
|
||||
}
|
||||
.cb-btn.on .cb-lbl,
|
||||
.cb-btn.on svg {
|
||||
color: var(--d-accent-soft);
|
||||
}
|
||||
.cb-btn.soon {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.cb-soon {
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
right: 0.5rem;
|
||||
font-size: 0.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--d-bg);
|
||||
background: var(--d-text-3);
|
||||
padding: 1px 0.25rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.cb-badge {
|
||||
position: absolute;
|
||||
top: 0.1875rem;
|
||||
right: 0.4375rem;
|
||||
min-width: 1rem;
|
||||
height: 1rem;
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 0.5rem;
|
||||
background: #e0364c;
|
||||
color: #fff;
|
||||
font-size: 0.59375rem;
|
||||
font-weight: 700;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
box-shadow: 0 0 0 2px rgba(22, 25, 31, 0.86);
|
||||
}
|
||||
.controlbar.pos-top .cb-badge {
|
||||
top: -0.1875rem;
|
||||
right: -0.125rem;
|
||||
box-shadow: 0 0 0 2px var(--d-panel-2);
|
||||
}
|
||||
.cb-div {
|
||||
width: 1px;
|
||||
height: 1.875rem;
|
||||
background: var(--d-border);
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
.cb-leave {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.1875rem;
|
||||
min-width: 3.75rem;
|
||||
height: 3.25rem;
|
||||
padding: 0 0.875rem;
|
||||
border: none;
|
||||
border-radius: 0.6875rem;
|
||||
background: #e0364c;
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cb-leave svg {
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
}
|
||||
.cb-leave .cb-lbl {
|
||||
color: #fff;
|
||||
}
|
||||
.cb-leave:hover {
|
||||
background: #c52a3e;
|
||||
}
|
||||
/* 상단 우측 모드: 라벨 숨기고 아이콘만 */
|
||||
.controlbar.pos-top .cb-lbl {
|
||||
display: none;
|
||||
}
|
||||
.controlbar.pos-top .cb-btn,
|
||||
.controlbar.pos-top .cb-leave {
|
||||
min-width: 0;
|
||||
height: 2.5rem;
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
.controlbar.pos-top .cb-soon {
|
||||
display: none;
|
||||
}
|
||||
.controlbar.pos-top .cb-div {
|
||||
height: 1.375rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,467 @@
|
||||
<script setup lang="ts">
|
||||
// 새 회의 만들기 — 공용 BaseModal 폼 모달(프로젝트 멤버 초대 모달과 동일 패턴).
|
||||
// 참여 제한 멤버 선택은 업무 생성의 담당자 할당과 동일한 형태:
|
||||
// 입력칸 고정 + 후보 드롭다운(focus 시) + 선택된 멤버는 입력칸 "아래"에 칩으로 표기.
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import BaseModal from '@/components/common/BaseModal.vue'
|
||||
import MeetingIcon from './MeetingIcon.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { useUser } from '@/composables/useUser'
|
||||
import { useMeeting } from '@/composables/useMeeting'
|
||||
import type { ApiMember } from '@/types/repo'
|
||||
import type { ApiMeetingRoom } from '@/types/meeting'
|
||||
|
||||
interface Props {
|
||||
// 편집 모드 — 전달되면 해당 방의 현재 값으로 채우고 수정한다(없으면 생성 모드).
|
||||
editRoom?: ApiMeetingRoom | null
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
// 생성/수정 성공 → 부모가 목록 갱신 + 모달 닫기
|
||||
(e: 'created'): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
const NAME_RE = /^[A-Za-z0-9가-힣ㄱ-ㅎㅏ-ㅣ_-]{1,64}$/
|
||||
|
||||
const userApi = useUser()
|
||||
const meetingApi = useMeeting()
|
||||
|
||||
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)
|
||||
|
||||
// --- 참여 허용 멤버 선택 (업무 담당자 할당과 동일한 형태) ---
|
||||
// 편집 모드면 현재 허용 멤버로 칩을 미리 채운다(백엔드가 진행자에게 allowedUsers 제공).
|
||||
const selected = ref<ApiMember[]>([...(props.editRoom?.allowedUsers ?? [])]) // 선택된 멤버(입력칸 아래 칩)
|
||||
const query = ref('')
|
||||
const found = ref<ApiMember[]>([]) // 검색 결과
|
||||
const showDropdown = ref(false)
|
||||
|
||||
// 검색어 변경 시 후보 갱신(서버 검색)
|
||||
watch(query, async (q) => {
|
||||
if (!restrict.value) return
|
||||
found.value = await userApi.search(q)
|
||||
})
|
||||
|
||||
// 아직 선택되지 않은 멤버 후보
|
||||
const candidates = computed(() => {
|
||||
const picked = new Set(selected.value.map((u) => u.id))
|
||||
return found.value.filter((u) => !picked.has(u.id)).slice(0, 8)
|
||||
})
|
||||
|
||||
async function onMemberFocus() {
|
||||
showDropdown.value = true
|
||||
if (found.value.length === 0) found.value = await userApi.search(query.value)
|
||||
}
|
||||
function onMemberBlur() {
|
||||
// 항목은 @mousedown.prevent 로 선택하므로 약간 지연 후 닫는다
|
||||
setTimeout(() => (showDropdown.value = false), 120)
|
||||
}
|
||||
function addMember(u: ApiMember) {
|
||||
if (!selected.value.some((x) => x.id === u.id)) selected.value.push(u)
|
||||
query.value = ''
|
||||
showDropdown.value = false
|
||||
}
|
||||
function removeMember(id: string) {
|
||||
selected.value = selected.value.filter((u) => u.id !== id)
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (submitting.value) return
|
||||
const n = name.value.trim()
|
||||
if (!NAME_RE.test(n)) {
|
||||
error.value = '방 이름은 한글·영문·숫자·-·_ 1~64자만 가능합니다.'
|
||||
return
|
||||
}
|
||||
// 이름은 표시용 라벨이라 중복을 허용한다(식별은 코드로). 별도 중복 검사 없음.
|
||||
error.value = ''
|
||||
submitting.value = true
|
||||
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,
|
||||
}
|
||||
try {
|
||||
if (props.editRoom) {
|
||||
await meetingApi.updateRoom(props.editRoom.id, payload)
|
||||
} else {
|
||||
await meetingApi.createRoom(payload)
|
||||
}
|
||||
emit('created')
|
||||
} catch {
|
||||
error.value = isEdit.value
|
||||
? '방 수정에 실패했습니다. (이름이 이미 사용 중일 수 있습니다)'
|
||||
: '방 생성에 실패했습니다. (이름이 이미 사용 중일 수 있습니다)'
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal
|
||||
:title="isEdit ? '회의 편집' : '새 회의 만들기'"
|
||||
@close="emit('cancel')"
|
||||
>
|
||||
<template #subtitle>
|
||||
{{ isEdit ? '내가 만든 방의 이름·예약·참여 제한을 수정합니다.' : '방을 만들면 내가 진행자가 됩니다.' }}
|
||||
</template>
|
||||
|
||||
<form
|
||||
class="cf-form"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<div class="form-field">
|
||||
<label class="form-label"><span class="req">*</span> 방 이름</label>
|
||||
<input
|
||||
v-model="name"
|
||||
class="form-input"
|
||||
autofocus
|
||||
placeholder="방 이름을 입력하세요"
|
||||
maxlength="64"
|
||||
@input="error = ''"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label class="form-label">예약 시각 <span class="opt">선택</span></label>
|
||||
<input
|
||||
v-model="scheduledAt"
|
||||
class="form-input"
|
||||
type="datetime-local"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label class="cf-toggle">
|
||||
<input
|
||||
v-model="restrict"
|
||||
type="checkbox"
|
||||
>
|
||||
<span class="cf-tg-box"><i v-if="restrict" /></span>
|
||||
<span class="cf-tg-txt"><b>참여 제한</b> — 지정한 멤버만 입장할 수 있습니다</span>
|
||||
</label>
|
||||
|
||||
<div
|
||||
v-if="restrict"
|
||||
class="cf-picker"
|
||||
>
|
||||
<!-- 입력칸은 고정 — 선택된 멤버는 아래에 표기 -->
|
||||
<div class="combo">
|
||||
<div class="combo-field">
|
||||
<input
|
||||
v-model="query"
|
||||
class="combo-input"
|
||||
placeholder="멤버 검색…"
|
||||
autocomplete="off"
|
||||
@focus="onMemberFocus"
|
||||
@blur="onMemberBlur"
|
||||
>
|
||||
<span class="combo-caret">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="m6 9 6 6 6-6" /></svg>
|
||||
</span>
|
||||
</div>
|
||||
<!-- 멤버 후보 드롭다운 -->
|
||||
<div
|
||||
v-if="showDropdown"
|
||||
class="assignee-dd"
|
||||
>
|
||||
<button
|
||||
v-for="m in candidates"
|
||||
:key="m.id"
|
||||
type="button"
|
||||
class="assignee-dd-item"
|
||||
@mousedown.prevent="addMember(m)"
|
||||
>
|
||||
<span class="avatar"><UserAvatar
|
||||
:url="m.avatarUrl"
|
||||
:name="m.name"
|
||||
/></span>
|
||||
<span class="ad-nm">{{ m.name }}</span>
|
||||
<span class="ad-em">{{ m.email }}</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="candidates.length === 0"
|
||||
class="assignee-dd-msg"
|
||||
>
|
||||
추가할 멤버가 없습니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 선택된 멤버 칩 (입력칸 아래에 추가) -->
|
||||
<div
|
||||
v-if="selected.length"
|
||||
class="assignee-chips"
|
||||
>
|
||||
<span
|
||||
v-for="a in selected"
|
||||
:key="a.id"
|
||||
class="chip"
|
||||
>
|
||||
<span class="avatar"><UserAvatar
|
||||
:url="a.avatarUrl"
|
||||
:name="a.name"
|
||||
/></span>
|
||||
{{ a.name }}
|
||||
<span
|
||||
class="x"
|
||||
@click="removeMember(a.id)"
|
||||
>×</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="error"
|
||||
class="cf-err"
|
||||
>
|
||||
<MeetingIcon name="warn" />{{ error }}
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<template #foot>
|
||||
<button
|
||||
type="button"
|
||||
class="mbtn"
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="mbtn primary"
|
||||
:disabled="submitting"
|
||||
@click="submit"
|
||||
>
|
||||
<MeetingIcon :name="isEdit ? 'edit' : 'plus'" />{{
|
||||
submitting
|
||||
? isEdit
|
||||
? '저장 중…'
|
||||
: '생성 중…'
|
||||
: isEdit
|
||||
? '저장'
|
||||
: '방 만들기'
|
||||
}}
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 폼은 BaseModal 본문(.bm-body)의 세로 간격을 그대로 상속받도록 display:contents */
|
||||
.cf-form {
|
||||
display: contents;
|
||||
}
|
||||
.form-label .opt {
|
||||
font-weight: 500;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* 참여 제한 토글 */
|
||||
.cf-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.cf-toggle input {
|
||||
display: none;
|
||||
}
|
||||
.cf-tg-box {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
border: 0.09375rem solid var(--border-strong);
|
||||
border-radius: 0.3125rem;
|
||||
background: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cf-toggle input:checked + .cf-tg-box {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.cf-tg-box i {
|
||||
width: 0.5625rem;
|
||||
height: 0.5625rem;
|
||||
border-radius: 0.125rem;
|
||||
background: #fff;
|
||||
}
|
||||
.cf-tg-txt {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.cf-tg-txt b {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 멤버 피커 (업무 담당자 할당과 동일) */
|
||||
.cf-picker {
|
||||
margin-top: 0.625rem;
|
||||
}
|
||||
.combo-field {
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.4375rem 0.5rem;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.combo-field:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-weak);
|
||||
}
|
||||
.combo-input {
|
||||
flex: 1;
|
||||
min-width: 4.375rem;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
font-size: 0.8125rem;
|
||||
padding: 0.25rem 0.125rem;
|
||||
background: transparent;
|
||||
}
|
||||
.combo-input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.combo-caret {
|
||||
color: var(--text-3);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.combo-caret svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
/* 모달 안에서는 절대배치 시 스크롤 영역에 잘리므로 인라인(흐름 내)으로 펼친다 */
|
||||
.assignee-dd {
|
||||
margin-top: 0.375rem;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5625rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 0.375rem;
|
||||
max-height: 14rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.assignee-dd-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.4375rem 0.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
.assignee-dd-item:hover {
|
||||
background: #f5f6f8;
|
||||
}
|
||||
.assignee-dd-item .avatar {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ad-nm {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ad-em {
|
||||
font-size: 0.719rem;
|
||||
color: var(--text-3);
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 9rem;
|
||||
}
|
||||
.assignee-dd-msg {
|
||||
padding: 0.625rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-3);
|
||||
text-align: center;
|
||||
}
|
||||
.assignee-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
background: #f1f2f4;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 1.25rem;
|
||||
padding: 0.1875rem 0.5rem 0.1875rem 0.1875rem;
|
||||
font-size: 0.78125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chip .avatar {
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.chip .x {
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1;
|
||||
margin-left: 0.0625rem;
|
||||
}
|
||||
.chip .x:hover {
|
||||
color: var(--red);
|
||||
}
|
||||
.cf-err {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4375rem;
|
||||
font-size: 0.78125rem;
|
||||
color: var(--red);
|
||||
font-weight: 500;
|
||||
}
|
||||
.cf-err svg {
|
||||
width: 0.9375rem;
|
||||
height: 0.9375rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,189 @@
|
||||
<script lang="ts">
|
||||
// 사용 가능한 화상회의 아이콘 이름(별도 <script> 블록에서 export → 다른 SFC 에서 import 가능)
|
||||
export type MeetingIconName =
|
||||
| 'cam'
|
||||
| 'camOff'
|
||||
| 'mic'
|
||||
| 'micOff'
|
||||
| 'screen'
|
||||
| 'chat'
|
||||
| 'send'
|
||||
| 'users'
|
||||
| 'hangup'
|
||||
| 'expand'
|
||||
| 'plus'
|
||||
| 'host'
|
||||
| 'grid'
|
||||
| 'speaker'
|
||||
| 'left'
|
||||
| 'right'
|
||||
| 'close'
|
||||
| 'search'
|
||||
| 'pin'
|
||||
| 'warn'
|
||||
| 'lock'
|
||||
| 'trash'
|
||||
| 'edit'
|
||||
| 'link'
|
||||
| 'check'
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 화상회의 전용 라인 아이콘 모음 — name 으로 SVG 를 선택해 렌더한다.
|
||||
// (svg 의 stroke/fill 은 currentColor 를 사용하므로 색상은 부모 color 로 제어)
|
||||
defineProps<{ name: MeetingIconName }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<template v-if="name === 'cam'">
|
||||
<path d="m23 7-7 5 7 5V7Z" /><rect
|
||||
x="1"
|
||||
y="5"
|
||||
width="15"
|
||||
height="14"
|
||||
rx="2"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="name === 'camOff'">
|
||||
<path d="M16 16v2a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2m4 0h5a2 2 0 0 1 2 2v3l4-3v9" /><path d="m1 1 22 22" />
|
||||
</template>
|
||||
<template v-else-if="name === 'mic'">
|
||||
<rect
|
||||
x="9"
|
||||
y="2"
|
||||
width="6"
|
||||
height="12"
|
||||
rx="3"
|
||||
/><path d="M5 10a7 7 0 0 0 14 0M12 19v3" />
|
||||
</template>
|
||||
<template v-else-if="name === 'micOff'">
|
||||
<path d="M9 9v1a3 3 0 0 0 5.12 2.12M15 9.34V5a3 3 0 0 0-5.94-.6" /><path d="M17 10a7 7 0 0 1-.11 1.23M5 10a7 7 0 0 0 10.91 5.81M12 19v3M8 22h8" /><path d="m1 1 22 22" />
|
||||
</template>
|
||||
<template v-else-if="name === 'screen'">
|
||||
<rect
|
||||
x="2"
|
||||
y="3"
|
||||
width="20"
|
||||
height="14"
|
||||
rx="2"
|
||||
/><path d="M8 21h8M12 17v4" /><path d="m9 9.5 3-3 3 3M12 6.5v6" />
|
||||
</template>
|
||||
<template v-else-if="name === 'chat'">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z" />
|
||||
</template>
|
||||
<template v-else-if="name === 'send'">
|
||||
<path d="M22 2 11 13M22 2l-7 20-4-9-9-4 20-7Z" />
|
||||
</template>
|
||||
<template v-else-if="name === 'users'">
|
||||
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle
|
||||
cx="9"
|
||||
cy="7"
|
||||
r="4"
|
||||
/><path d="M22 21v-2a4 4 0 0 0-3-3.87M16 3.13A4 4 0 0 1 16 11" />
|
||||
</template>
|
||||
<template v-else-if="name === 'hangup'">
|
||||
<path
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
d="M12 9c-1.6 0-3.15.25-4.6.7v3.1c0 .39-.23.74-.56.9-.98.49-1.87 1.12-2.66 1.85a.99.99 0 0 1-1.36-.02L.29 13.36a.99.99 0 0 1 0-1.41C3.34 9.07 7.46 7.3 12 7.3s8.66 1.77 11.71 4.65a.99.99 0 0 1 0 1.41l-2.57 3.07a.99.99 0 0 1-1.36.02 11.5 11.5 0 0 0-2.66-1.85.998.998 0 0 1-.56-.9v-3.1C15.15 9.25 13.6 9 12 9Z"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="name === 'expand'">
|
||||
<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7" />
|
||||
</template>
|
||||
<template v-else-if="name === 'plus'">
|
||||
<path d="M5 12h14M12 5v14" />
|
||||
</template>
|
||||
<template v-else-if="name === 'host'">
|
||||
<path d="M12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2z" />
|
||||
</template>
|
||||
<template v-else-if="name === 'grid'">
|
||||
<rect
|
||||
x="3"
|
||||
y="3"
|
||||
width="7"
|
||||
height="7"
|
||||
rx="1"
|
||||
/><rect
|
||||
x="14"
|
||||
y="3"
|
||||
width="7"
|
||||
height="7"
|
||||
rx="1"
|
||||
/><rect
|
||||
x="3"
|
||||
y="14"
|
||||
width="7"
|
||||
height="7"
|
||||
rx="1"
|
||||
/><rect
|
||||
x="14"
|
||||
y="14"
|
||||
width="7"
|
||||
height="7"
|
||||
rx="1"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="name === 'speaker'">
|
||||
<rect
|
||||
x="3"
|
||||
y="3"
|
||||
width="18"
|
||||
height="13"
|
||||
rx="2"
|
||||
/><path d="M3 19h5M11 19h2M16 19h5" />
|
||||
</template>
|
||||
<template v-else-if="name === 'left'">
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</template>
|
||||
<template v-else-if="name === 'right'">
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</template>
|
||||
<template v-else-if="name === 'close'">
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</template>
|
||||
<template v-else-if="name === 'search'">
|
||||
<circle
|
||||
cx="11"
|
||||
cy="11"
|
||||
r="7"
|
||||
/><path d="m21 21-4-4" />
|
||||
</template>
|
||||
<template v-else-if="name === 'pin'">
|
||||
<path d="M9 4h6l-1 7 4 3v2H6v-2l4-3z" /><path d="M12 16v5" />
|
||||
</template>
|
||||
<template v-else-if="name === 'warn'">
|
||||
<path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z" /><path d="M12 9v4M12 17h.01" />
|
||||
</template>
|
||||
<template v-else-if="name === 'lock'">
|
||||
<rect
|
||||
x="3"
|
||||
y="11"
|
||||
width="18"
|
||||
height="11"
|
||||
rx="2"
|
||||
/><path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</template>
|
||||
<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>
|
||||
@@ -0,0 +1,429 @@
|
||||
<script setup lang="ts">
|
||||
// 화상회의 로비 — 검색 + 새 회의 만들기 + 방 목록.
|
||||
import { computed, ref } from 'vue'
|
||||
import MeetingIcon from './MeetingIcon.vue'
|
||||
import MeetingCreateForm from './MeetingCreateForm.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { relativeTimeKo, formatDate } from '@/shared/utils/format'
|
||||
import type { ApiMeetingRoom } from '@/types/meeting'
|
||||
|
||||
interface Props {
|
||||
rooms: ApiMeetingRoom[]
|
||||
joinError: string | null
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(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('')
|
||||
|
||||
// 방 이름·진행자 이름으로 목록 검색(클라이언트 필터)
|
||||
const filteredRooms = computed(() => {
|
||||
const q = keyword.value.trim().toLowerCase()
|
||||
if (!q) return props.rooms
|
||||
return props.rooms.filter(
|
||||
(r) =>
|
||||
r.name.toLowerCase().includes(q) ||
|
||||
(r.host?.name ?? '').toLowerCase().includes(q),
|
||||
)
|
||||
})
|
||||
|
||||
function onSearchInput() {
|
||||
if (props.joinError) emit('clear-error')
|
||||
}
|
||||
// 새 회의 만들기(생성 모드)
|
||||
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')
|
||||
}
|
||||
|
||||
// 예약 시각 표시(날짜+시간)
|
||||
function fmtSchedule(iso: string): string {
|
||||
return new Date(iso).toLocaleString('ko-KR', {
|
||||
dateStyle: 'short',
|
||||
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>
|
||||
<div class="page">
|
||||
<div class="crumb">
|
||||
<b>화상회의</b>
|
||||
</div>
|
||||
<div class="pagehead">
|
||||
<h1>화상회의</h1>
|
||||
<span class="count-pill">{{ rooms.length }}</span>
|
||||
</div>
|
||||
<p class="lede">
|
||||
회의 방을 만들거나 목록에서 선택해 입장하세요.
|
||||
</p>
|
||||
|
||||
<!-- 툴바: 검색 + 새 회의 만들기 -->
|
||||
<div class="toolbar">
|
||||
<div class="search">
|
||||
<MeetingIcon name="search" />
|
||||
<input
|
||||
v-model="keyword"
|
||||
type="text"
|
||||
placeholder="회의방 검색…"
|
||||
@input="onSearchInput"
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn primary new-room"
|
||||
@click="openCreate"
|
||||
>
|
||||
<MeetingIcon name="plus" />새 회의 만들기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="joinError"
|
||||
class="join-err"
|
||||
>
|
||||
<MeetingIcon name="warn" /><span>{{ joinError }}</span>
|
||||
</div>
|
||||
|
||||
<MeetingCreateForm
|
||||
v-if="showForm || editRoom"
|
||||
:edit-room="editRoom"
|
||||
@created="onFormDone"
|
||||
@cancel="closeForm"
|
||||
/>
|
||||
|
||||
<!-- 방 목록 -->
|
||||
<div class="roomlist">
|
||||
<div
|
||||
v-for="r in filteredRooms"
|
||||
:key="r.id"
|
||||
class="room-row"
|
||||
>
|
||||
<div class="rr-ico">
|
||||
<MeetingIcon name="cam" />
|
||||
</div>
|
||||
<div class="rr-main">
|
||||
<div class="rr-title">
|
||||
<span class="rr-name">{{ r.name }}</span>
|
||||
<span
|
||||
v-if="r.restricted"
|
||||
class="rr-badge lock"
|
||||
><MeetingIcon name="lock" />제한</span>
|
||||
</div>
|
||||
<div class="rr-meta">
|
||||
<span
|
||||
v-if="r.host"
|
||||
class="rr-mi rr-host"
|
||||
><span class="rr-av"><UserAvatar
|
||||
:url="r.host.avatarUrl"
|
||||
:name="r.host.name"
|
||||
/></span>{{ r.host.name }}</span>
|
||||
<span
|
||||
v-if="r.scheduledAt"
|
||||
class="rr-mi"
|
||||
>예약 {{ fmtSchedule(r.scheduledAt) }}</span>
|
||||
<span
|
||||
class="rr-mi rr-dim"
|
||||
:title="formatDate(r.createdAt)"
|
||||
>{{ relativeTimeKo(r.createdAt) }} 생성</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rr-actions">
|
||||
<button
|
||||
v-if="r.canJoin"
|
||||
type="button"
|
||||
class="btn primary rr-join"
|
||||
@click="emit('join', r)"
|
||||
>
|
||||
입장
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="btn rr-noaccess"
|
||||
disabled
|
||||
title="참여 허용 멤버만 입장할 수 있습니다"
|
||||
>
|
||||
<MeetingIcon name="lock" />권한 없음
|
||||
</button>
|
||||
<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.isHost"
|
||||
type="button"
|
||||
class="btn rr-edit"
|
||||
title="방 편집"
|
||||
@click="openEdit(r)"
|
||||
>
|
||||
<MeetingIcon name="edit" />
|
||||
</button>
|
||||
<button
|
||||
v-if="r.isHost"
|
||||
type="button"
|
||||
class="btn danger rr-del"
|
||||
title="방 삭제"
|
||||
@click="emit('delete', r)"
|
||||
>
|
||||
<MeetingIcon name="trash" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState v-if="rooms.length === 0">
|
||||
아직 생성된 회의방이 없습니다. 새 회의를 만들어 보세요.
|
||||
</EmptyState>
|
||||
<EmptyState v-else-if="filteredRooms.length === 0">
|
||||
검색 결과가 없습니다.
|
||||
</EmptyState>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 콘텐츠 폭/여백은 전역 .page(max-width 67.5rem) 사용 — 프로젝트·내 업무 화면과 동일 */
|
||||
/* .crumb / .count-pill 은 전역(relay.css) 스타일 사용 */
|
||||
.crumb b {
|
||||
color: var(--text-2);
|
||||
font-weight: 600;
|
||||
}
|
||||
.pagehead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6875rem;
|
||||
padding: 0.5625rem 0 0.25rem;
|
||||
}
|
||||
.pagehead h1 {
|
||||
font-size: 1.375rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025rem;
|
||||
}
|
||||
.lede {
|
||||
color: var(--text-2);
|
||||
font-size: 0.844rem;
|
||||
margin: 0.25rem 0 1.125rem;
|
||||
}
|
||||
|
||||
/* 툴바: 검색(.toolbar/.search 는 전역) + 우측 새 회의 버튼 */
|
||||
.new-room {
|
||||
margin-left: auto;
|
||||
}
|
||||
.join-err {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.6875rem 0.875rem;
|
||||
background: var(--red-weak);
|
||||
border: 1px solid var(--red-border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--red);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.join-err svg {
|
||||
width: 1.0625rem;
|
||||
height: 1.0625rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 방 목록 */
|
||||
.roomlist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5625rem;
|
||||
}
|
||||
.room-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.625rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.room-row:hover {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.rr-ico {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 0.5625rem;
|
||||
background: var(--accent-weak);
|
||||
color: var(--accent);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rr-ico svg {
|
||||
width: 1.3125rem;
|
||||
height: 1.3125rem;
|
||||
}
|
||||
.rr-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.rr-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.rr-name {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
letter-spacing: -0.0125rem;
|
||||
}
|
||||
.rr-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 1.25rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.rr-badge svg {
|
||||
width: 0.6875rem;
|
||||
height: 0.6875rem;
|
||||
}
|
||||
.rr-badge.lock {
|
||||
color: var(--text-2);
|
||||
background: #f1f2f4;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.rr-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.9375rem;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.rr-mi {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3125rem;
|
||||
}
|
||||
.rr-mi svg {
|
||||
width: 0.8125rem;
|
||||
height: 0.8125rem;
|
||||
}
|
||||
/* 소유자(진행자) — 아바타 + 이름 (프로젝트·업무 화면과 동일 표기) */
|
||||
.rr-host {
|
||||
gap: 0.375rem;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.rr-av {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rr-dim {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.rr-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rr-join {
|
||||
padding: 0 1.25rem;
|
||||
}
|
||||
.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 {
|
||||
background: #fff;
|
||||
border-color: var(--red-border);
|
||||
color: var(--red);
|
||||
}
|
||||
.btn.danger:hover {
|
||||
background: var(--red-weak);
|
||||
border-color: var(--red);
|
||||
color: var(--red);
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn:disabled:hover {
|
||||
background: var(--panel);
|
||||
color: var(--text-2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,291 @@
|
||||
<script setup lang="ts">
|
||||
// 참여자 목록 — 진행자/참여자 그룹 구분, 이름 검색, 마이크·품질 표시, 진행자의 승격 버튼.
|
||||
import { computed, ref } from 'vue'
|
||||
import { ConnectionQuality, type Participant } from 'livekit-client'
|
||||
import MeetingIcon from './MeetingIcon.vue'
|
||||
import QualityBars from './QualityBars.vue'
|
||||
import { avatarColor, avatarInitial } from '@/shared/utils/avatar'
|
||||
import { normalizeQuality } from '@/composables/useLiveKitRoom'
|
||||
|
||||
interface Props {
|
||||
participants: Participant[]
|
||||
featuredId: string | null
|
||||
localId: string | null
|
||||
isHost: boolean
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{ (e: 'promote', identity: string): void }>()
|
||||
|
||||
const query = ref('')
|
||||
|
||||
// metadata 로 진행자 판정
|
||||
function isHostMeta(p: Participant): boolean {
|
||||
if (!p.metadata) return false
|
||||
try {
|
||||
return (JSON.parse(p.metadata) as { role?: string }).role === 'host'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = query.value.trim()
|
||||
return props.participants.filter((p) =>
|
||||
(p.name || p.identity).includes(q),
|
||||
)
|
||||
})
|
||||
const hosts = computed(() => filtered.value.filter(isHostMeta))
|
||||
const guests = computed(() => filtered.value.filter((p) => !isHostMeta(p)))
|
||||
|
||||
function quality(p: Participant) {
|
||||
return normalizeQuality(p.connectionQuality as ConnectionQuality)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="people">
|
||||
<div class="pp-search">
|
||||
<MeetingIcon name="search" />
|
||||
<input
|
||||
v-model="query"
|
||||
type="text"
|
||||
placeholder="이름 검색…"
|
||||
aria-label="참여자 검색"
|
||||
>
|
||||
</div>
|
||||
<div class="pp-list">
|
||||
<template v-if="hosts.length">
|
||||
<div class="pp-group">
|
||||
진행자
|
||||
</div>
|
||||
<div
|
||||
v-for="p in hosts"
|
||||
:key="p.identity"
|
||||
class="pp-row"
|
||||
:class="{ featured: p.identity === featuredId }"
|
||||
>
|
||||
<span
|
||||
class="pp-av"
|
||||
:style="{ background: avatarColor(p.name || p.identity) }"
|
||||
>{{ avatarInitial(p.name || p.identity) }}</span>
|
||||
<span class="pp-name">{{ p.name || p.identity }}{{ p.identity === localId ? ' (나)' : '' }}</span>
|
||||
<span
|
||||
v-if="p.identity === featuredId"
|
||||
class="pp-pin"
|
||||
title="스테이지"
|
||||
><MeetingIcon name="pin" /></span>
|
||||
<span class="pp-grow" />
|
||||
<QualityBars
|
||||
:quality="quality(p)"
|
||||
variant="bare"
|
||||
/>
|
||||
<span
|
||||
class="pp-mic"
|
||||
:class="{ off: !p.isMicrophoneEnabled }"
|
||||
><MeetingIcon :name="p.isMicrophoneEnabled ? 'mic' : 'micOff'" /></span>
|
||||
<button
|
||||
v-if="isHost && p.identity !== featuredId"
|
||||
type="button"
|
||||
class="pp-promote"
|
||||
title="스테이지로 크게 보기"
|
||||
@click="emit('promote', p.identity)"
|
||||
>
|
||||
<MeetingIcon name="expand" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="guests.length">
|
||||
<div class="pp-group">
|
||||
참여자 {{ guests.length }}
|
||||
</div>
|
||||
<div
|
||||
v-for="p in guests"
|
||||
:key="p.identity"
|
||||
class="pp-row"
|
||||
:class="{ featured: p.identity === featuredId }"
|
||||
>
|
||||
<span
|
||||
class="pp-av"
|
||||
:style="{ background: avatarColor(p.name || p.identity) }"
|
||||
>{{ avatarInitial(p.name || p.identity) }}</span>
|
||||
<span class="pp-name">{{ p.name || p.identity }}{{ p.identity === localId ? ' (나)' : '' }}</span>
|
||||
<span
|
||||
v-if="p.identity === featuredId"
|
||||
class="pp-pin"
|
||||
title="스테이지"
|
||||
><MeetingIcon name="pin" /></span>
|
||||
<span class="pp-grow" />
|
||||
<QualityBars
|
||||
:quality="quality(p)"
|
||||
variant="bare"
|
||||
/>
|
||||
<span
|
||||
class="pp-mic"
|
||||
:class="{ off: !p.isMicrophoneEnabled }"
|
||||
><MeetingIcon :name="p.isMicrophoneEnabled ? 'mic' : 'micOff'" /></span>
|
||||
<button
|
||||
v-if="isHost && p.identity !== featuredId"
|
||||
type="button"
|
||||
class="pp-promote"
|
||||
title="스테이지로 크게 보기"
|
||||
@click="emit('promote', p.identity)"
|
||||
>
|
||||
<MeetingIcon name="expand" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="filtered.length === 0"
|
||||
class="pp-empty"
|
||||
>
|
||||
검색 결과가 없습니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.people {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.pp-search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0.75rem 1rem;
|
||||
height: 2.25rem;
|
||||
padding: 0 0.6875rem;
|
||||
background: var(--d-panel-2);
|
||||
border: 1px solid var(--d-border);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.pp-search svg {
|
||||
width: 0.9375rem;
|
||||
height: 0.9375rem;
|
||||
color: var(--d-text-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pp-search input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--d-text);
|
||||
}
|
||||
.pp-search input::placeholder {
|
||||
color: var(--d-text-3);
|
||||
}
|
||||
.pp-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0 0.5rem 0.75rem;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--d-border) transparent;
|
||||
}
|
||||
.pp-list::-webkit-scrollbar {
|
||||
width: 0.4375rem;
|
||||
}
|
||||
.pp-list::-webkit-scrollbar-thumb {
|
||||
background: var(--d-border);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.pp-group {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--d-text-3);
|
||||
padding: 0.75rem 0.5rem 0.3125rem;
|
||||
}
|
||||
.pp-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5625rem;
|
||||
padding: 0.4375rem 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
.pp-row:hover {
|
||||
background: var(--d-panel-2);
|
||||
}
|
||||
.pp-row.featured {
|
||||
background: rgba(79, 70, 229, 0.13);
|
||||
}
|
||||
.pp-av {
|
||||
width: 1.875rem;
|
||||
height: 1.875rem;
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.pp-name {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--d-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.pp-pin {
|
||||
color: #9b91ff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.pp-pin svg {
|
||||
width: 0.8125rem;
|
||||
height: 0.8125rem;
|
||||
}
|
||||
.pp-grow {
|
||||
flex: 1;
|
||||
}
|
||||
.pp-mic {
|
||||
color: var(--d-text-2);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.pp-mic svg {
|
||||
width: 0.9375rem;
|
||||
height: 0.9375rem;
|
||||
}
|
||||
.pp-mic.off {
|
||||
color: var(--d-red);
|
||||
}
|
||||
.pp-promote {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border: 1px solid var(--d-border);
|
||||
background: transparent;
|
||||
color: var(--d-text-2);
|
||||
border-radius: 0.4375rem;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.pp-promote svg {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
}
|
||||
.pp-promote:hover {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.pp-empty {
|
||||
padding: 1.875rem;
|
||||
text-align: center;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--d-text-3);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,388 @@
|
||||
<script setup lang="ts">
|
||||
// 입장 전 프리조인 — 실제 로컬 카메라/마이크를 미리보기하고 초기 on/off 상태를 정한 뒤 입장한다.
|
||||
// 권한이 거부되면 미리보기 없이 '권한 거부 상태로 입장(참관)'을 안내한다.
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import MeetingIcon from './MeetingIcon.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
|
||||
interface PrejoinRoom {
|
||||
name: string
|
||||
hostName: string | null
|
||||
restricted: boolean
|
||||
isHost: boolean
|
||||
}
|
||||
interface Props {
|
||||
room: PrejoinRoom
|
||||
myName: string
|
||||
myAvatarUrl: string | null
|
||||
}
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'enter', opts: { mic: boolean; cam: boolean }): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
const videoEl = ref<HTMLVideoElement | null>(null)
|
||||
const micOn = ref(true)
|
||||
const camOn = ref(true)
|
||||
const denied = ref(false) // 미디어 권한 획득 실패 여부
|
||||
let stream: MediaStream | null = null
|
||||
|
||||
// 트랙 enabled 동기화
|
||||
function applyTrackState() {
|
||||
if (!stream) return
|
||||
stream.getAudioTracks().forEach((t) => (t.enabled = micOn.value))
|
||||
stream.getVideoTracks().forEach((t) => (t.enabled = camOn.value))
|
||||
}
|
||||
watch([micOn, camOn], applyTrackState)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: true,
|
||||
audio: true,
|
||||
})
|
||||
if (videoEl.value) videoEl.value.srcObject = stream
|
||||
applyTrackState()
|
||||
} catch {
|
||||
// 권한 거부/장치 없음 — 미리보기 불가, 토글은 off 로
|
||||
denied.value = true
|
||||
micOn.value = false
|
||||
camOn.value = false
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(stopPreview)
|
||||
|
||||
// 미리보기 트랙 정리(입장 시 LiveKit 이 다시 장치를 획득하므로 여기서 해제)
|
||||
function stopPreview() {
|
||||
if (stream) {
|
||||
stream.getTracks().forEach((t) => t.stop())
|
||||
stream = null
|
||||
}
|
||||
if (videoEl.value) videoEl.value.srcObject = null
|
||||
}
|
||||
|
||||
function enter() {
|
||||
stopPreview()
|
||||
emit('enter', { mic: micOn.value, cam: camOn.value })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="prejoin">
|
||||
<div class="pj-card">
|
||||
<div class="pj-preview">
|
||||
<video
|
||||
ref="videoEl"
|
||||
class="pj-video"
|
||||
:class="{ hidden: !camOn }"
|
||||
autoplay
|
||||
playsinline
|
||||
muted
|
||||
/>
|
||||
<div
|
||||
v-if="!camOn"
|
||||
class="pj-off"
|
||||
>
|
||||
<span class="av-lg"><UserAvatar
|
||||
:url="myAvatarUrl"
|
||||
:name="myName"
|
||||
/></span>
|
||||
</div>
|
||||
<div class="pj-selflbl">
|
||||
{{ myName }} (나)
|
||||
</div>
|
||||
<div class="pj-prectrl">
|
||||
<button
|
||||
type="button"
|
||||
class="pj-rb"
|
||||
:class="{ off: !micOn }"
|
||||
:disabled="denied"
|
||||
title="마이크"
|
||||
@click="micOn = !micOn"
|
||||
>
|
||||
<MeetingIcon :name="micOn ? 'mic' : 'micOff'" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pj-rb"
|
||||
:class="{ off: !camOn }"
|
||||
:disabled="denied"
|
||||
title="카메라"
|
||||
@click="camOn = !camOn"
|
||||
>
|
||||
<MeetingIcon :name="camOn ? 'cam' : 'camOff'" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pj-side">
|
||||
<div class="pj-room">
|
||||
<span class="pj-kicker">입장하려는 방</span>
|
||||
<div class="pj-roomname">
|
||||
{{ room.name }}
|
||||
<span
|
||||
v-if="room.restricted"
|
||||
class="rr-badge lock"
|
||||
><MeetingIcon name="lock" />제한</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="room.hostName"
|
||||
class="pj-roomhost"
|
||||
>
|
||||
<MeetingIcon name="host" /><span>{{ room.hostName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pj-checklist">
|
||||
<div
|
||||
class="pj-chk"
|
||||
:class="{ ok: micOn }"
|
||||
>
|
||||
<MeetingIcon :name="micOn ? 'mic' : 'micOff'" /><span>마이크 {{ micOn ? '켜짐' : '꺼짐' }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="pj-chk"
|
||||
:class="{ ok: camOn }"
|
||||
>
|
||||
<MeetingIcon :name="camOn ? 'cam' : 'camOff'" /><span>카메라 {{ camOn ? '켜짐' : '꺼짐' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn primary pj-enter"
|
||||
@click="enter"
|
||||
>
|
||||
지금 입장
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="pj-cancel"
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
← 로비로 돌아가기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.prejoin {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 1.875rem;
|
||||
}
|
||||
.pj-card {
|
||||
display: flex;
|
||||
gap: 1.375rem;
|
||||
width: min(56.25rem, 100%);
|
||||
background: var(--d-panel);
|
||||
border: 1px solid var(--d-border);
|
||||
border-radius: 1.125rem;
|
||||
padding: 1.375rem;
|
||||
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.pj-preview {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
aspect-ratio: 16/9;
|
||||
border-radius: 0.8125rem;
|
||||
overflow: hidden;
|
||||
background: var(--tile);
|
||||
}
|
||||
.pj-video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
.pj-video.hidden {
|
||||
display: none;
|
||||
}
|
||||
.pj-off {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--tile);
|
||||
}
|
||||
.av-lg {
|
||||
width: clamp(3.5rem, 12vh, 6rem);
|
||||
height: clamp(3.5rem, 12vh, 6rem);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pj-selflbl {
|
||||
position: absolute;
|
||||
left: 0.875rem;
|
||||
bottom: 0.875rem;
|
||||
z-index: 3;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.42);
|
||||
padding: 0.3125rem 0.6875rem;
|
||||
border-radius: 0.4375rem;
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.pj-prectrl {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0.875rem;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.pj-rb {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.pj-rb svg {
|
||||
width: 1.3125rem;
|
||||
height: 1.3125rem;
|
||||
}
|
||||
.pj-rb:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.28);
|
||||
}
|
||||
.pj-rb.off {
|
||||
background: var(--red);
|
||||
}
|
||||
.pj-rb:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.pj-side {
|
||||
width: 17.875rem;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem;
|
||||
}
|
||||
.pj-room {
|
||||
padding-bottom: 0.875rem;
|
||||
border-bottom: 1px solid var(--d-border);
|
||||
}
|
||||
.pj-kicker {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--d-text-3);
|
||||
}
|
||||
.pj-roomname {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.375rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--d-text);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
.rr-badge.lock {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 1.25rem;
|
||||
line-height: 1.5;
|
||||
color: var(--d-text-2);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid var(--d-border);
|
||||
}
|
||||
.rr-badge.lock svg {
|
||||
width: 0.6875rem;
|
||||
height: 0.6875rem;
|
||||
}
|
||||
.pj-roomhost {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--d-text-2);
|
||||
}
|
||||
.pj-roomhost span {
|
||||
line-height: 1;
|
||||
}
|
||||
.pj-roomhost svg {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pj-checklist {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.pj-chk {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5625rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--d-text-3);
|
||||
}
|
||||
.pj-chk svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
.pj-chk.ok {
|
||||
color: var(--d-text);
|
||||
}
|
||||
.pj-enter {
|
||||
height: 2.875rem;
|
||||
border-radius: 0.625rem;
|
||||
justify-content: center;
|
||||
font-size: 0.9375rem;
|
||||
margin-top: auto;
|
||||
}
|
||||
.pj-cancel {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--d-text-2);
|
||||
font-family: inherit;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
.pj-cancel:hover {
|
||||
color: var(--d-text);
|
||||
}
|
||||
|
||||
/* 공용 .btn primary 가 라이트 테마 기준이므로 입장 버튼 색을 명시 */
|
||||
.pj-enter.btn.primary {
|
||||
background: var(--accent);
|
||||
border: none;
|
||||
color: #fff;
|
||||
}
|
||||
.pj-enter.btn.primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
@media (max-width: 53.75rem) {
|
||||
.pj-card {
|
||||
flex-direction: column;
|
||||
}
|
||||
.pj-side {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,438 @@
|
||||
<script setup lang="ts">
|
||||
// 연결된 회의실 — 상단 바 + (스피커/그리드 뷰) 본문 + 오른쪽 패널(참여자/채팅) + 컨트롤 바.
|
||||
// 모든 라이브 상태(participants/featuredIdentity/mic/cam/messages)는 부모(MeetingPage)가 주입한다.
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import type { Participant } from 'livekit-client'
|
||||
import ParticipantTile from './ParticipantTile.vue'
|
||||
import MeetingIcon from './MeetingIcon.vue'
|
||||
import MeetingControlBar from './MeetingControlBar.vue'
|
||||
import MeetingSidePanel from './MeetingSidePanel.vue'
|
||||
import type { ChatMessage } from '@/types/meeting'
|
||||
|
||||
interface Props {
|
||||
roomName: string
|
||||
participants: Participant[]
|
||||
featuredIdentity: string | null
|
||||
localId: string | null
|
||||
micEnabled: boolean
|
||||
camEnabled: boolean
|
||||
isHost: boolean
|
||||
messages: ChatMessage[]
|
||||
// 말하는 사람 하이라이트 사용 여부(기본 on)
|
||||
highlight?: boolean
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), { highlight: true })
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggle-mic'): void
|
||||
(e: 'toggle-cam'): void
|
||||
(e: 'promote', identity: string): void
|
||||
(e: 'send', text: string): void
|
||||
(e: 'leave'): void
|
||||
}>()
|
||||
|
||||
// 한 페이지에 보일 참여자 수 — 스피커 뷰는 진행자(스테이지) 제외 나머지 최대 50명,
|
||||
// 그리드 뷰는 전원 기준 50명. 초과분만 페이지네이션으로 넘긴다.
|
||||
const PER_PAGE = 50
|
||||
|
||||
const page = ref(0)
|
||||
const panel = ref<'people' | 'chat' | null>(null)
|
||||
const unread = ref(0)
|
||||
|
||||
function isHostMeta(p: Participant): boolean {
|
||||
if (!p.metadata) return false
|
||||
try {
|
||||
return (JSON.parse(p.metadata) as { role?: string }).role === 'host'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// --- 레이아웃 검증용 가상 참여자(개발 모드 전용) ---
|
||||
// 사용법: /meeting/<방>?mock=100 → 미디어 없이 타일만 N명 추가(프로덕션 빌드에선 비활성).
|
||||
const route = useRoute()
|
||||
const MOCK_NAMES = [
|
||||
'김민준',
|
||||
'이서연',
|
||||
'박지후',
|
||||
'최예은',
|
||||
'정도윤',
|
||||
'강하늘',
|
||||
'윤시우',
|
||||
'임수아',
|
||||
'한지안',
|
||||
'오재현',
|
||||
]
|
||||
function makeMock(i: number): Participant {
|
||||
const name = `${MOCK_NAMES[i % MOCK_NAMES.length]}${i + 1}`
|
||||
return {
|
||||
sid: `mock-${i}`,
|
||||
identity: `mock-${i}`,
|
||||
name,
|
||||
isMicrophoneEnabled: i % 3 !== 0,
|
||||
isSpeaking: i % 7 === 0,
|
||||
connectionQuality: i % 5 === 0 ? 'poor' : i % 3 === 0 ? 'good' : 'excellent',
|
||||
metadata: undefined,
|
||||
getTrackPublication: () => undefined,
|
||||
on() {
|
||||
return this
|
||||
},
|
||||
off() {
|
||||
return this
|
||||
},
|
||||
} as unknown as Participant
|
||||
}
|
||||
const mockCount = computed(() => {
|
||||
if (!import.meta.env.DEV) return 0
|
||||
const n = Number(route.query.mock)
|
||||
return Number.isFinite(n) && n > 0 ? Math.min(Math.floor(n), 200) : 0
|
||||
})
|
||||
const mocks = computed<Participant[]>(() =>
|
||||
Array.from({ length: mockCount.value }, (_, i) => makeMock(i)),
|
||||
)
|
||||
// 실제 참여자 + 가상 참여자
|
||||
const allParticipants = computed<Participant[]>(() =>
|
||||
mockCount.value ? [...props.participants, ...mocks.value] : props.participants,
|
||||
)
|
||||
|
||||
// 스테이지에 올릴 참여자 — 승격됨 > 진행자 > 첫 참여자
|
||||
const featured = computed<Participant | null>(() => {
|
||||
const list = allParticipants.value
|
||||
if (!list.length) return null
|
||||
if (props.featuredIdentity) {
|
||||
const f = list.find((p) => p.identity === props.featuredIdentity)
|
||||
if (f) return f
|
||||
}
|
||||
return list.find(isHostMeta) ?? list[0] ?? null
|
||||
})
|
||||
|
||||
// 스테이지(진행자) 제외 나머지 — 썸네일 그리드에 표시
|
||||
const others = computed(() =>
|
||||
allParticipants.value.filter((p) => p !== featured.value),
|
||||
)
|
||||
const pageCount = computed(() =>
|
||||
Math.max(1, Math.ceil(others.value.length / PER_PAGE)),
|
||||
)
|
||||
const safePage = computed(() => Math.min(page.value, pageCount.value - 1))
|
||||
const pageSlice = computed(() => {
|
||||
const start = safePage.value * PER_PAGE
|
||||
return others.value.slice(start, start + PER_PAGE)
|
||||
})
|
||||
|
||||
function togglePanel(tab: 'people' | 'chat') {
|
||||
panel.value = panel.value === tab ? null : tab
|
||||
if (panel.value === 'chat') unread.value = 0
|
||||
}
|
||||
function setTab(tab: 'people' | 'chat') {
|
||||
panel.value = tab
|
||||
if (tab === 'chat') unread.value = 0
|
||||
}
|
||||
|
||||
// 채팅이 닫혀 있을 때 도착한 상대 메시지를 미읽음으로 집계
|
||||
watch(
|
||||
() => props.messages.length,
|
||||
(len, prev) => {
|
||||
const added = props.messages.slice(prev ?? 0)
|
||||
if (panel.value !== 'chat') {
|
||||
unread.value += added.filter((m) => !m.self).length
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="room"
|
||||
:class="{ 'panel-open': panel }"
|
||||
>
|
||||
<!-- 상단 바 -->
|
||||
<div class="room-bar">
|
||||
<div class="rb-left">
|
||||
<span class="rb-live"><i />LIVE</span>
|
||||
<span class="rb-name">{{ roomName }}</span>
|
||||
</div>
|
||||
<div class="rb-right">
|
||||
<span class="rb-count"><MeetingIcon name="users" />{{ allParticipants.length }}명</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 본문 -->
|
||||
<div class="room-body">
|
||||
<div class="room-main">
|
||||
<!-- 축소된 스테이지(위) + 썸네일 그리드(아래) -->
|
||||
<div class="speaker-wrap">
|
||||
<div class="stage">
|
||||
<ParticipantTile
|
||||
v-if="featured"
|
||||
:key="featured.identity"
|
||||
:participant="featured"
|
||||
featured
|
||||
:is-local="featured.identity === localId"
|
||||
:highlight="highlight"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="stage-empty"
|
||||
>
|
||||
참여자를 기다리는 중…
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="others.length"
|
||||
class="thumbs"
|
||||
>
|
||||
<div class="thumb-scroll">
|
||||
<div class="thumb-grid">
|
||||
<ParticipantTile
|
||||
v-for="p in pageSlice"
|
||||
:key="p.sid || p.identity"
|
||||
:participant="p"
|
||||
:is-local="p.identity === localId"
|
||||
:can-promote="isHost"
|
||||
:highlight="highlight"
|
||||
@promote="(id) => emit('promote', id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="pageCount > 1"
|
||||
class="pager"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn"
|
||||
:disabled="safePage === 0"
|
||||
@click="page = safePage - 1"
|
||||
>
|
||||
<MeetingIcon name="left" />
|
||||
</button>
|
||||
<span class="pg-info">썸네일 · {{ safePage + 1 }}/{{ pageCount }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="pg-btn"
|
||||
:disabled="safePage >= pageCount - 1"
|
||||
@click="page = safePage + 1"
|
||||
>
|
||||
<MeetingIcon name="right" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 오른쪽 패널 -->
|
||||
<MeetingSidePanel
|
||||
v-if="panel"
|
||||
:tab="panel"
|
||||
:participants="allParticipants"
|
||||
:featured-id="featured?.identity ?? null"
|
||||
:local-id="localId"
|
||||
:is-host="isHost"
|
||||
:messages="messages"
|
||||
@tab="setTab"
|
||||
@close="panel = null"
|
||||
@promote="(id) => emit('promote', id)"
|
||||
@send="(t) => emit('send', t)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 컨트롤 바 -->
|
||||
<MeetingControlBar
|
||||
pos="bottom"
|
||||
:mic-enabled="micEnabled"
|
||||
:cam-enabled="camEnabled"
|
||||
:chat-open="panel === 'chat'"
|
||||
:people-open="panel === 'people'"
|
||||
:unread="unread"
|
||||
@toggle-mic="emit('toggle-mic')"
|
||||
@toggle-cam="emit('toggle-cam')"
|
||||
@toggle-chat="togglePanel('chat')"
|
||||
@toggle-people="togglePanel('people')"
|
||||
@leave="emit('leave')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.room {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
.room-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.875rem;
|
||||
height: 3.5rem;
|
||||
flex-shrink: 0;
|
||||
padding: 0 1.125rem;
|
||||
border-bottom: 1px solid var(--d-border);
|
||||
background: var(--d-panel);
|
||||
}
|
||||
.rb-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6875rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.rb-live {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.05em;
|
||||
color: #ff5a6e;
|
||||
}
|
||||
.rb-live i {
|
||||
width: 0.4375rem;
|
||||
height: 0.4375rem;
|
||||
border-radius: 50%;
|
||||
background: #ff5a6e;
|
||||
animation: livedot 1.6s infinite;
|
||||
}
|
||||
@keyframes livedot {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
.rb-name {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: var(--d-text);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.rb-right {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.rb-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--d-text-2);
|
||||
}
|
||||
.rb-count svg {
|
||||
width: 0.9375rem;
|
||||
height: 0.9375rem;
|
||||
}
|
||||
.room-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
.room-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 1rem 1.125rem 6.25rem;
|
||||
}
|
||||
|
||||
/* 스피커 뷰 — 위: 축소된 스테이지 / 아래: 썸네일 그리드(50명 채움) */
|
||||
.speaker-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.875rem;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
.stage {
|
||||
flex: 0 0 45%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
/* 축소된 스테이지에서 큰 화면은 16:9 비율 유지 + 높이에 맞춰 중앙 정렬 */
|
||||
.stage :deep(.tile.featured) {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
.stage-empty {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--d-text-3);
|
||||
border: 1px dashed var(--d-border);
|
||||
border-radius: 0.875rem;
|
||||
}
|
||||
.thumbs {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
.thumb-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding-right: 0.125rem;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--d-border) transparent;
|
||||
}
|
||||
.thumb-scroll::-webkit-scrollbar {
|
||||
width: 0.4375rem;
|
||||
}
|
||||
.thumb-scroll::-webkit-scrollbar-thumb {
|
||||
background: var(--d-border);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.thumb-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr));
|
||||
gap: 0.5rem;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
/* 페이지네이션 */
|
||||
.pager {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pg-btn {
|
||||
width: 1.875rem;
|
||||
height: 1.875rem;
|
||||
border: 1px solid var(--d-border);
|
||||
background: var(--d-panel-2);
|
||||
color: var(--d-text-2);
|
||||
border-radius: 0.4375rem;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.pg-btn svg {
|
||||
width: 0.9375rem;
|
||||
height: 0.9375rem;
|
||||
}
|
||||
.pg-btn:hover:not(:disabled) {
|
||||
color: var(--d-text);
|
||||
border-color: var(--d-text-3);
|
||||
}
|
||||
.pg-btn:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.pg-info {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--d-text-3);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
// 회의실 오른쪽 패널 — 참여자/채팅 탭 전환 컨테이너. 활성 탭은 부모가 제어(컨트롤 바와 연동).
|
||||
import type { Participant } from 'livekit-client'
|
||||
import MeetingIcon from './MeetingIcon.vue'
|
||||
import MeetingPeopleList from './MeetingPeopleList.vue'
|
||||
import MeetingChat from './MeetingChat.vue'
|
||||
import type { ChatMessage } from '@/types/meeting'
|
||||
|
||||
type Tab = 'people' | 'chat'
|
||||
|
||||
interface Props {
|
||||
tab: Tab
|
||||
participants: Participant[]
|
||||
featuredId: string | null
|
||||
localId: string | null
|
||||
isHost: boolean
|
||||
messages: ChatMessage[]
|
||||
}
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'tab', tab: Tab): void
|
||||
(e: 'close'): void
|
||||
(e: 'promote', identity: string): void
|
||||
(e: 'send', text: string): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="ppanel">
|
||||
<div class="sp-tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="sp-tab"
|
||||
:class="{ on: tab === 'people' }"
|
||||
@click="emit('tab', 'people')"
|
||||
>
|
||||
참여자 <span class="sp-cnt">{{ participants.length }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="sp-tab"
|
||||
:class="{ on: tab === 'chat' }"
|
||||
@click="emit('tab', 'chat')"
|
||||
>
|
||||
채팅
|
||||
</button>
|
||||
<span class="sp-grow" />
|
||||
<button
|
||||
type="button"
|
||||
class="pp-x"
|
||||
title="패널 닫기"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<MeetingIcon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
<MeetingChat
|
||||
v-if="tab === 'chat'"
|
||||
:messages="messages"
|
||||
@send="(t) => emit('send', t)"
|
||||
/>
|
||||
<MeetingPeopleList
|
||||
v-else
|
||||
:participants="participants"
|
||||
:featured-id="featuredId"
|
||||
:local-id="localId"
|
||||
:is-host="isHost"
|
||||
@promote="(id) => emit('promote', id)"
|
||||
/>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ppanel {
|
||||
width: 19.5rem;
|
||||
flex-shrink: 0;
|
||||
border-left: 1px solid var(--d-border);
|
||||
background: var(--d-panel);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.sp-tabs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5625rem 0.5rem 0.5625rem 0.625rem;
|
||||
border-bottom: 1px solid var(--d-border);
|
||||
}
|
||||
.sp-tab {
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-family: inherit;
|
||||
font-size: 0.84375rem;
|
||||
font-weight: 600;
|
||||
color: var(--d-text-3);
|
||||
padding: 0.4375rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4375rem;
|
||||
}
|
||||
.sp-tab:hover {
|
||||
color: var(--d-text-2);
|
||||
}
|
||||
.sp-tab.on {
|
||||
color: var(--d-text);
|
||||
background: var(--d-panel-2);
|
||||
}
|
||||
.sp-cnt {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
color: var(--d-text-2);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
padding: 1px 0.4375rem;
|
||||
border-radius: 1.25rem;
|
||||
}
|
||||
.sp-tab.on .sp-cnt {
|
||||
color: var(--d-accent-soft);
|
||||
background: rgba(124, 92, 240, 0.22);
|
||||
}
|
||||
.sp-grow {
|
||||
flex: 1;
|
||||
}
|
||||
.pp-x {
|
||||
width: 1.875rem;
|
||||
height: 1.875rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--d-text-3);
|
||||
border-radius: 0.4375rem;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.pp-x svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
.pp-x:hover {
|
||||
background: var(--d-panel-2);
|
||||
color: var(--d-text);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,325 @@
|
||||
<script setup lang="ts">
|
||||
// 참여자 1명의 영상 타일 — 카메라 트랙을 <video>, (원격) 마이크 트랙을 <audio> 에 연결한다.
|
||||
// 로컬 참여자 오디오는 에코 방지를 위해 재생하지 않는다. 카메라가 꺼져 있으면 이름 이니셜 아바타.
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import {
|
||||
Track,
|
||||
ParticipantEvent,
|
||||
ConnectionQuality,
|
||||
type Participant,
|
||||
} from 'livekit-client'
|
||||
import QualityBars from './QualityBars.vue'
|
||||
import MeetingIcon from './MeetingIcon.vue'
|
||||
import { avatarColor, avatarInitial } from '@/shared/utils/avatar'
|
||||
import { normalizeQuality } from '@/composables/useLiveKitRoom'
|
||||
import type { MeetingQuality } from '@/types/meeting'
|
||||
|
||||
interface Props {
|
||||
participant: Participant
|
||||
isLocal?: boolean
|
||||
// 큰 화면(stage) 렌더 여부
|
||||
featured?: boolean
|
||||
// 진행자가 이 타일을 승격할 수 있는지(호버 시 "크게" 버튼)
|
||||
canPromote?: boolean
|
||||
// 말하는 사람 하이라이트 사용 여부
|
||||
highlight?: boolean
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{ (e: 'promote', identity: string): void }>()
|
||||
|
||||
const videoEl = ref<HTMLVideoElement | null>(null)
|
||||
const hasVideo = ref(false)
|
||||
const micOn = ref(false)
|
||||
const speaking = ref(false)
|
||||
const quality = ref<MeetingQuality>('good')
|
||||
const isHost = ref(false)
|
||||
|
||||
const displayName = ref(props.participant.name || props.participant.identity)
|
||||
const avColor = computed(() => avatarColor(displayName.value))
|
||||
const initial = computed(() => avatarInitial(displayName.value))
|
||||
const showSpeaking = computed(
|
||||
() => props.highlight && speaking.value && micOn.value,
|
||||
)
|
||||
|
||||
// 참여자 metadata 문자열에서 진행자 여부 파싱({"role":"host"})
|
||||
function parseHost(metadata: string | undefined): boolean {
|
||||
if (!metadata) return false
|
||||
try {
|
||||
return (JSON.parse(metadata) as { role?: string }).role === 'host'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 현재 트랙/상태를 DOM 과 반응형 값에 반영
|
||||
function sync() {
|
||||
const p = props.participant
|
||||
displayName.value = p.name || p.identity
|
||||
micOn.value = p.isMicrophoneEnabled
|
||||
speaking.value = p.isSpeaking
|
||||
quality.value = normalizeQuality(p.connectionQuality as ConnectionQuality)
|
||||
isHost.value = parseHost(p.metadata)
|
||||
|
||||
// 오디오는 룸 레벨(useLiveKitRoom)에서 전원 재생하므로 여기서는 영상만 다룬다.
|
||||
// 영상 트랙 연결만 담당한다. 수신 화질(스테이지=HIGH/나머지=LOW)은 타일이 아니라
|
||||
// useLiveKitRoom 가 스테이지 변경에 맞춰 중앙에서 제어한다(강등 시 누락·레이스 방지).
|
||||
const camPub = p.getTrackPublication(Track.Source.Camera)
|
||||
if (camPub?.track && videoEl.value) {
|
||||
camPub.track.attach(videoEl.value)
|
||||
hasVideo.value = !camPub.isMuted
|
||||
} else {
|
||||
hasVideo.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSpeaking() {
|
||||
speaking.value = props.participant.isSpeaking
|
||||
}
|
||||
function onQuality() {
|
||||
quality.value = normalizeQuality(
|
||||
props.participant.connectionQuality as ConnectionQuality,
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
sync()
|
||||
const p = props.participant
|
||||
p.on(ParticipantEvent.TrackSubscribed, sync)
|
||||
p.on(ParticipantEvent.TrackUnsubscribed, sync)
|
||||
p.on(ParticipantEvent.LocalTrackPublished, sync)
|
||||
p.on(ParticipantEvent.LocalTrackUnpublished, sync)
|
||||
p.on(ParticipantEvent.TrackMuted, sync)
|
||||
p.on(ParticipantEvent.TrackUnmuted, sync)
|
||||
p.on(ParticipantEvent.IsSpeakingChanged, onSpeaking)
|
||||
p.on(ParticipantEvent.ConnectionQualityChanged, onQuality)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
const p = props.participant
|
||||
p.off(ParticipantEvent.TrackSubscribed, sync)
|
||||
p.off(ParticipantEvent.TrackUnsubscribed, sync)
|
||||
p.off(ParticipantEvent.LocalTrackPublished, sync)
|
||||
p.off(ParticipantEvent.LocalTrackUnpublished, sync)
|
||||
p.off(ParticipantEvent.TrackMuted, sync)
|
||||
p.off(ParticipantEvent.TrackUnmuted, sync)
|
||||
p.off(ParticipantEvent.IsSpeakingChanged, onSpeaking)
|
||||
p.off(ParticipantEvent.ConnectionQualityChanged, onQuality)
|
||||
// 타일 언마운트 시 비디오 트랙을 엘리먼트에서 분리(미분리 시 attachedElements 누수)
|
||||
const camPub = p.getTrackPublication(Track.Source.Camera)
|
||||
if (camPub?.track && videoEl.value) camPub.track.detach(videoEl.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="tile"
|
||||
:class="{ featured, speaking: showSpeaking }"
|
||||
:style="{ '--av': avColor }"
|
||||
>
|
||||
<!-- 영상: 로컬은 좌우 반전(거울)으로 표시 -->
|
||||
<video
|
||||
ref="videoEl"
|
||||
class="vid"
|
||||
:class="{ mirror: isLocal, hidden: !hasVideo }"
|
||||
autoplay
|
||||
playsinline
|
||||
:muted="isLocal"
|
||||
/>
|
||||
<!-- 카메라 off/미발행 시 이름 이니셜 아바타 -->
|
||||
<div
|
||||
v-if="!hasVideo"
|
||||
class="tile-off"
|
||||
>
|
||||
<span class="av-lg">{{ initial }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 연결 품질 -->
|
||||
<div class="tile-q">
|
||||
<QualityBars :quality="quality" />
|
||||
</div>
|
||||
|
||||
<!-- 진행자 승격 버튼(호버) -->
|
||||
<button
|
||||
v-if="canPromote && !featured"
|
||||
type="button"
|
||||
class="tile-promote"
|
||||
title="스테이지로 크게 보기"
|
||||
@click="emit('promote', participant.identity)"
|
||||
>
|
||||
<MeetingIcon name="expand" /><span>크게</span>
|
||||
</button>
|
||||
|
||||
<!-- 하단 메타 -->
|
||||
<div class="tile-meta">
|
||||
<span
|
||||
class="mmic"
|
||||
:class="{ off: !micOn }"
|
||||
:title="micOn ? '마이크 켜짐' : '마이크 꺼짐'"
|
||||
>
|
||||
<MeetingIcon :name="micOn ? 'mic' : 'micOff'" />
|
||||
</span>
|
||||
<span class="mname">{{ displayName }}{{ isLocal ? ' (나)' : '' }}</span>
|
||||
<span
|
||||
v-if="isHost"
|
||||
class="mhost"
|
||||
title="진행자"
|
||||
>
|
||||
<MeetingIcon name="host" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tile {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
background: var(--tile);
|
||||
border: 0.125rem solid transparent;
|
||||
}
|
||||
.tile.featured {
|
||||
aspect-ratio: auto;
|
||||
height: 100%;
|
||||
border-radius: 0.875rem;
|
||||
}
|
||||
.tile.speaking {
|
||||
border-color: var(--green);
|
||||
box-shadow: 0 0 0 1px var(--green), 0 0 22px -4px rgba(31, 157, 87, 0.7);
|
||||
}
|
||||
.vid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.vid.mirror {
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
.vid.hidden {
|
||||
display: none;
|
||||
}
|
||||
.tile-off {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--tile);
|
||||
}
|
||||
.av-lg {
|
||||
width: clamp(2.875rem, 9vh, 5.75rem);
|
||||
height: clamp(2.875rem, 9vh, 5.75rem);
|
||||
border-radius: 50%;
|
||||
background: var(--av, #555);
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: clamp(1.25rem, 4vh, 2.375rem);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.tile-q {
|
||||
position: absolute;
|
||||
top: 0.5625rem;
|
||||
left: 0.5625rem;
|
||||
z-index: 3;
|
||||
}
|
||||
.tile-meta {
|
||||
position: absolute;
|
||||
left: 0.5625rem;
|
||||
bottom: 0.5625rem;
|
||||
right: 0.5625rem;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.tile.featured .tile-meta {
|
||||
left: 0.875rem;
|
||||
bottom: 0.875rem;
|
||||
}
|
||||
.mmic {
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
border-radius: 0.375rem;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mmic svg {
|
||||
width: 0.8125rem;
|
||||
height: 0.8125rem;
|
||||
}
|
||||
.mmic.off {
|
||||
color: var(--d-red);
|
||||
}
|
||||
.mname {
|
||||
font-size: 0.78125rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
padding: 0.1875rem 0.5625rem;
|
||||
border-radius: 0.375rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.tile.featured .mname {
|
||||
font-size: 0.875rem;
|
||||
padding: 0.3125rem 0.75rem;
|
||||
}
|
||||
.mhost {
|
||||
width: 1.375rem;
|
||||
height: 1.375rem;
|
||||
border-radius: 0.375rem;
|
||||
background: rgba(124, 92, 240, 0.85);
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.mhost svg {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
}
|
||||
.tile-promote {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
z-index: 4;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3125rem;
|
||||
height: 1.75rem;
|
||||
padding: 0 0.625rem;
|
||||
border: none;
|
||||
border-radius: 0.4375rem;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transform: translateY(-0.125rem);
|
||||
transition:
|
||||
opacity 0.12s,
|
||||
transform 0.12s;
|
||||
}
|
||||
.tile-promote svg {
|
||||
width: 0.8125rem;
|
||||
height: 0.8125rem;
|
||||
}
|
||||
.tile:hover .tile-promote {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
.tile-promote:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
// 연결 품질 막대(3/2/1칸) — good/medium/poor 단계에 따라 채워진 칸 수와 색이 달라진다.
|
||||
import { computed } from 'vue'
|
||||
import type { MeetingQuality } from '@/types/meeting'
|
||||
|
||||
interface Props {
|
||||
quality: MeetingQuality
|
||||
// 'tile'(다크 배경 위 반투명 칩) | 'bare'(배경 없이 막대만, 목록·상단바용)
|
||||
variant?: 'tile' | 'bare'
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), { variant: 'tile' })
|
||||
|
||||
const level = computed(() =>
|
||||
props.quality === 'good' ? 3 : props.quality === 'medium' ? 2 : 1,
|
||||
)
|
||||
const label = computed(() =>
|
||||
props.quality === 'good'
|
||||
? '원활'
|
||||
: props.quality === 'medium'
|
||||
? '보통'
|
||||
: '불안정',
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="qbars"
|
||||
:class="[`q-${quality}`, variant === 'bare' ? 'bare' : '']"
|
||||
:title="`연결 품질: ${label}`"
|
||||
>
|
||||
<i
|
||||
v-for="n in 3"
|
||||
:key="n"
|
||||
:class="{ on: n <= level }"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.qbars {
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
gap: 0.125rem;
|
||||
height: 0.8125rem;
|
||||
padding: 0.1875rem;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
border-radius: 0.3125rem;
|
||||
}
|
||||
.qbars.bare {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
.qbars i {
|
||||
width: 0.1875rem;
|
||||
border-radius: 1px;
|
||||
background: rgba(255, 255, 255, 0.32);
|
||||
}
|
||||
.qbars.bare i {
|
||||
background: #3a3f4a;
|
||||
}
|
||||
.qbars i:nth-child(1) {
|
||||
height: 0.25rem;
|
||||
}
|
||||
.qbars i:nth-child(2) {
|
||||
height: 0.4375rem;
|
||||
}
|
||||
.qbars i:nth-child(3) {
|
||||
height: 0.625rem;
|
||||
}
|
||||
.qbars.q-good i.on {
|
||||
background: var(--d-green);
|
||||
}
|
||||
.qbars.q-medium i.on {
|
||||
background: var(--d-amber);
|
||||
}
|
||||
.qbars.q-poor i.on {
|
||||
background: var(--d-red);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,153 @@
|
||||
<script setup lang="ts">
|
||||
// 프로젝트 AI 검토 문서 모달 — 연동 드라이브 폴더 내 문서를 리스트로 표시(다운로드 전용)
|
||||
import BaseModal from '@/components/common/BaseModal.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import AttachmentAiBadge from '@/components/common/AttachmentAiBadge.vue'
|
||||
import { useDrive } from '@/composables/useDrive'
|
||||
import { fileBadgeOf, formatBytes } from '@/shared/utils/format'
|
||||
import type { ApiProjectDocument, ApiProjectDriveLink } from '@/types/project'
|
||||
|
||||
defineProps<{
|
||||
documents: ApiProjectDocument[]
|
||||
driveLinks: ApiProjectDriveLink[]
|
||||
}>()
|
||||
defineEmits<{ (e: 'close'): void }>()
|
||||
|
||||
const drive = useDrive()
|
||||
|
||||
// 드라이브 단기 presigned URL 발급 후 다운로드
|
||||
async function download(fileId: string): Promise<void> {
|
||||
const url = await drive.getDownloadUrl(fileId)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.rel = 'noopener'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal
|
||||
title="AI 검토 문서"
|
||||
@close="$emit('close')"
|
||||
>
|
||||
<template #subtitle>
|
||||
연동된 드라이브 폴더 <b>{{ driveLinks.length }}</b>개의 문서를 AI 업무 추천에 활용합니다.
|
||||
</template>
|
||||
|
||||
<EmptyState v-if="documents.length === 0">
|
||||
연동 폴더에 문서가 없습니다. 설정에서 드라이브 폴더를 연동해 보세요.
|
||||
</EmptyState>
|
||||
<div
|
||||
v-else
|
||||
class="pdm-list"
|
||||
>
|
||||
<button
|
||||
v-for="d in documents"
|
||||
:key="d.id"
|
||||
class="pdm-row"
|
||||
type="button"
|
||||
title="다운로드"
|
||||
@click="download(d.id)"
|
||||
>
|
||||
<span
|
||||
class="pdm-ico"
|
||||
:class="fileBadgeOf(d.name).cls"
|
||||
>{{ fileBadgeOf(d.name).label }}</span>
|
||||
<span class="pdm-main">
|
||||
<span class="pdm-name">{{ d.name }}</span>
|
||||
<span class="pdm-size">{{ formatBytes(d.size) }}</span>
|
||||
</span>
|
||||
<AttachmentAiBadge :status="d.aiStatus" />
|
||||
</button>
|
||||
</div>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pdm-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.625rem;
|
||||
overflow: hidden;
|
||||
max-height: 24rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.pdm-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
.pdm-row:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
.pdm-row:hover {
|
||||
background: #fafbfc;
|
||||
}
|
||||
.pdm-ico {
|
||||
flex: 0 0 auto;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 0.5rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
color: #fff;
|
||||
}
|
||||
.pdm-main {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
.pdm-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pdm-size {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
/* 파일 종류 배지 색상(format.fileBadgeOf 와 동일) */
|
||||
.fi-img {
|
||||
background: #2aa775;
|
||||
}
|
||||
.fi-pdf {
|
||||
background: #e25950;
|
||||
}
|
||||
.fi-doc {
|
||||
background: #3d8bf2;
|
||||
}
|
||||
.fi-xls {
|
||||
background: #1f9d57;
|
||||
}
|
||||
.fi-ppt {
|
||||
background: #e0823d;
|
||||
}
|
||||
.fi-zip {
|
||||
background: #8a64d6;
|
||||
}
|
||||
.fi-txt {
|
||||
background: #5b6470;
|
||||
}
|
||||
.fi-media {
|
||||
background: #c0469d;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useApi } from './useApi'
|
||||
import type {
|
||||
DriveActivityPage,
|
||||
DriveFile,
|
||||
DriveFolder,
|
||||
DriveList,
|
||||
PresignUploadPayload,
|
||||
PresignUploadResult,
|
||||
} from '@/types/drive'
|
||||
|
||||
// 드라이브 도메인 API Composable — 인터셉터가 success/data 를 언래핑
|
||||
// (바이너리 송수신은 백엔드를 거치지 않고 presigned URL 로 직접 — store 에서 처리)
|
||||
export function useDrive() {
|
||||
const api = useApi()
|
||||
|
||||
// 목록 (folderId 생략 시 루트)
|
||||
async function list(folderId?: string): Promise<DriveList> {
|
||||
return (await api.get('/drive', {
|
||||
params: folderId ? { folderId } : {},
|
||||
})) as unknown as DriveList
|
||||
}
|
||||
|
||||
// 폴더
|
||||
async function createFolder(
|
||||
name: string,
|
||||
parentId?: string,
|
||||
): Promise<DriveFolder> {
|
||||
return (await api.post('/drive/folders', {
|
||||
name,
|
||||
...(parentId ? { parentId } : {}),
|
||||
})) as unknown as DriveFolder
|
||||
}
|
||||
async function renameFolder(id: string, name: string): Promise<DriveFolder> {
|
||||
return (await api.patch(`/drive/folders/${id}`, { name })) as unknown as DriveFolder
|
||||
}
|
||||
async function deleteFolder(id: string): Promise<void> {
|
||||
await api.delete(`/drive/folders/${id}`)
|
||||
}
|
||||
|
||||
// 파일 — presigned 3단계 업로드(① presign → ② 직접 PUT[store] → ③ complete)
|
||||
async function presignUpload(
|
||||
payload: PresignUploadPayload,
|
||||
): Promise<PresignUploadResult> {
|
||||
return (await api.post('/drive/files/presign', payload)) as unknown as PresignUploadResult
|
||||
}
|
||||
async function completeUpload(fileId: string): Promise<DriveFile> {
|
||||
return (await api.post(`/drive/files/${fileId}/complete`)) as unknown as DriveFile
|
||||
}
|
||||
async function getDownloadUrl(fileId: string): Promise<string> {
|
||||
const res = (await api.get(`/drive/files/${fileId}/download-url`)) as unknown as {
|
||||
url: string
|
||||
}
|
||||
return res.url
|
||||
}
|
||||
async function renameFile(id: string, name: string): Promise<DriveFile> {
|
||||
return (await api.patch(`/drive/files/${id}`, { name })) as unknown as DriveFile
|
||||
}
|
||||
async function deleteFile(id: string): Promise<void> {
|
||||
await api.delete(`/drive/files/${id}`)
|
||||
}
|
||||
|
||||
// 감사 로그(최신순, 페이지네이션)
|
||||
async function listActivity(
|
||||
page = 1,
|
||||
size = 20,
|
||||
): Promise<DriveActivityPage> {
|
||||
return (await api.get('/drive/activity', {
|
||||
params: { page, size },
|
||||
})) as unknown as DriveActivityPage
|
||||
}
|
||||
|
||||
return {
|
||||
list,
|
||||
createFolder,
|
||||
renameFolder,
|
||||
deleteFolder,
|
||||
presignUpload,
|
||||
completeUpload,
|
||||
getDownloadUrl,
|
||||
renameFile,
|
||||
deleteFile,
|
||||
listActivity,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
import { ref, shallowRef, watch } from 'vue'
|
||||
import {
|
||||
Room,
|
||||
RoomEvent,
|
||||
Track,
|
||||
ConnectionQuality,
|
||||
RemoteTrackPublication,
|
||||
VideoQuality,
|
||||
type Participant,
|
||||
type RemoteParticipant,
|
||||
type RemoteTrack,
|
||||
} from 'livekit-client'
|
||||
import type { ChatMessage, MeetingQuality } from '@/types/meeting'
|
||||
|
||||
// 브라우저가 접속할 LiveKit WebSocket URL (build arg/env 로 주입, 미설정 시 로컬 기본값)
|
||||
const LIVEKIT_URL = import.meta.env.VITE_LIVEKIT_URL || 'ws://localhost:7880'
|
||||
|
||||
// 채팅 메시지 최대 길이(데이터 채널 패킷 과대 방지) — 입력/전송에서 공유한다.
|
||||
export const MAX_CHAT_LENGTH = 2000
|
||||
|
||||
// 입장 옵션 — 프리조인에서 정한 초기 마이크/카메라 상태
|
||||
export interface ConnectOptions {
|
||||
cam?: boolean
|
||||
mic?: boolean
|
||||
}
|
||||
|
||||
// LiveKit ConnectionQuality → UI 막대 단계로 정규화
|
||||
export function normalizeQuality(q: ConnectionQuality | undefined): MeetingQuality {
|
||||
switch (q) {
|
||||
case ConnectionQuality.Excellent:
|
||||
case ConnectionQuality.Good:
|
||||
return 'good'
|
||||
case ConnectionQuality.Poor:
|
||||
return 'medium'
|
||||
case ConnectionQuality.Lost:
|
||||
return 'poor'
|
||||
default:
|
||||
return 'good'
|
||||
}
|
||||
}
|
||||
|
||||
// 참여자 metadata(JSON)에서 프로필 이미지 URL 파싱 — 토큰 발급 시 백엔드가 실어준다.
|
||||
function parseMetaAvatar(metadata: string | undefined): string | null {
|
||||
if (!metadata) return null
|
||||
try {
|
||||
return (
|
||||
(JSON.parse(metadata) as { avatarUrl?: string | null }).avatarUrl ?? null
|
||||
)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 참여자 metadata 가 진행자(host)인지 — 서버 서명 토큰의 값이라 위조 불가.
|
||||
function isHostMeta(metadata: string | undefined): boolean {
|
||||
if (!metadata) return false
|
||||
try {
|
||||
return (JSON.parse(metadata) as { role?: string }).role === 'host'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// LiveKit 방 연결/생명주기 관리 Composable
|
||||
// 미디어 전송 자체는 LiveKit SDK 가 담당하고, 여기서는 연결 상태와 참여자 목록만 반응형으로 노출한다.
|
||||
export function useLiveKitRoom() {
|
||||
// Room 인스턴스는 깊은 반응형이 불필요(내부적으로 자체 이벤트 관리) → shallowRef
|
||||
const room = shallowRef<Room | null>(null)
|
||||
// 참여자 배열도 shallowRef — Participant 는 비공개 멤버가 있는 클래스라 깊은 언랩 시
|
||||
// 구조적 타입으로 바뀌어 타입 불일치가 난다. 매번 배열을 통째로 교체하므로 shallow 로 충분.
|
||||
const participants = shallowRef<Participant[]>([])
|
||||
const connected = ref(false)
|
||||
const connecting = ref(false)
|
||||
const micEnabled = ref(false)
|
||||
const camEnabled = ref(false)
|
||||
const errorMsg = ref<string | null>(null)
|
||||
// 크게 보기(stage)로 승격된 참여자 identity. 진행자가 데이터 채널로 브로드캐스트 → 전원 동기화.
|
||||
const featuredIdentity = ref<string | null>(null)
|
||||
// 채팅 메시지(인메모리) — 데이터 채널로 주고받음. 서버에 저장하지 않으므로 새로고침 시 사라진다.
|
||||
const messages = ref<ChatMessage[]>([])
|
||||
|
||||
// 로컬 + 원격 참여자 목록 갱신. 마이크/발화/품질 등 참여자 상태 변동 시에도 호출해
|
||||
// 배열 참조를 새로 만들어(=동일 Participant 인스턴스 유지) 의존 computed 를 재평가시킨다.
|
||||
function refresh() {
|
||||
const r = room.value
|
||||
if (!r) {
|
||||
participants.value = []
|
||||
return
|
||||
}
|
||||
participants.value = [
|
||||
r.localParticipant,
|
||||
...Array.from(r.remoteParticipants.values()),
|
||||
]
|
||||
}
|
||||
|
||||
// 스테이지(featured)로 보일 참여자 식별자 해석 — 명시 승격 > 진행자 > 첫 참여자.
|
||||
// (MeetingRoom 의 레이아웃 featured 해석과 동일 규칙이라 화질=스테이지가 일치한다)
|
||||
function effectiveFeaturedId(): string | null {
|
||||
const r = room.value
|
||||
if (!r) return null
|
||||
const all = [
|
||||
r.localParticipant,
|
||||
...Array.from(r.remoteParticipants.values()),
|
||||
]
|
||||
if (
|
||||
featuredIdentity.value &&
|
||||
all.some((p) => p.identity === featuredIdentity.value)
|
||||
) {
|
||||
return featuredIdentity.value
|
||||
}
|
||||
return (all.find((p) => isHostMeta(p.metadata)) ?? all[0])?.identity ?? null
|
||||
}
|
||||
|
||||
// 수신 화질 적용 — 스테이지 유저만 HIGH, 나머지 원격 참여자는 모두 LOW 로 구독한다.
|
||||
// 각 클라이언트가 자기 시점에서 적용하므로 "남들이 보기에" 스테이지=고화질이 된다.
|
||||
// 스테이지 변경·참여자/트랙 변동 시마다 호출해 강등된 참여자도 확실히 LOW 로 내린다.
|
||||
// (adaptiveStream 을 끈 상태라 setVideoQuality 가 곧 실효 화질 — 크기에 덮어써지지 않음)
|
||||
function applyVideoQuality() {
|
||||
const r = room.value
|
||||
if (!r) return
|
||||
const fid = effectiveFeaturedId()
|
||||
r.remoteParticipants.forEach((p) => {
|
||||
const pub = p.getTrackPublication(Track.Source.Camera)
|
||||
if (pub instanceof RemoteTrackPublication && pub.isSubscribed) {
|
||||
pub.setVideoQuality(
|
||||
p.identity === fid ? VideoQuality.HIGH : VideoQuality.LOW,
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 참여자 변동(입장/퇴장/메타변경) 시 목록 갱신 + 화질 재적용(스테이지 폴백이 바뀔 수 있음)
|
||||
function onParticipantsChanged() {
|
||||
refresh()
|
||||
applyVideoQuality()
|
||||
}
|
||||
|
||||
// 스테이지(featuredIdentity)가 바뀌면 즉시 화질 재적용 — 강등된 참여자를 LOW 로 내린다.
|
||||
watch(featuredIdentity, () => applyVideoQuality())
|
||||
|
||||
// --- 원격 오디오는 화면 타일(페이지네이션)과 무관하게 항상 재생되어야 한다 ---
|
||||
// 타일에 attach 하면 현재 페이지에 없는 참여자(>50번째)는 무음이 되므로,
|
||||
// 숨은 컨테이너에 트랙별 audio 엘리먼트를 붙여 룸 레벨에서 전원 오디오를 재생한다.
|
||||
let audioContainer: HTMLElement | null = null
|
||||
const audioEls = new Map<string, HTMLMediaElement>()
|
||||
|
||||
function attachRemoteAudio(track: RemoteTrack, pub: RemoteTrackPublication) {
|
||||
if (track.kind !== Track.Kind.Audio) return
|
||||
if (!audioContainer) {
|
||||
audioContainer = document.createElement('div')
|
||||
audioContainer.style.display = 'none'
|
||||
document.body.appendChild(audioContainer)
|
||||
}
|
||||
const el = track.attach() // 새 audio 엘리먼트 생성
|
||||
audioContainer.appendChild(el)
|
||||
audioEls.set(pub.trackSid, el)
|
||||
}
|
||||
function detachRemoteAudio(track: RemoteTrack, pub: RemoteTrackPublication) {
|
||||
if (track.kind !== Track.Kind.Audio) return
|
||||
track.detach()
|
||||
const el = audioEls.get(pub.trackSid)
|
||||
if (el) {
|
||||
el.remove()
|
||||
audioEls.delete(pub.trackSid)
|
||||
}
|
||||
}
|
||||
function cleanupAudio() {
|
||||
audioEls.forEach((el) => el.remove())
|
||||
audioEls.clear()
|
||||
audioContainer?.remove()
|
||||
audioContainer = null
|
||||
}
|
||||
|
||||
// TrackSubscribed/Unsubscribed: 목록 갱신 + (오디오면) 룸 레벨 재생 관리
|
||||
function onTrackSubscribed(track: RemoteTrack, pub: RemoteTrackPublication) {
|
||||
refresh()
|
||||
attachRemoteAudio(track, pub)
|
||||
// 새로 구독된 영상에도 스테이지 여부에 맞는 화질을 적용한다(늦게 켠 카메라 등).
|
||||
applyVideoQuality()
|
||||
}
|
||||
function onTrackUnsubscribed(track: RemoteTrack, pub: RemoteTrackPublication) {
|
||||
refresh()
|
||||
detachRemoteAudio(track, pub)
|
||||
}
|
||||
|
||||
function onDisconnected() {
|
||||
connected.value = false
|
||||
participants.value = []
|
||||
featuredIdentity.value = null
|
||||
messages.value = []
|
||||
cleanupAudio()
|
||||
}
|
||||
|
||||
// 데이터 채널 수신 — 진행자의 승격(promote) + 채팅(chat) 메시지를 처리
|
||||
function onData(payload: Uint8Array, sender?: RemoteParticipant) {
|
||||
try {
|
||||
const msg = JSON.parse(new TextDecoder().decode(payload)) as {
|
||||
type?: string
|
||||
identity?: string
|
||||
text?: string
|
||||
ts?: number
|
||||
}
|
||||
if (msg.type === 'promote' && typeof msg.identity === 'string') {
|
||||
// 진행자가 보낸 승격만 적용 — 일반 참여자가 보낸 promote 는 무시(인가).
|
||||
if (isHostMeta(sender?.metadata)) featuredIdentity.value = msg.identity
|
||||
} else if (msg.type === 'chat' && typeof msg.text === 'string') {
|
||||
messages.value = [
|
||||
...messages.value,
|
||||
{
|
||||
id: `${sender?.identity ?? 'remote'}-${msg.ts ?? performance.now()}`,
|
||||
senderId: sender?.identity ?? 'unknown',
|
||||
// 표시 이름은 서버 서명 토큰의 name/identity 만 신뢰(클라이언트가 보낸 name 은 사칭 위험이라 무시).
|
||||
senderName: sender?.name || sender?.identity || '참여자',
|
||||
senderAvatarUrl: parseMetaAvatar(sender?.metadata),
|
||||
text: msg.text,
|
||||
ts: msg.ts ?? Date.now(),
|
||||
self: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
} catch {
|
||||
// 알 수 없는 메시지는 무시
|
||||
}
|
||||
}
|
||||
|
||||
// 특정 참여자를 크게 보기로 승격 — 진행자만 호출(UI에서 제한). 전원에게 브로드캐스트.
|
||||
async function promote(identity: string): Promise<void> {
|
||||
const r = room.value
|
||||
if (!r) return
|
||||
featuredIdentity.value = identity
|
||||
const data = new TextEncoder().encode(
|
||||
JSON.stringify({ type: 'promote', identity }),
|
||||
)
|
||||
await r.localParticipant.publishData(data, { reliable: true })
|
||||
}
|
||||
|
||||
// 채팅 전송 — 데이터 채널로 브로드캐스트 + 본인 목록에 즉시 추가
|
||||
async function sendChat(text: string): Promise<void> {
|
||||
const r = room.value
|
||||
// 데이터 채널 패킷 권장 크기를 넘지 않도록 길이 제한(과도한 페이로드 방지).
|
||||
const body = text.trim().slice(0, MAX_CHAT_LENGTH)
|
||||
if (!r || !body) return
|
||||
const ts = Date.now()
|
||||
const me = r.localParticipant
|
||||
messages.value = [
|
||||
...messages.value,
|
||||
{
|
||||
id: `${me.identity}-${ts}`,
|
||||
senderId: me.identity,
|
||||
senderName: me.name || me.identity,
|
||||
senderAvatarUrl: parseMetaAvatar(me.metadata),
|
||||
text: body,
|
||||
ts,
|
||||
self: true,
|
||||
},
|
||||
]
|
||||
// name 은 보내지 않는다 — 수신 측은 서명된 sender.name 을 사용(사칭 방지).
|
||||
const data = new TextEncoder().encode(
|
||||
JSON.stringify({ type: 'chat', text: body, ts }),
|
||||
)
|
||||
await me.publishData(data, { reliable: true })
|
||||
}
|
||||
|
||||
// 방 입장 — 토큰으로 연결 후 옵션에 맞춰 카메라/마이크 발행
|
||||
async function connect(token: string, opts: ConnectOptions = {}): Promise<void> {
|
||||
if (connecting.value || connected.value) return
|
||||
const wantCam = opts.cam ?? true
|
||||
const wantMic = opts.mic ?? true
|
||||
connecting.value = true
|
||||
errorMsg.value = null
|
||||
try {
|
||||
// adaptiveStream 은 끈다 — 화질을 "엘리먼트 크기"가 아니라 "스테이지 역할"로
|
||||
// 명시 제어하기 위함(스테이지=HIGH, 나머지=LOW 보장). 켜져 있으면 setVideoQuality 가
|
||||
// 타일 크기에 덮어써져(min) 스테이지가 HD 가 안 되거나 강등 반영이 비결정적이 된다.
|
||||
// dynacast 는 유지 — 아무도 구독 안 하는 레이어는 발행자가 송출을 멈춘다.
|
||||
const r = new Room({ dynacast: true })
|
||||
// 참여자 목록(배열) 재생성이 필요한 이벤트만 구독한다.
|
||||
// 발화(ActiveSpeakers)·연결품질(ConnectionQuality)은 매우 잦게 발생하는데
|
||||
// 각 타일이 ParticipantEvent 로 자체 갱신하므로, 여기서 전체 배열을 재생성하면
|
||||
// 대규모(수십 명) 화면에서 불필요한 리렌더로 버벅임을 유발한다 → 제외.
|
||||
r.on(RoomEvent.ParticipantConnected, onParticipantsChanged)
|
||||
.on(RoomEvent.ParticipantDisconnected, onParticipantsChanged)
|
||||
.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
|
||||
.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
|
||||
.on(RoomEvent.TrackMuted, refresh)
|
||||
.on(RoomEvent.TrackUnmuted, refresh)
|
||||
.on(RoomEvent.LocalTrackPublished, refresh)
|
||||
.on(RoomEvent.LocalTrackUnpublished, refresh)
|
||||
.on(RoomEvent.ParticipantMetadataChanged, onParticipantsChanged)
|
||||
.on(RoomEvent.DataReceived, onData)
|
||||
.on(RoomEvent.Disconnected, onDisconnected)
|
||||
|
||||
await r.connect(LIVEKIT_URL, token)
|
||||
room.value = r
|
||||
connected.value = true
|
||||
|
||||
// 카메라/마이크 권한 요청 및 발행 (실패해도 연결은 유지)
|
||||
if (wantCam || wantMic) {
|
||||
try {
|
||||
if (wantCam && wantMic) {
|
||||
await r.localParticipant.enableCameraAndMicrophone()
|
||||
} else {
|
||||
if (wantMic) await r.localParticipant.setMicrophoneEnabled(true)
|
||||
if (wantCam) await r.localParticipant.setCameraEnabled(true)
|
||||
}
|
||||
camEnabled.value = wantCam
|
||||
micEnabled.value = wantMic
|
||||
} catch {
|
||||
errorMsg.value =
|
||||
'카메라/마이크를 사용할 수 없습니다. 권한을 확인해 주세요.'
|
||||
}
|
||||
} else {
|
||||
// 권한 거부 입장 — 영상·음성 없이 참관
|
||||
errorMsg.value =
|
||||
'카메라·마이크 없이 참여 중입니다. 아래 컨트롤에서 언제든 켤 수 있습니다.'
|
||||
}
|
||||
refresh()
|
||||
} catch (e) {
|
||||
// 연결 실패 원인을 콘솔에 남긴다(이전엔 errorMsg 로만 삼켜져 화면·콘솔 어디에도 안 보였다).
|
||||
console.error('[meeting] LiveKit 연결 실패:', e)
|
||||
errorMsg.value = e instanceof Error ? e.message : '연결에 실패했습니다.'
|
||||
await disconnect()
|
||||
} finally {
|
||||
connecting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 방 나가기
|
||||
async function disconnect(): Promise<void> {
|
||||
const r = room.value
|
||||
if (r) await r.disconnect()
|
||||
room.value = null
|
||||
connected.value = false
|
||||
camEnabled.value = false
|
||||
micEnabled.value = false
|
||||
participants.value = []
|
||||
featuredIdentity.value = null
|
||||
messages.value = []
|
||||
cleanupAudio()
|
||||
}
|
||||
|
||||
async function toggleMic(): Promise<void> {
|
||||
const r = room.value
|
||||
if (!r) return
|
||||
const next = !micEnabled.value
|
||||
micEnabled.value = next
|
||||
try {
|
||||
await r.localParticipant.setMicrophoneEnabled(next)
|
||||
} catch {
|
||||
micEnabled.value = !next // 실패 시 상태 원복(표시와 실제 송출 불일치 방지)
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
|
||||
async function toggleCam(): Promise<void> {
|
||||
const r = room.value
|
||||
if (!r) return
|
||||
const next = !camEnabled.value
|
||||
camEnabled.value = next
|
||||
try {
|
||||
await r.localParticipant.setCameraEnabled(next)
|
||||
} catch {
|
||||
camEnabled.value = !next
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
|
||||
return {
|
||||
participants,
|
||||
connected,
|
||||
connecting,
|
||||
micEnabled,
|
||||
camEnabled,
|
||||
errorMsg,
|
||||
featuredIdentity,
|
||||
messages,
|
||||
connect,
|
||||
disconnect,
|
||||
toggleMic,
|
||||
toggleCam,
|
||||
promote,
|
||||
sendChat,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useApi } from './useApi'
|
||||
import type {
|
||||
ApiMeetingRoom,
|
||||
CreateMeetingRoomPayload,
|
||||
UpdateMeetingRoomPayload,
|
||||
} from '@/types/meeting'
|
||||
|
||||
// 회의 내 역할 — 진행자(방 생성자) / 일반 참여자
|
||||
export type MeetingRole = 'host' | 'participant'
|
||||
|
||||
// 화상회의 토큰 응답 — 백엔드 MeetingTokenResult 와 1:1
|
||||
export interface MeetingToken {
|
||||
token: string // LiveKit 입장 JWT
|
||||
roomName: string
|
||||
identity: string // 참여자 식별자(사용자 ID)
|
||||
role: MeetingRole // 이번 입장자의 역할
|
||||
}
|
||||
|
||||
// 화상회의 도메인 API Composable — 인터셉터가 success/data 를 언래핑
|
||||
export function useMeeting() {
|
||||
const api = useApi()
|
||||
|
||||
// LiveKit 입장 토큰 발급 — 방 입장 코드로 요청한다.
|
||||
async function getToken(code: string): Promise<MeetingToken> {
|
||||
return (await api.post('/meetings/token', {
|
||||
code,
|
||||
})) as unknown as MeetingToken
|
||||
}
|
||||
|
||||
// 방 목록
|
||||
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 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, updateRoom, removeRoom }
|
||||
}
|
||||
@@ -31,5 +31,18 @@ export function useNotification() {
|
||||
)) as unknown as UnreadCountResult
|
||||
}
|
||||
|
||||
return { list, markRead, markAllRead }
|
||||
// 웹 푸시 구독 등록(브라우저 PushSubscription)
|
||||
async function subscribePush(body: {
|
||||
endpoint: string
|
||||
keys: { p256dh: string; auth: string }
|
||||
}): Promise<void> {
|
||||
await api.post('/notifications/push/subscribe', body)
|
||||
}
|
||||
|
||||
// 웹 푸시 구독 해제
|
||||
async function unsubscribePush(endpoint: string): Promise<void> {
|
||||
await api.post('/notifications/push/unsubscribe', { endpoint })
|
||||
}
|
||||
|
||||
return { list, markRead, markAllRead, subscribePush, unsubscribePush }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useApi } from './useApi'
|
||||
import type {
|
||||
ApiProject,
|
||||
ApiProjectDriveLink,
|
||||
ApiProjectDocument,
|
||||
CreateProjectPayload,
|
||||
ProjectListQuery,
|
||||
UpdateProjectPayload,
|
||||
@@ -41,5 +43,53 @@ export function useProject() {
|
||||
await api.delete(`/projects/${id}`)
|
||||
}
|
||||
|
||||
return { list, get, create, update, remove }
|
||||
// 연동된 드라이브 폴더 목록
|
||||
async function listDriveLinks(
|
||||
projectId: string,
|
||||
): Promise<ApiProjectDriveLink[]> {
|
||||
return (await api.get(
|
||||
`/projects/${projectId}/drive-links`,
|
||||
)) as unknown as ApiProjectDriveLink[]
|
||||
}
|
||||
|
||||
// 드라이브 폴더 연동(관리자, 본인 소유 폴더만)
|
||||
async function linkDriveFolder(
|
||||
projectId: string,
|
||||
folderId: string,
|
||||
permission?: 'read' | 'read-write',
|
||||
): Promise<ApiProjectDriveLink> {
|
||||
return (await api.post(`/projects/${projectId}/drive-links`, {
|
||||
folderId,
|
||||
...(permission ? { permission } : {}),
|
||||
})) as unknown as ApiProjectDriveLink
|
||||
}
|
||||
|
||||
// 드라이브 폴더 연동 해제(관리자)
|
||||
async function unlinkDriveFolder(
|
||||
projectId: string,
|
||||
linkId: string,
|
||||
): Promise<void> {
|
||||
await api.delete(`/projects/${projectId}/drive-links/${linkId}`)
|
||||
}
|
||||
|
||||
// AI 검토 대상 문서 목록(연동 폴더 내 파일)
|
||||
async function listDocuments(
|
||||
projectId: string,
|
||||
): Promise<ApiProjectDocument[]> {
|
||||
return (await api.get(
|
||||
`/projects/${projectId}/documents`,
|
||||
)) as unknown as ApiProjectDocument[]
|
||||
}
|
||||
|
||||
return {
|
||||
list,
|
||||
get,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
listDriveLinks,
|
||||
linkDriveFolder,
|
||||
unlinkDriveFolder,
|
||||
listDocuments,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { ref } from 'vue'
|
||||
import { useNotification } from '@/composables/useNotification'
|
||||
|
||||
// VAPID 공개키(base64url) → Uint8Array (applicationServerKey 형식)
|
||||
// ArrayBuffer 백킹으로 명시(BufferSource 타입 충족)
|
||||
function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
|
||||
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
|
||||
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
|
||||
const raw = atob(base64)
|
||||
const arr = new Uint8Array(new ArrayBuffer(raw.length))
|
||||
for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i)
|
||||
return arr
|
||||
}
|
||||
|
||||
// 푸시 켜기 결과 — UI 가 사유별 안내를 띄울 수 있도록 상태로 반환
|
||||
export type PushResult =
|
||||
| 'subscribed' // 구독 성공
|
||||
| 'denied' // 브라우저가 알림을 차단함
|
||||
| 'dismissed' // 사용자가 권한 팝업을 닫음(미결정 유지)
|
||||
| 'unsupported' // 미지원/비보안 컨텍스트
|
||||
| 'no-key' // 서버 VAPID 공개키 미설정
|
||||
| 'error' // 그 외 실패(SW/구독)
|
||||
|
||||
// 웹 푸시(Level2) 구독 관리 — 권한 요청 + PushManager 구독 + 백엔드 등록/해제.
|
||||
export function usePush() {
|
||||
const api = useNotification()
|
||||
const supported =
|
||||
typeof navigator !== 'undefined' &&
|
||||
'serviceWorker' in navigator &&
|
||||
typeof window !== 'undefined' &&
|
||||
'PushManager' in window &&
|
||||
typeof Notification !== 'undefined'
|
||||
|
||||
// 현재 권한 상태(UI 토글용)
|
||||
const permission = ref<NotificationPermission>(
|
||||
supported ? Notification.permission : 'denied',
|
||||
)
|
||||
|
||||
// 서비스워커 등록 확보 — 명시적 register 로 .ready 무한대기를 피한다.
|
||||
// 활성화 전이면 최대 5초만 기다린다(그래도 없으면 그대로 진행 — subscribe 가 처리).
|
||||
async function getRegistration(): Promise<ServiceWorkerRegistration | null> {
|
||||
if (!supported) return null
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.register('/sw.js')
|
||||
if (!reg.active) {
|
||||
await Promise.race([
|
||||
navigator.serviceWorker.ready,
|
||||
new Promise((resolve) => setTimeout(resolve, 5000)),
|
||||
])
|
||||
}
|
||||
return reg
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 권한 요청(필요 시) + 푸시 구독 + 백엔드 등록. 결과 상태를 반환(UI 안내용).
|
||||
// silent=true 면 이미 granted 인 경우에만 진행(권한 팝업 띄우지 않음 — 자동 재구독용).
|
||||
async function enablePush(silent = false): Promise<PushResult> {
|
||||
if (!supported) return 'unsupported'
|
||||
const key = import.meta.env.VITE_VAPID_PUBLIC_KEY
|
||||
if (!key) return 'no-key'
|
||||
|
||||
let perm = Notification.permission
|
||||
if (perm !== 'granted') {
|
||||
if (silent) return 'denied'
|
||||
perm = await Notification.requestPermission()
|
||||
}
|
||||
permission.value = perm
|
||||
if (perm === 'denied') return 'denied'
|
||||
if (perm !== 'granted') return 'dismissed'
|
||||
|
||||
const reg = await getRegistration()
|
||||
if (!reg) return 'error'
|
||||
try {
|
||||
let sub = await reg.pushManager.getSubscription()
|
||||
if (!sub) {
|
||||
sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(key),
|
||||
})
|
||||
}
|
||||
const json = sub.toJSON()
|
||||
await api.subscribePush({
|
||||
endpoint: sub.endpoint,
|
||||
keys: {
|
||||
p256dh: json.keys?.p256dh ?? '',
|
||||
auth: json.keys?.auth ?? '',
|
||||
},
|
||||
})
|
||||
return 'subscribed'
|
||||
} catch {
|
||||
return 'error'
|
||||
}
|
||||
}
|
||||
|
||||
// 구독 해제(로그아웃 등) — 백엔드 삭제 + 브라우저 구독 해지
|
||||
async function disablePush(): Promise<void> {
|
||||
if (!supported) return
|
||||
const reg = await getRegistration()
|
||||
if (!reg) return
|
||||
try {
|
||||
const sub = await reg.pushManager.getSubscription()
|
||||
if (sub) {
|
||||
await api.unsubscribePush(sub.endpoint)
|
||||
await sub.unsubscribe()
|
||||
}
|
||||
} catch {
|
||||
// 해제 실패 무시
|
||||
}
|
||||
}
|
||||
|
||||
return { supported, permission, enablePush, disablePush }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user