commit da39cfeef98b908e3182e92d48463fc817bcf009 Author: dsyoon Date: Fri Apr 3 20:45:17 2026 +0900 Initial commit: AI platform app (server, views, lib, data, deploy docs) Made-with: Cursor diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..be0ae44 --- /dev/null +++ b/.env.example @@ -0,0 +1,72 @@ +# DEV: 개발 | PROD: 운영(임직원 이메일 로그인, 구 REAL) | SUPER: 데모·제한 완화 +OPS_STATE=DEV + +PORT=8030 +# HTTP 수신 주소 (기본 0.0.0.0 = 모든 인터페이스, 로컬만이면 127.0.0.1) +HOST=0.0.0.0 +ADMIN_TOKEN=xavis-admin +# --- OPS_STATE=PROD: 매직 링크 이메일 (앱 서버가 아웃바운드로 TCP 연결 가능한 SMTP만 동작) +# BASE_URL=https://실제-도메인 +# AUTH_SECRET=운영용-비밀값 +# 사내 전용 게이트웨이(gw.* 등)는 클라우드에서 587이 ECONNREFUSED로 막히는 경우가 많음 → +# Google Workspace SMTP 릴레이(smtp-relay.gmail.com + 발신 IP 허용), SendGrid, SES 등 사용 권장. +# SMTP_HOST=smtp-relay.gmail.com +# SMTP_PORT=587 +# SMTP_SECURE=0 +# SMTP_USER= +# SMTP_PASS= +# SMTP_FROM=noreply@xavis.co.kr +# 선택: 587에서 STARTTLS 강제(기본 on). 특수 서버만 0 +# SMTP_REQUIRE_TLS=1 +# 이메일 로그인 세션 만료: 해당 일자 23:59:59까지(달력은 OPS_SESSION_TZ 기준, 기본 Asia/Seoul) +# OPS_SESSION_TZ=Asia/Seoul +PAGE_SIZE=9 +# 학습센터 동영상 파일 업로드 최대 크기(MB, 기본 500). 리버스 프록시(Nginx 등)의 client_max_body_size도 같이 늘려야 합니다. +LECTURE_VIDEO_MAX_MB=500 +# 1=PostgreSQL 단일 소스, 0=data/lectures.json 사용 +ENABLE_POSTGRES=1 +DB_HOST=your-db-host +DB_PORT=5432 +DB_DATABASE=your_database +DB_USERNAME=your_user +DB_PASSWORD=your_password +# DB 연결이 없거나 실패하면 회의록 AI는 data/meeting-ai.json에 저장됩니다(로컬 개발에 유용). +# 회의 음성 업로드 최대 크기(MB, 기본 300). OpenAI 전사 API는 요청당 약 25MB이므로 초과분은 서버에서 ffmpeg로 분할 후 전사합니다. +MEETING_AUDIO_MAX_MB=300 +# 회의록 저장 후 OpenAI JSON으로 체크리스트 추출 → 업무 체크리스트 자동 반영 (1=기본, 0=비활성) +MEETING_AUTO_CHECKLIST=1 +# 추출 시 회의록 본문 최대 길이(문자). 긴 경우 끝부분(체크리스트가 뒤에 있을 때)만 사용 +MEETING_CHECKLIST_EXTRACT_MAX_CHARS=24000 +ENABLE_PPT_THUMBNAIL=1 +THUMBNAIL_WIDTH=1000 +THUMBNAIL_MAX_RETRY=2 +THUMBNAIL_RETRY_DELAY_MS=5000 +THUMBNAIL_EVENT_KEEP=200 +THUMBNAIL_EVENT_PAGE_SIZE=50 + +# 채팅 기능용 API 키 +# OpenAI: https://platform.openai.com/api-keys +OPENAI_API_KEY= +# 선택: UI의 gpt-5.4 / gpt-5-mini에 대응하는 실제 Chat Completions 모델 ID (미설정 시 gpt-4o / gpt-4o-mini) +# OPENAI_MODEL_DEFAULT=gpt-4o +# OPENAI_MODEL_MINI=gpt-4o-mini +# OpenAI Responses API 내장 웹 검색(기본 on). 끄려면 아래 주석 해제 후 0 +# OPENAI_WEB_SEARCH=0 +# 웹 검색 위치 힌트(선택) +# OPENAI_WEB_SEARCH_COUNTRY=KR +# OPENAI_WEB_SEARCH_CITY= +# OPENAI_WEB_SEARCH_REGION= +# OPENAI_WEB_SEARCH_TIMEZONE=Asia/Seoul +# Anthropic Claude (claude-*): https://console.anthropic.com/ +CLAUDE_API_KEY= +# Google Gemini (gemini-*): https://aistudio.google.com/apikey +GENAI_API_KEY= + +# OPS_STATE=DEV + 관리자 토큰일 때 회의록 AI 등에 쓸 가상 사용자 이메일 (미설정 시 dev@xavis.co.kr) +# MEETING_DEV_EMAIL=you@example.com +# SUPER 모드에서 회의록·체크리스트용 데모 사용자 이메일 (미설정 시 MEETING_DEV_EMAIL 또는 demo@xavis.local) +# MEETING_SUPER_EMAIL=demo@xavis.local +# 회의록 음성 전사 기본 모델 (미설정 시 gpt-4o-mini-transcribe) +# OPENAI_WHISPER_MODEL=gpt-4o-mini-transcribe +# gpt-4o 전사 API: 요청당 오디오 토큰 한도 → ffmpeg 분할 길이(초, 15~600, 기본 120). 한도 오류 시 30 또는 15 +# OPENAI_TRANSCRIBE_SEGMENT_SEC=30 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac76504 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +node_modules/ +.DS_Store + +# 환경 비밀값 — 저장소에는 .env.example 만 공유 +.env +.env.ncue +*.log +uploads/ +.tmp-pdftest/ +package-lock.json + +# data: 임시 작업 디렉터리만 제외 (JSON·마크다운 등 주요 데이터는 버전 관리) +data/tmp/ +# 매직 링크 토큰(OPS PROD) +data/ops-magic-links.json +# 회의록 AI 로컬 폴백(사용자별 프롬프트·저장 회의) +data/meeting-ai.json +data/meeting-ai-checklist.json +# PPT 썸네일 이벤트 로그(PG 미사용 시 폴백·마이그레이션 백업) +data/thumbnail-events.json +data/thumbnail-events.json.migrated.bak diff --git a/README.md b/README.md new file mode 100644 index 0000000..7ae8cd8 --- /dev/null +++ b/README.md @@ -0,0 +1,514 @@ +# Web Platform (학습센터) + +유튜브 링크와 PowerPoint(`.pptx`) 강의를 등록하고, 목록에서 클릭해 바로 시청할 수 있는 사내 학습센터 예제입니다. + +--- + +## 접속 방법 + +**브라우저에서 열기 전에 반드시 터미널에서 서버를 실행해야 합니다.** (`npm start` 미실행 상태에서는 `http://localhost:8030`에 연결되지 않습니다.) + +서버 실행 후 아래 주소로 접속합니다. + +| 환경 | URL | +|------|-----| +| 로컬 기본 | [http://localhost:8030](http://localhost:8030) | +| 포트 변경 시 | `http://localhost:{PORT}` (예: `PORT=4000 npm start` → `http://localhost:4000`) | + +- **메인 페이지** (`/`): 학습센터 뷰어 (강의 목록) +- **학습센터 뷰어** (`/learning`): 강의 검색/필터 + 카드 목록 (일반 사용자) +- **관리자** (`/admin`): 강의 등록(YouTube/PPT/웹 링크 등), 썸네일·AI 추가 등 통합 관리 +- **강의 상세**: 카드 클릭 시 유튜브 재생 또는 PPT 뷰어 +- **기타 메뉴**: 채팅(`/chat`), AI(`/ai-explore`), **프롬프트 라이브러리**(`/ai-explore/prompts`), **AI 성공 사례**(`/ai-cases`), AX 과제 신청(`/ax-apply`) +- **관리자 이벤트 로그**: `http://localhost:8030/admin/thumbnail-events?token={ADMIN_TOKEN}` + +### 접속이 안 될 때 (트러블슈팅) + +1. **서버가 떠 있는지 확인** + 프로젝트 루트에서 `npm start`를 실행하고, 터미널에 `Server started: http://localhost:8030` 같은 로그가 나오는지 확인합니다. +2. **포트 번호 확인** + `.env`에 `PORT=...`가 있으면 **그 포트**로 접속합니다. (기본값은 `8030`) +3. **부팅 실패** + 터미널에 `Failed to bootstrap:`이 나오면 프로세스가 종료되며 HTTP 서버가 뜨지 않습니다. 메시지를 확인한 뒤 `data/` 쓰기 권한, 디스크 여유, DB 설정 등을 점검합니다. +4. **포트 충돌** + 다른 프로그램이 8030을 쓰는 경우 `PORT=8031 npm start`처럼 바꿔 실행하거나, **이미 떠 있는 Node 서버 프로세스를 종료**합니다. 구체적인 명령은 아래 **실행 방법** 절의 **기존 서버 프로세스 종료** 항목을 참고하세요. +5. **바인드 주소** + 기본은 모든 네트워크 인터페이스(`0.0.0.0`)에서 수신합니다. 로컬 루프백만 쓰려면 `HOST=127.0.0.1 npm start`를 사용할 수 있습니다. + +--- + +## 핵심 기능 + +- 학습센터 UI (좌측 메뉴 + 상단 헤더 + 강의 카드 레이아웃) +- **AI 탐색** (`/ai-explore`): 전체 너비 레이아웃, AI 서비스 카드(프롬프트·회의록 등). 검색창에 **「프롬프트」**가 포함된 채 검색(Enter) 시 프롬프트 라이브러리로 이동 +- **프롬프트 라이브러리** (`/ai-explore/prompts`): 업무별 기본 프롬프트 카드 선택·미리보기·클립보드 복사 (`data/company-prompts.json`). **좌측 메뉴(채팅·AI·AI 성공 사례·학습센터·AX 과제 신청)는 관리자 여부와 관계없이 접근 가능**(강의 삭제·관리자 대시보드 등 일부 기능은 관리자 모드에서만) +- 검색/필터/페이지네이션 + - 검색어(`q`) 기반 제목/설명/태그 필터 + - 타입(`YouTube`, `PPT`) 필터 + - 태그 필터 + 페이지네이션 +- 유튜브 강의 등록/시청 + - 유튜브 URL 입력 후 목록에 추가 + - 강의 상세에서 iframe 임베드로 재생 +- PPT 강의 등록/시청 + - `.pptx` 파일 업로드 + - 상세에서 슬라이드 텍스트 기반 뷰어 제공 + - 목록 카드에 PPT 프리뷰(첫 슬라이드 제목 + 장수) 표시 + - macOS 환경에서는 `qlmanage` 기반 실제 썸네일(첫 장 이미지) 자동 생성 +- 썸네일 백그라운드 큐 + - 썸네일 생성은 비동기 큐에서 처리 + - 상태값: `pending` / `processing` / `ready` / `failed` + - 실패 시 자동 재시도 정책 적용(최대 횟수 이후 `failed` 고정) + - 큐 스냅샷을 `data/thumbnail-jobs.json`에 저장해 재시작 후 복구 + - 작업 이벤트를 `data/thumbnail-events.json`에 기록 +- 관리자 삭제 + - 관리자 토큰으로 강의 삭제 가능 +- 초기 샘플 데이터 시드 + - `resources/lecture`에 있는 `.pptx`를 최초 실행 시 자동 등록 + +--- + +## 프로젝트 구조 + +```text +webplatform/ +├─ server.js # Express 서버 진입점 (라우팅, 업로드, 썸네일 큐, DB 연동) +├─ package.json # 의존성 및 npm 스크립트 +├─ .env # 환경 변수 (실제 값, .gitignore 대상) +├─ .env.example # 환경 변수 예시 템플릿 +├─ db/ +│ └─ schema.sql # PostgreSQL lectures 테이블 스키마 (서버 기동 시 자동 적용) +├─ scripts/ +│ └─ apply-schema.js # 수동 스키마 적용 스크립트 (npm run db:schema) +├─ public/ +│ └─ styles.css # 전역 스타일 +├─ views/ +│ ├─ partials/ +│ │ └─ nav.ejs # 좌측 공통 네비게이션 +│ ├─ learning-viewer.ejs # 학습센터 뷰어 (일반 사용자) +│ ├─ learning-admin.ejs # 학습센터 관리 (업로드·삭제·썸네일) +│ ├─ chat.ejs # 채팅 +│ ├─ ai-explore.ejs # AI 탐색 (전체 너비, 프롬프트 카드·검색) +│ ├─ ai-prompts.ejs # 프롬프트 라이브러리 (카드·미리보기·복사) +│ ├─ ai-cases.ejs # AI 성공 사례 목록(카드) +│ ├─ ai-case-detail.ejs # AI 성공 사례 상세(마크다운) +│ ├─ ai-cases-write.ejs # AI 성공 사례 관리자 등록·편집 +│ ├─ ax-apply.ejs # AX 과제 신청 +│ ├─ lecture-youtube.ejs # 유튜브 강의 상세 (iframe 임베드) +│ ├─ lecture-ppt.ejs # PPT 강의 상세 (슬라이드 텍스트 뷰어) +│ └─ admin-thumbnail-events.ejs # 썸네일 이벤트 로그 관리자 페이지 +├─ data/ +│ ├─ lectures.json # 강의 메타데이터 (ENABLE_POSTGRES=0 또는 DB 폴백 시) +│ ├─ company-prompts.json # AI 프롬프트 라이브러리 템플릿 (제목·설명·본문) +│ ├─ thumbnail-jobs.json # 썸네일 큐 스냅샷 (재시작 후 복구용) +│ └─ thumbnail-events.json # 썸네일 작업 이벤트 로그 +├─ uploads/ # 업로드된 .pptx 파일 저장 +│ └─ thumbnails/ # 생성된 썸네일 이미지 +└─ resources/ + └─ lecture/ # 초기 시드용 샘플 PPT (최초 실행 시 자동 등록) +``` + +### 구조 설명 + +| 구분 | 설명 | +|------|------| +| **서버** | `server.js`가 Express 앱, 라우트, Multer 업로드, 썸네일 백그라운드 워커, PostgreSQL 연동을 모두 담당 | +| **데이터 저장소** | `ENABLE_POSTGRES=1`이면 PostgreSQL `lectures` 테이블이 단일 소스, `0`이면 `data/lectures.json` 사용 | +| **썸네일** | 비동기 큐 처리, `data/thumbnail-jobs.json`으로 영속화, `data/thumbnail-events.json`에 이벤트 기록 | +| **뷰** | EJS 템플릿으로 메인/유튜브/PPT/관리자 페이지 렌더링 | + +--- + +## 서버 배포 (새 머신·처음부터) + +아래는 **저장소를 처음 받아 운영 서버에 올리는 경우**를 가정한 절차입니다. **macOS**(개발용·소규모 호스팅)와 **Linux**(일반적인 VPS·온프레미스 서버)를 구분했습니다. + +### 공통 사전 요구사항 + +| 항목 | 설명 | +|------|------| +| **Node.js** | **v18 이상** 권장 (`node -v`로 확인) | +| **npm** | Node와 함께 설치 | +| **Git** | 저장소 클론용 | +| **PostgreSQL** | `ENABLE_POSTGRES=1` 사용 시 접속 가능한 DB(로컬 설치 또는 원격) | +| **환경 변수** | `.env.example`을 복사해 `.env` 작성(비밀번호·토큰은 반드시 변경) | + +PPT 썸네일·슬라이드 이미지는 **macOS**에서는 `qlmanage`가 우선 사용되고, 그 외에는 **LibreOffice**·**poppler(`pdftoppm`)** 조합이 필요합니다. 도구가 없으면 텍스트 프리뷰로 동작합니다. + +--- + +### macOS에서 배포 (새 머신) + +1. **도구 설치** + - **Node.js**: [nodejs.org](https://nodejs.org/) LTS 설치, 또는 `brew install node@20` 등 + - **Git**: Xcode Command Line Tools(`xcode-select --install`) 또는 `brew install git` + - **PostgreSQL**(로컬 DB를 쓸 때): `brew install postgresql@16` 후 서비스 기동, 또는 원격 DB만 사용 + - **PPT 변환 강화(선택)**: `brew install --cask libreoffice`, `brew install poppler` + +2. **코드 받기** + ```bash + mkdir -p ~/apps && cd ~/apps + git clone <저장소 URL> webplatform + cd webplatform + ``` + +3. **의존성·환경** + ```bash + npm install + cp .env.example .env + # 편집기로 .env 수정: PORT, ADMIN_TOKEN, DB_*, ENABLE_POSTGRES 등 + ``` + +4. **DB 스키마** (`ENABLE_POSTGRES=1`일 때) + ```bash + npm run db:schema + ``` + +5. **실행 방식 선택** + - **포그라운드(테스트)**: `npm start` → 터미널에 `Server started: http://localhost:8030` 확인 후 브라우저 접속 + - **백그라운드(운영에 가깝게)**: `npm install -g pm2` 후 + `pm2 start server.js --name webplatform && pm2 save` + +6. **접속** + 같은 Mac에서: `http://127.0.0.1:8030` (또는 `.env`의 `PORT`). 다른 기기에서 접속하려면 `HOST=0.0.0.0`이 기본이므로, **macOS 방화벽**에서 Node 허용 여부를 확인합니다. + +--- + +### Linux에서 배포 (새 서버) + +배포 경로는 예시로 `/var/www/webplatform`을 둡니다. 배포 사용자·그룹(`www-data` 등)은 배포 정책에 맞게 조정하세요. + +1. **시스템 패키지** (Ubuntu/Debian 예시) + ```bash + sudo apt update + sudo apt install -y git build-essential + ``` + - **Node.js**: 배포판 기본 패키지가 오래된 경우가 많으므로 **[NodeSource](https://github.com/nodesource/distributions)** 또는 **nvm**으로 **v18+** 설치를 권장합니다. + - **PostgreSQL 클라이언트/서버**: 원격 DB만 쓰면 클라이언트 라이브러리만으로도 되고, 로컬 DB면 `postgresql` 패키지 설치 후 DB·사용자 생성. + - **PPT 변환(선택)**: `sudo apt install -y libreoffice poppler-utils` + +2. **코드 배치** + ```bash + sudo mkdir -p /var/www + cd /var/www + sudo git clone <저장소 URL> webplatform + cd webplatform + sudo chown -R "$USER:$USER" /var/www/webplatform # 이후 작업 사용자에 맞게 조정 + ``` + +3. **의존성·환경** + ```bash + npm install --production + cp .env.example .env + nano .env # 또는 vim — PORT, ADMIN_TOKEN, DB_*, ENABLE_POSTGRES 등 + ``` + +4. **DB 스키마** (`ENABLE_POSTGRES=1`일 때) + ```bash + npm run db:schema + ``` + +5. **프로세스 관리 (PM2 권장)** + ```bash + sudo npm install -g pm2 + cd /var/www/webplatform + pm2 start server.js --name webplatform + pm2 save + pm2 startup # 부팅 시 자동 기동(출력되는 sudo 명령 실행) + ``` + +6. **방화벽·리버스 프록시** + - 외부에 직접 `8030`을 열지 않고 **Nginx/Apache**로 TLS·프록시하는 구성이 일반적입니다. + - **Apache2** 예시(모듈·VirtualHost)는 [docs/DEPLOYMENT-xavis.ncue.net.md](docs/DEPLOYMENT-xavis.ncue.net.md)를 참고하세요. + - `ufw` 사용 시: `sudo ufw allow 80,443/tcp` 후 앱은 로컬에서만 듣게 하거나, 프록시 뒤에 둡니다. + +7. **데이터 디렉터리 권한** + `data/`, `uploads/` 등에 앱 실행 사용자(예: `pm2`로 띄운 사용자, 또는 `www-data`)가 쓰기 가능해야 합니다. 실패 시 로그에 `Failed to bootstrap` 또는 권한 오류가 납니다. + +--- + +### 배포 후 확인 + +- 터미널 또는 `pm2 logs webplatform`에서 **에러 없이** `Server started` 로그 확인 +- 브라우저로 메인·`/learning`·관리자(토큰)까지 동작 확인 +- PostgreSQL 사용 시 `npm run db:schema`는 **최초 1회**(또는 스키마 변경 시); 백업·마이그레이션 정책은 운영 환경에 맞게 별도 수립 + +--- + +## 실행 방법 + +**로컬에서 빠르게 띄우기**는 아래 순서면 됩니다. **새 서버에 맞춰 처음부터 배포**할 때는 위 「서버 배포 (새 머신·처음부터)」의 **macOS** 또는 **Linux** 절을 따르세요. + +## 1) 의존성 설치 + +```bash +npm install +``` + +## 2) PostgreSQL 스키마 적용 + +```bash +npm run db:schema +``` + +## 3) 서버 실행 + +프로젝트 루트에서: + +```bash +npm start +``` + +정상 기동 시 터미널에 `Server started: http://localhost:8030` (또는 설정한 `PORT`)가 출력됩니다. **이 상태에서만** 브라우저로 접속할 수 있습니다. + +운영/배포 환경에서는 예: + +```bash +# PM2 사용 시 +pm2 restart webplatform +``` + +```bash +node server.js +``` + +- 기본 포트: `8030` +- 접속 URL: [http://localhost:8030](http://localhost:8030) + +### 포트·수신 주소 변경 + +```bash +PORT=8030 npm start +``` + +```bash +# 로컬 루프백만 (외부 네트워크 인터페이스에 바인딩하지 않음) +HOST=127.0.0.1 npm start +``` + +서버는 기본적으로 `HOST=0.0.0.0`으로 바인딩합니다(동일 기기의 `localhost` 접속에 사용). + +### 기존 서버 프로세스 종료 + +터미널을 닫았는데도 이전에 실행한 `node server.js`가 남아 있거나, **포트가 이미 사용 중(`EADDRINUSE`)**이라 새로 `npm start`가 실패할 때, 기존 프로세스를 먼저 종료합니다. + +**포트로 PID 확인 후 종료 (macOS / Linux, 기본 포트 8030 예시)** + +```bash +lsof -i :8030 +``` + +출력의 `PID` 열 값을 확인한 뒤: + +```bash +kill PID +``` + +응답이 없으면 `kill -9 PID`로 강제 종료할 수 있으나, 다른 Node 작업이 같은 포트를 쓰는지 확인한 뒤 사용하세요. + +**프로젝트 진입점만 대상으로 종료 (다른 `node` 작업에 영향을 줄 수 있으니 주의)** + +```bash +pkill -f "node server.js" +``` + +**PM2로 띄운 경우** + +```bash +pm2 list +pm2 stop webplatform +# 완전히 제거하려면 +pm2 delete webplatform +``` + +Windows에서는 작업 관리자에서 `Node.js` 프로세스를 종료하거나, PowerShell에서 `Get-NetTCPConnection -LocalPort 8030` 등으로 점유 프로세스를 확인한 뒤 해당 PID를 종료합니다. + +### 관리자 토큰/페이지 크기 설정(선택) + +```bash +ADMIN_TOKEN=my-secret PAGE_SIZE=12 npm start +``` + +- `ADMIN_TOKEN` 미지정 시 기본값: `xavis-admin` +- `PAGE_SIZE` 미지정 시 기본값: `8` +- `.env` 파일이 있으면 `dotenv`로 자동 로드 + +### PostgreSQL 연결 설정 + +- `ENABLE_POSTGRES=1`일 때: PostgreSQL이 **단일 소스**로 사용됩니다. `data/lectures.json`은 사용하지 않습니다. +- `ENABLE_POSTGRES=0`일 때: `data/lectures.json`만 사용합니다. +- DB 연결 실패 시 자동으로 `data/lectures.json` 기반 파일 저장소로 폴백합니다. +- 필수 변수: `DB_HOST`, `DB_PORT`, `DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD` + +### 채팅 기능 (OpenAI API) + +- `.env`에 `OPENAI_API_KEY`를 넣은 뒤 **서버를 재시작**하면 채팅 메뉴(`/chat`)에서 OpenAI와 실제 대화가 이루어집니다. +- API 키 발급: [OpenAI Platform](https://platform.openai.com/api-keys) +- 키 값 앞뒤 공백은 자동으로 제거합니다. +- 화면의 모델 선택(`gpt-5.4`, `gpt-5-mini`)은 내부적으로 OpenAI에 전달할 **실제 모델 ID**로 매핑됩니다. 기본값은 각각 `gpt-4o`, `gpt-4o-mini`이며, 필요하면 `.env`에서 `OPENAI_MODEL_DEFAULT`, `OPENAI_MODEL_MINI`로 바꿀 수 있습니다. +- **웹 검색(기본 활성):** `OPENAI_WEB_SEARCH`가 `0`이 아니면 OpenAI **Responses API**와 내장 **`web_search`** 도구로 질의에 맞게 웹을 검색한 뒤 답변합니다. 끄려면 `OPENAI_WEB_SEARCH=0`으로 두면 이전과 같이 **Chat Completions**만 사용합니다. 선택적으로 `OPENAI_WEB_SEARCH_COUNTRY`(기본 `KR`), `OPENAI_WEB_SEARCH_CITY`, `OPENAI_WEB_SEARCH_REGION`, `OPENAI_WEB_SEARCH_TIMEZONE`(기본 `Asia/Seoul`)로 검색 맥락을 줄 수 있습니다. +- `GET /api/chat/config`는 `{ configured, webSearch }`를 반환합니다(`webSearch`는 위 플래그와 동일). +- 스트리밍(`POST /api/chat/stream`)은 SSE로 `data: {JSON}` 줄을 보냅니다. 타입 예: `delta`(본문 조각), `status`(예: `phase: "web_search"` — UI에서 «웹 검색 중…» 표시), `sources`(인용 URL·제목 목록), `done`, `error`. 비스트리밍 `POST /api/chat`은 필요 시 응답 JSON에 `sources` 배열을 포함할 수 있습니다. +- 키가 비어 있으면 `POST /api/chat`·`POST /api/chat/stream`은 503을 반환하고, 채팅 화면 상단에 안내 배너가 표시됩니다. + +### PPT 썸네일 및 슬라이드 이미지 생성 제어(선택) + +```bash +ENABLE_PPT_THUMBNAIL=1 npm start +``` + +- 기본값: `1` (활성) +- `ENABLE_PPT_THUMBNAIL=0`이면 썸네일·슬라이드 이미지 생성 비활성화 +- 우선순위: macOS `qlmanage` → LibreOffice(`soffice/libreoffice`) + `pdftoppm` +- 도구 미설치 또는 변환 실패 시 텍스트 프리뷰로 자동 폴백 + +**PPT 슬라이드 이미지(뷰어용):** PPTX 파일은 LibreOffice로 PDF 변환 후 `pdftoppm`으로 이미지 생성. +- **PPTX**: LibreOffice 필수. macOS: `brew install --cask libreoffice` +- **PDF**: `pdftoppm`만 있으면 동작 (poppler 패키지) + +### `.env` 예시 + +```env +PORT=8030 +HOST=0.0.0.0 +ADMIN_TOKEN=xavis-admin +PAGE_SIZE=8 +ENABLE_POSTGRES=1 +DB_HOST=your-db-host +DB_PORT=5432 +DB_DATABASE=your_database +DB_USERNAME=your_user +DB_PASSWORD=your_password +ENABLE_PPT_THUMBNAIL=1 +THUMBNAIL_WIDTH=1000 +THUMBNAIL_MAX_RETRY=2 +THUMBNAIL_RETRY_DELAY_MS=5000 +THUMBNAIL_EVENT_KEEP=200 +THUMBNAIL_EVENT_PAGE_SIZE=50 + +# 채팅 기능 (OpenAI API 키) +OPENAI_API_KEY= +# OPENAI_MODEL_DEFAULT=gpt-4o +# OPENAI_MODEL_MINI=gpt-4o-mini +``` + +--- + +## 사용 방법 + +1. 메인 페이지에서 강의 등록 + - **유튜브 강의 등록**: 제목 + 유튜브 링크 (+설명) + - **PowerPoint 강의 등록**: 제목 + `.pptx` 파일 (+설명) + - 두 등록 폼 모두 **태그(쉼표 구분)** 입력 가능 +2. 하단 **등록된 강의** 카드에서 항목 클릭 +3. 강의 상세 화면에서 시청 + - 유튜브: 동영상 재생 + - PPT: 슬라이드 텍스트 목록 확인 + +### 검색/필터 + +- 검색어, 타입, 태그를 조합해서 목록을 조회할 수 있습니다. +- 결과 목록은 페이지 단위로 분할되어 이동 가능합니다. + +### 관리자 삭제 + +1. 화면의 관리자 모드에서 토큰 입력 후 활성화 +2. 강의 카드의 `삭제` 버튼으로 즉시 삭제 +3. PPT 강의 삭제 시 업로드 파일도 함께 제거 시도 + +### 썸네일 재생성(관리자) + +- 관리자 모드에서 PPT 카드의 `썸네일 재생성` 버튼으로 수동 재생성 가능 +- `실패 썸네일 일괄 재시도` 버튼으로 실패 건을 큐에 일괄 재등록 가능 +- `이벤트 로그 페이지`에서 기간/유형/강의ID/사유 필터 조회 가능 +- 이벤트 로그는 CSV 다운로드 지원 + +--- + +## 주요 라우트 + +### 메뉴별 화면 + +| 경로 | 설명 | +|------|------| +| `GET /` | 학습센터 뷰어 (강의 목록) | +| `GET /learning` | 학습센터 뷰어 (검색/필터/페이지네이션) | +| `GET /admin` | 관리자 대시보드 (강의·썸네일·AI 등) | +| `GET /chat` | 채팅 | +| `GET /ai-explore` | AI 탐색 (프롬프트·회의록 등 카드, 전체 너비 레이아웃) | +| `GET /ai-explore/prompts` | 프롬프트 라이브러리 (업무 시나리오별 기본 프롬프트) | +| `GET /ai-cases` | AI 성공 사례 목록(검색·태그 필터, 카드) | +| `GET /ai-cases/:slug` | AI 성공 사례 상세 | +| `GET /ai-cases/write` | AI 성공 사례 관리(관리자 토큰 필요) | +| `POST/PUT/DELETE /api/ai-success-stories` | 사례 CRUD(관리자) · 본문은 `data/ai-success-stories/*.md` | +| `GET /ax-apply` | AX 과제 신청 | +| `GET /lectures/:id` | 강의 상세 뷰어 (유튜브/PPT) | + +### API + +- `GET /api/chat/config` + 채팅용 OpenAI 키 설정 여부 `{ configured: boolean }` (키 문자열은 노출하지 않음) + +- `POST /api/chat` + JSON `{ model, messages }` — OpenAI Chat Completions 연동(비스트리밍). `OPENAI_API_KEY` 필수. + +- `POST /api/chat/stream` + 동일 본문으로 **SSE**(`text/event-stream`) 스트리밍 응답. 이벤트: `data: {"type":"delta","text":"..."}` 조각, 마지막 `{"type":"done"}`. 오류 시 `{"type":"error","error":"..."}`. 채팅 UI는 이 엔드포인트를 사용합니다. + +- `POST /lectures/youtube` + 유튜브 강의 등록 + +- `POST /lectures/ppt` + PPT 강의 등록 (`multipart/form-data`) + +- `POST /lectures/:id/delete` + 관리자 토큰 기반 강의 삭제 + +- `POST /lectures/:id/thumbnail/regenerate` + 관리자 토큰 기반 PPT 썸네일 재생성 + +- `POST /thumbnails/retry-failed` + 관리자 토큰 기반 실패 썸네일 일괄 재시도 큐 등록 + +- `GET /api/queue/metrics?token=...` + 관리자 토큰 기반 큐/상태 메트릭 JSON 조회 + +- `GET /api/queue/events-summary?token=...&hours=24` + 최근 시간대 요약 KPI/시간대별 처리량 JSON 조회 + +- `GET /admin/thumbnail-events?token=...` + 관리자 이벤트 로그 페이지 (필터/페이지네이션) + +- `GET /admin/thumbnail-events.csv?token=...` + 필터 기준 이벤트 로그 CSV 다운로드 + +--- + +## 최근 업데이트 (요약) + +- **AI 탐색** (`/ai-explore`): 메인 콘텐츠를 뷰포트 전체 너비로 사용, **프롬프트** 서비스 카드를 첫 번째에 배치, 검색어에 **「프롬프트」**가 포함된 채 검색(Enter) 시 프롬프트 라이브러리로 이동. **좌측 전체 메뉴는 관리자 여부와 관계없이 동일하게 표시·접근 가능** +- **프롬프트 라이브러리** (`/ai-explore/prompts`): 시나리오 카드·미리보기·복사 UI, 템플릿 데이터는 `data/company-prompts.json`에서 로드 +- **서버 수신 주소**: 기본 `HOST=0.0.0.0`(로컬 `localhost` 접속에 사용). 필요 시 `HOST=127.0.0.1`로 제한 가능 +- **문서**: `localhost` 접속 불가 시 확인할 항목을 아래 「접속이 안 될 때 (트러블슈팅)」 절에 정리 + +--- + +## 기술 스택 + +- Node.js +- Express +- EJS +- PostgreSQL (`pg`) +- Multer (파일 업로드) +- JSZip (`.pptx` 내부 XML 파싱) +- UUID + +--- + +## 구현 참고/제약 + +- PPT 뷰어는 **원본 슬라이드 디자인 렌더링**이 아닌, `.pptx` 내부 텍스트를 추출해 보여주는 방식입니다. +- PPT 썸네일은 시스템 도구 상태에 따라 생성 실패할 수 있으며, 이 경우 이미지 없이 텍스트 프리뷰만 표시됩니다. +- 썸네일 큐는 단일 프로세스 워커 기준으로 동작합니다(다중 인스턴스 분산 락은 미구현). +- 폰트/도형/애니메이션까지 완전 동일 렌더링이 필요하면 별도 문서 렌더러(예: LibreOffice/PDF 변환 파이프라인) 연동이 필요합니다. +- 이벤트 로그 페이지의 실시간 그래프는 클라이언트 폴링 기반이며, 다수 접속 시 폴링 주기 조정이 필요할 수 있습니다. + diff --git a/data/ai-success-stories.json b/data/ai-success-stories.json new file mode 100644 index 0000000..f4b5b1a --- /dev/null +++ b/data/ai-success-stories.json @@ -0,0 +1,22 @@ +[ + { + "id": "story-1774520941288", + "slug": "jojung-sook-hr-claude-cowork", + "title": "인사총무팀 AI 적용 사례", + "excerpt": "인사총무팀 조정숙 과장의 AI 업무 혁신 이야기. Claude Cowork 도입, 법인세 인사자료 8시간→4시간, 데이터 오류율 감소.", + "author": "조정숙", + "department": "인사총무팀", + "publishedAt": "2026-03-25", + "tags": [ + "인사총무", + "Claude Cowork", + "법인세", + "OT", + "엑셀" + ], + "contentFile": "jojung-sook-hr-claude-cowork.md", + "pdfUrl": "/public/resources/ai-success/1774520936441-c8e17615-dad1-4a08-9a26-f6303b666058.pdf", + "createdAt": "2026-03-26T10:29:01.288Z", + "updatedAt": "2026-03-26T10:29:01.288Z" + } +] \ No newline at end of file diff --git a/data/ai-success-stories/jojung-sook-hr-claude-cowork.md b/data/ai-success-stories/jojung-sook-hr-claude-cowork.md new file mode 100644 index 0000000..e69de29 diff --git a/data/ax-assignments.json b/data/ax-assignments.json new file mode 100644 index 0000000..8396adf --- /dev/null +++ b/data/ax-assignments.json @@ -0,0 +1,86 @@ +[ + { + "id": "2371e4d4-ac7b-4ae1-bde2-1f9ff67e9d15", + "department": "테스트부서", + "name": "홍길동", + "employeeId": "", + "position": "", + "phone": "", + "email": "", + "workProcessDescription": "테스트", + "painPoint": "테스트", + "currentTimeSpent": "30분", + "errorRateBefore": "5%", + "collaborationDepts": "", + "reasonToSolve": "테스트", + "aiExpectation": "테스트", + "outputType": "테스트", + "automationLevel": "", + "dataReadiness": "", + "dataLocation": "", + "personalInfo": "", + "dataQuality": "", + "dataCount": "", + "dataTypes": null, + "timeReduction": "", + "errorReduction": "", + "volumeIncrease": "", + "costReduction": "", + "responseTime": "", + "otherMetrics": "", + "annualSavings": "", + "laborReplacement": "", + "revenueIncrease": "", + "otherEffects": "", + "qualitativeEffects": null, + "techStack": null, + "risks": null, + "riskDetail": "", + "participationPledge": true, + "status": "신청", + "createdAt": "2026-03-18T05:11:13.793Z", + "updatedAt": "2026-03-18T05:11:13.793Z" + }, + { + "id": "a9d59801-1f84-4c0d-ad7e-e8db28fcd003", + "department": "테스트부서", + "name": "홍길동", + "employeeId": "", + "position": "", + "phone": "", + "email": "", + "workProcessDescription": "테스트", + "painPoint": "테스트", + "currentTimeSpent": "30분", + "errorRateBefore": "5%", + "collaborationDepts": "", + "reasonToSolve": "테스트", + "aiExpectation": "테스트", + "outputType": "테스트", + "automationLevel": "", + "dataReadiness": "", + "dataLocation": "", + "personalInfo": "", + "dataQuality": "", + "dataCount": "", + "dataTypes": null, + "timeReduction": "", + "errorReduction": "", + "volumeIncrease": "", + "costReduction": "", + "responseTime": "", + "otherMetrics": "", + "annualSavings": "", + "laborReplacement": "", + "revenueIncrease": "", + "otherEffects": "", + "qualitativeEffects": null, + "techStack": null, + "risks": null, + "riskDetail": "", + "participationPledge": true, + "status": "신청", + "createdAt": "2026-03-18T05:05:04.467Z", + "updatedAt": "2026-03-18T05:05:04.467Z" + } +] \ No newline at end of file diff --git a/data/check-queue.html b/data/check-queue.html new file mode 100644 index 0000000..1e33e1f --- /dev/null +++ b/data/check-queue.html @@ -0,0 +1,255 @@ + + + + + + 학습센터 + + + +
+ + +
+
+

학습센터

+ 강의 등록하기 +
+ +
+
+

최신 컨텐츠로 학습하고, 바로 업무에 적용하세요.

+

유튜브 링크 또는 PPT를 등록한 뒤, 목록에서 클릭하여 강의를 시청할 수 있습니다.

+
+ +
+

강의 검색/필터

+
+ + + + + + + +
+ + 초기화 +
+
+
+ +
+

관리자 모드

+ + +

관리자 모드 활성화됨

+
+ 큐: 1 + 워커: 작동중 + 실패 재시도 최대: 2 +
+
+ PPT 썸네일 - 준비완료 0 + 처리중 0 + 대기 0 + 실패 2 +
+ +
+ + + + + + + +
+ +
+ + + +
+ +
+ +
+

유튜브 강의 등록

+
+ + + + + +
+
+ +
+

PowerPoint 강의 등록

+
+ + + + + +
+
+ +
+
+

등록된 강의

+ 총 2건 +
+ + +
+ + + + + +
+ + +
+
+
+
+ + diff --git a/data/company-prompts.json b/data/company-prompts.json new file mode 100644 index 0000000..5f13f85 --- /dev/null +++ b/data/company-prompts.json @@ -0,0 +1,58 @@ +[ + { + "id": "meeting-summary", + "title": "회의 요약", + "description": "녹취·메모를 안건·결정사항·액션아이템으로 정리", + "tag": "협업", + "body": "아래 회의 내용(또는 녹취/메모)을 바탕으로 다음 형식으로 정리해 주세요.\n\n1) 회의 개요: 일시, 참석자(알 수 있는 경우), 목적\n2) 논의 안건별 요약\n3) 결정 사항 (명확한 문장으로)\n4) 액션 아이템 표: 담당 / 기한 / 산출물\n5) 체크리스트 (완료여부 체크)\n\n회의 내용:\n[여기에 붙여넣기]" + }, + { + "id": "email-draft", + "title": "비즈니스 이메일 초안", + "description": "정중하고 간결한 사내·대외 메일 문안", + "tag": "커뮤니케이션", + "body": "다음 조건에 맞는 비즈니스 이메일 초안을 작성해 주세요.\n\n- 수신자·관계(내부/거래처/고객)\n- 목적(요청, 공지, 사과, 후속, 감사 등)\n- 반드시 포함할 사실·숫자·일정\n- 톤: [격식 있게 / 친근하되 전문적으로]\n\n배경 및 요청 사항:\n[여기에 입력]" + }, + { + "id": "report-outline", + "title": "보고서·기획안 목차", + "description": "경영·프로젝트 보고용 목차와 각 절 요지", + "tag": "기획", + "body": "다음 주제에 대한 보고서(또는 기획안) 목차를 제안하고, 각 장·절에 2~3문장 수준의 작성 요지를 적어 주세요.\n\n주제:\n[여기에 입력]\n\n대상 독자: [임원 / 팀 / 외부 심사 등]\n분량 느낌: [A4 기준 N페이지 내외]\n\n추가로, Executive Summary에 넣을 핵심 bullet 5개도 제안해 주세요." + }, + { + "id": "okr-weekly", + "title": "OKR·주간 업무 정리", + "description": "목표 대비 진척·리스크·다음 주 계획", + "tag": "성과관리", + "body": "아래 정보를 바탕으로 주간 업무 정리 형식으로 작성해 주세요.\n\n형식:\n- 이번 주 완료 항목 (OKR 또는 KPI와 연결)\n- 진행 중 / 지연 항목과 사유\n- 리스크·에스컬레이션 필요 사항\n- 다음 주 우선순위 Top 3\n\n원시 메모:\n[여기에 입력]" + }, + { + "id": "code-review", + "title": "코드 리뷰 요청", + "description": "변경 의도·리뷰 포인트를 구조화", + "tag": "개발", + "body": "다음 코드(또는 PR 설명)를 검토할 때, 리뷰어가 빠르게 이해할 수 있도록 정리해 주세요.\n\n1) 변경 목적·배경\n2) 주요 변경 사항 요약\n3) 리뷰 시 특히 봐 주었으면 하는 부분 (성능, 보안, 가독성, 엣지 케이스)\n4) 테스트 범위·한계\n\n코드/설명:\n[여기에 붙여넣기]" + }, + { + "id": "customer-reply", + "title": "고객 문의 응대 초안", + "description": "클레임·문의에 대한 사실 기반 답변 틀", + "tag": "CS", + "body": "고객 문의에 대한 응대 초안을 작성해 주세요.\n\n원칙: 사실 관계를 명확히 하고, 과도한 약속은 피하며, 다음 단계와 연락 채널을 제시합니다.\n\n고객 메시지 요지:\n[요약 입력]\n\n사내 확인된 사실:\n[입력]\n\n희망 톤: [공손·단호·공감 중심 등]\n\n답변 초안을 본문과, 내부용 메모(주의사항)로 나누어 주세요." + }, + { + "id": "jd-draft", + "title": "채용 JD 초안", + "description": "직무·자격요건·우대사항을 균형 있게", + "tag": "인사", + "body": "다음 조건으로 채용 공고(JD) 초안을 작성해 주세요.\n\n- 직무명·조직 맥락\n- 핵심 업무 (5~7 bullet)\n- 필수 자격·경력\n- 우대 사항\n- 근무 방식·복리후생은 플레이스홀더로 표시 가능\n\n입력:\n[채용 배경, 팀 상황, 기술 스택 등]" + }, + { + "id": "risk-review", + "title": "리스크·의사결정 메모", + "description": "안건의 대안·트레이드오프·권고안", + "tag": "경영", + "body": "다음 의사결정 안건에 대해 내부 공유용 메모를 작성해 주세요.\n\n구성:\n1) 안건 한 줄 요약\n2) 선택지(옵션) A/B/C와 각각의 장단점\n3) 법무·보안·재무·운영 관점 체크리스트 (해당 시)\n4) 권고안과 근거\n5) 의사결정에 필요한 추가 정보\n\n안건 설명:\n[여기에 입력]" + } +] diff --git a/data/lectures.json b/data/lectures.json new file mode 100644 index 0000000..1237beb --- /dev/null +++ b/data/lectures.json @@ -0,0 +1,36 @@ +[ + { + "id": "4a937c1e-98cb-402f-abd4-497120212974", + "type": "ppt", + "title": "claude cowork 가이드", + "description": "초기 샘플 PPT 강의", + "fileName": "1773454993461-578d68bd-9486-4ee2-bcea-46d038571a2e.pptx", + "originalName": "claude cowork 가이드.pptx", + "createdAt": "2026-03-14T02:23:13.463Z", + "tags": [], + "previewTitle": "제목 없음", + "slideCount": 12, + "thumbnailUrl": null, + "thumbnailStatus": "failed", + "thumbnailRetryCount": 3, + "thumbnailError": "썸네일 생성 도구 실행 실패 또는 미설치", + "thumbnailUpdatedAt": "2026-03-14T02:55:55.126Z" + }, + { + "id": "51ee515c-fff0-4156-97ac-09ec0b412345", + "type": "ppt", + "title": "생성형AI, LLM, 에이전틱 AI 이해하기", + "description": "초기 샘플 PPT 강의", + "fileName": "1773454993463-3f6f09f5-0d48-4f0f-9a15-2b61adce1ba9.pptx", + "originalName": "생성형AI, LLM, 에이전틱 AI 이해하기.pptx", + "createdAt": "2026-03-14T02:23:13.466Z", + "tags": [], + "previewTitle": "제목 없음", + "slideCount": 1, + "thumbnailUrl": null, + "thumbnailStatus": "failed", + "thumbnailRetryCount": 3, + "thumbnailError": "썸네일 생성 도구 실행 실패 또는 미설치", + "thumbnailUpdatedAt": "2026-03-14T02:55:55.141Z" + } +] \ No newline at end of file diff --git a/data/thumbnail-jobs.json b/data/thumbnail-jobs.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/data/thumbnail-jobs.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/db/bootstrap-role.sql.example b/db/bootstrap-role.sql.example new file mode 100644 index 0000000..a407040 --- /dev/null +++ b/db/bootstrap-role.sql.example @@ -0,0 +1,19 @@ +-- PostgreSQL 슈퍼유저(예: postgres)로 **한 번만** 실행하는 예시입니다. +-- 값을 바꾼 뒤: psql -U postgres -f bootstrap-role.sql (또는 psql 안에서 붙여넣기) +-- +-- 앱은 .env의 DB_USERNAME / DB_PASSWORD / DB_DATABASE 로 접속합니다. +-- 역할이 이미 있으면 CREATE ROLE 부분은 생략하고 비밀번호만 맞춥니다: \password 역할이름 + +-- 1) 앱 전용 로그인 역할 (이름은 .env의 DB_USERNAME과 동일하게) +CREATE ROLE app_user WITH LOGIN PASSWORD '여기에_강한_비밀번호'; + +-- 2) 데이터베이스 (.env의 DB_DATABASE와 동일) +CREATE DATABASE app_database OWNER app_user; + +-- 이미 DB가 있고 소유자만 바꾸려면(주의: 운영 정책에 맞게 사용): +-- ALTER DATABASE app_database OWNER TO app_user; + +-- 3) 같은 서버에서 DB를 만든 직후, public 스키마 권한(버전에 따라 필요) +\c app_database +GRANT ALL ON SCHEMA public TO app_user; +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO app_user; diff --git a/db/migrations/add-lecture-type-video.sql b/db/migrations/add-lecture-type-video.sql new file mode 100644 index 0000000..1321693 --- /dev/null +++ b/db/migrations/add-lecture-type-video.sql @@ -0,0 +1,3 @@ +-- 기존 DB에 학습센터 강의 타입 'video' 추가 (PostgreSQL) +ALTER TABLE lectures DROP CONSTRAINT IF EXISTS lectures_type_check; +ALTER TABLE lectures ADD CONSTRAINT lectures_type_check CHECK (type IN ('youtube', 'ppt', 'news', 'link', 'video')); diff --git a/db/schema.sql b/db/schema.sql new file mode 100644 index 0000000..4a7b23e --- /dev/null +++ b/db/schema.sql @@ -0,0 +1,272 @@ +CREATE TABLE IF NOT EXISTS lectures ( + id UUID PRIMARY KEY, + type VARCHAR(20) NOT NULL CHECK (type IN ('youtube', 'ppt', 'news', 'link', 'video')), + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + tags TEXT[] NOT NULL DEFAULT '{}', + youtube_url TEXT, + file_name TEXT, + original_name TEXT, + preview_title TEXT, + slide_count INTEGER NOT NULL DEFAULT 0, + thumbnail_url TEXT, + thumbnail_status VARCHAR(20) NOT NULL DEFAULT 'pending', + thumbnail_retry_count INTEGER NOT NULL DEFAULT 0, + thumbnail_error TEXT, + thumbnail_updated_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + list_section VARCHAR(20) NOT NULL DEFAULT 'learning', + news_url TEXT +); + +ALTER TABLE lectures DROP CONSTRAINT IF EXISTS lectures_type_check; +ALTER TABLE lectures ADD CONSTRAINT lectures_type_check CHECK (type IN ('youtube', 'ppt', 'news', 'link', 'video')); +ALTER TABLE lectures ADD COLUMN IF NOT EXISTS list_section VARCHAR(20) NOT NULL DEFAULT 'learning'; +ALTER TABLE lectures ADD COLUMN IF NOT EXISTS news_url TEXT; + +CREATE INDEX IF NOT EXISTS idx_lectures_type_created_at ON lectures (type, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_lectures_created_at ON lectures (created_at DESC); +CREATE INDEX IF NOT EXISTS idx_lectures_tags ON lectures USING GIN (tags); + +CREATE OR REPLACE FUNCTION set_lectures_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_lectures_updated_at ON lectures; +CREATE TRIGGER trg_lectures_updated_at +BEFORE UPDATE ON lectures +FOR EACH ROW +EXECUTE FUNCTION set_lectures_updated_at(); + +-- AX 과제 신청 (PDF 양식 기반) +CREATE TABLE IF NOT EXISTS ax_assignments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + department VARCHAR(200) NOT NULL, + name VARCHAR(100) NOT NULL, + employee_id VARCHAR(50), + position VARCHAR(100), + phone VARCHAR(50), + email VARCHAR(200), + work_process_description TEXT, + pain_point TEXT, + current_time_spent VARCHAR(100), + error_rate_before VARCHAR(100), + collaboration_depts TEXT, + reason_to_solve TEXT, + ai_expectation TEXT, + output_type TEXT, + automation_level VARCHAR(50), + data_readiness VARCHAR(50), + data_location TEXT, + personal_info VARCHAR(50), + data_quality VARCHAR(50), + data_count VARCHAR(100), + data_types TEXT[], + time_reduction VARCHAR(100), + error_reduction VARCHAR(100), + volume_increase VARCHAR(100), + cost_reduction VARCHAR(100), + response_time VARCHAR(100), + other_metrics TEXT, + annual_savings VARCHAR(100), + labor_replacement VARCHAR(100), + revenue_increase VARCHAR(100), + other_effects TEXT, + qualitative_effects TEXT[], + tech_stack TEXT[], + risks TEXT[], + risk_detail TEXT, + participation_pledge BOOLEAN DEFAULT false, + application_file TEXT, + status VARCHAR(20) NOT NULL DEFAULT '신청', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +ALTER TABLE ax_assignments ADD COLUMN IF NOT EXISTS application_file TEXT; + +CREATE INDEX IF NOT EXISTS idx_ax_assignments_created_at ON ax_assignments (created_at DESC); + +CREATE OR REPLACE FUNCTION set_ax_assignments_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_ax_assignments_updated_at ON ax_assignments; +CREATE TRIGGER trg_ax_assignments_updated_at +BEFORE UPDATE ON ax_assignments +FOR EACH ROW +EXECUTE FUNCTION set_ax_assignments_updated_at(); + +-- OPS 이메일(@xavis.co.kr) 매직 링크 인증 — 이벤트 감사 로그 +CREATE TABLE IF NOT EXISTS ops_email_auth_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(320) NOT NULL, + event_type VARCHAR(40) NOT NULL CHECK (event_type IN ('magic_link_requested', 'login_success', 'logout')), + ip_address VARCHAR(45), + user_agent TEXT, + return_to TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_ops_email_auth_events_email_created ON ops_email_auth_events (email, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_ops_email_auth_events_created ON ops_email_auth_events (created_at DESC); +CREATE INDEX IF NOT EXISTS idx_ops_email_auth_events_event_type ON ops_email_auth_events (event_type); + +-- 이메일별 최초·최근 로그인 및 누적 로그인 횟수 +CREATE TABLE IF NOT EXISTS ops_email_users ( + email VARCHAR(320) PRIMARY KEY, + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_login_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + login_count INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_ops_email_users_last_login ON ops_email_users (last_login_at DESC); + +-- 회의록 AI: 이메일(OPS 세션) 기반 사용자·프롬프트·회의 저장 +CREATE TABLE IF NOT EXISTS meeting_ai_users ( + email VARCHAR(320) PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS meeting_ai_prompts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_email VARCHAR(320) NOT NULL REFERENCES meeting_ai_users(email) ON DELETE CASCADE, + include_title_line BOOLEAN NOT NULL DEFAULT true, + include_attendees BOOLEAN NOT NULL DEFAULT true, + include_summary BOOLEAN NOT NULL DEFAULT true, + include_action_items BOOLEAN NOT NULL DEFAULT true, + include_checklist BOOLEAN NOT NULL DEFAULT true, + custom_instructions TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_meeting_ai_prompts_user UNIQUE (user_email) +); + +CREATE INDEX IF NOT EXISTS idx_meeting_ai_prompts_user ON meeting_ai_prompts (user_email); + +-- 기존 DB: 신규 행 기본값만 갱신(이미 저장된 include_checklist 값은 유지) +ALTER TABLE meeting_ai_prompts ALTER COLUMN include_checklist SET DEFAULT true; + +CREATE TABLE IF NOT EXISTS meeting_ai_meetings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_email VARCHAR(320) NOT NULL REFERENCES meeting_ai_users(email) ON DELETE CASCADE, + title VARCHAR(500) NOT NULL DEFAULT '', + source_text TEXT, + transcript_text TEXT, + generated_minutes TEXT, + summary_text TEXT, + audio_file_path TEXT, + audio_original_name TEXT, + chat_model VARCHAR(80) NOT NULL DEFAULT 'gpt-5-mini', + transcription_model VARCHAR(80), + meeting_date DATE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_meeting_ai_meetings_user_created ON meeting_ai_meetings (user_email, created_at DESC); + +-- 기존 DB 마이그레이션: 전사에 사용한 OpenAI 모델(gpt-4o-mini-transcribe, gpt-4o-transcribe 등) +ALTER TABLE meeting_ai_meetings ADD COLUMN IF NOT EXISTS transcription_model VARCHAR(80); + +-- 미팅 일자(회의가 열린 날짜, 선택) +ALTER TABLE meeting_ai_meetings ADD COLUMN IF NOT EXISTS meeting_date DATE; + +-- 회의록 생성 후 체크리스트 자동 추출(JSON 스냅샷, 업무 체크리스트 AI 연동) +ALTER TABLE meeting_ai_meetings ADD COLUMN IF NOT EXISTS checklist_snapshot JSONB; + +-- 업무 체크리스트 툴팁용 짧은 요약(생성 시 회의록 본문에서 추출해 저장, 없으면 조회 시 추출) +ALTER TABLE meeting_ai_meetings ADD COLUMN IF NOT EXISTS summary_text TEXT; + +CREATE OR REPLACE FUNCTION set_meeting_ai_prompts_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_meeting_ai_prompts_updated_at ON meeting_ai_prompts; +CREATE TRIGGER trg_meeting_ai_prompts_updated_at +BEFORE UPDATE ON meeting_ai_prompts +FOR EACH ROW +EXECUTE FUNCTION set_meeting_ai_prompts_updated_at(); + +CREATE OR REPLACE FUNCTION set_meeting_ai_meetings_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_meeting_ai_meetings_updated_at ON meeting_ai_meetings; +CREATE TRIGGER trg_meeting_ai_meetings_updated_at +BEFORE UPDATE ON meeting_ai_meetings +FOR EACH ROW +EXECUTE FUNCTION set_meeting_ai_meetings_updated_at(); + +-- 업무 체크리스트 AI: 사용자별 항목(회의록에서 가져오기 또는 수동) +CREATE TABLE IF NOT EXISTS meeting_ai_checklist_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_email VARCHAR(320) NOT NULL REFERENCES meeting_ai_users(email) ON DELETE CASCADE, + meeting_id UUID REFERENCES meeting_ai_meetings(id) ON DELETE SET NULL, + title TEXT NOT NULL, + detail TEXT, + assignee VARCHAR(300), + due_note VARCHAR(300), + completed BOOLEAN NOT NULL DEFAULT false, + completed_at TIMESTAMPTZ, + completion_note TEXT, + sort_order INT NOT NULL DEFAULT 0, + source VARCHAR(20) NOT NULL DEFAULT 'imported' CHECK (source IN ('imported', 'manual')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +ALTER TABLE meeting_ai_checklist_items ADD COLUMN IF NOT EXISTS completion_note TEXT; + +CREATE INDEX IF NOT EXISTS idx_meeting_ai_checklist_user_updated ON meeting_ai_checklist_items (user_email, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_meeting_ai_checklist_meeting ON meeting_ai_checklist_items (meeting_id); + +CREATE OR REPLACE FUNCTION set_meeting_ai_checklist_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_meeting_ai_checklist_updated_at ON meeting_ai_checklist_items; +CREATE TRIGGER trg_meeting_ai_checklist_updated_at +BEFORE UPDATE ON meeting_ai_checklist_items +FOR EACH ROW +EXECUTE FUNCTION set_meeting_ai_checklist_updated_at(); + +-- PPT 썸네일 작업 이벤트 로그 (기존 data/thumbnail-events.json 대체) +CREATE TABLE IF NOT EXISTS lecture_thumbnail_events ( + id UUID PRIMARY KEY, + occurred_at TIMESTAMPTZ NOT NULL, + event_type VARCHAR(40) NOT NULL, + lecture_id UUID, + lecture_title TEXT, + reason VARCHAR(200), + force_flag BOOLEAN NOT NULL DEFAULT false, + queue_size_after INTEGER, + retry_count INTEGER, + duration_ms INTEGER, + error_text TEXT +); + +CREATE INDEX IF NOT EXISTS idx_lecture_thumbnail_events_occurred ON lecture_thumbnail_events (occurred_at DESC); +CREATE INDEX IF NOT EXISTS idx_lecture_thumbnail_events_type ON lecture_thumbnail_events (event_type); +CREATE INDEX IF NOT EXISTS idx_lecture_thumbnail_events_lecture ON lecture_thumbnail_events (lecture_id); diff --git a/deploy/apache-ai.ncue.net-ssl.conf.example b/deploy/apache-ai.ncue.net-ssl.conf.example new file mode 100644 index 0000000..fa18d21 --- /dev/null +++ b/deploy/apache-ai.ncue.net-ssl.conf.example @@ -0,0 +1,58 @@ +# ============================================================================= +# ai.ncue.net — Apache 2.4+ SSL → Node webplatform (단일 백엔드) +# ============================================================================= +# 필요 모듈: ssl, proxy, proxy_http, headers +# sudo a2enmod ssl proxy proxy_http headers +# +# Node listen 주소·포트는 .env 의 HOST / PORT 와 맞출 것 (기본 127.0.0.1:8030). +# 아래 127.0.0.1:8030 은 예시이며, 실제 프로세스 포트로 바꾸면 됩니다. +# +# 왜 경로별 ProxyPass 를 나누지 않나? +# - 이 저장소의 server.js 가 /chat, /learning, /admin, /ai-explore 등 모든 경로를 한 프로세스에서 처리합니다. +# - 동일 포트(8030)로 여러 줄 나누면 유지보수만 불필요하게 복잡해지고, 8018/8030 혼선만 생깁니다. +# - 한 줄 ProxyPass / 로 충분합니다. +# +# Apache 2.4.31+ : ProxyAddHeaders On 이 X-Forwarded-* 를 백엔드에 넘깁니다(클라이언트 IP 추적 등). +# 그보다 오래된 Apache 면 ProxyAddHeaders 줄을 제거하고, 필요 시 X-Forwarded-For 수동 설정을 검토하세요. +# ============================================================================= + + + + ServerName ai.ncue.net + + ProxyPreserveHost On + + # 대용량 업로드(회의 음성 등, 앱 MEETING_AUDIO_MAX_MB 와 맞출 것) + LimitRequestBody 314572800 + + TimeOut 600 + ProxyTimeout 600 + + # HTTPS 프록시임을 Node/앱에 알림 (쿠키 Secure, 리다이렉트 URL 등) + RequestHeader set X-Forwarded-Proto "https" + + # 클라이언트 IP 등 전달 (Apache 2.4.31+, mod_proxy_http) + + ProxyAddHeaders On + + + ProxyPass / http://127.0.0.1:8030/ + ProxyPassReverse / http://127.0.0.1:8030/ + + SSLEngine on + Include /etc/letsencrypt/options-ssl-apache.conf + + ErrorLog ${APACHE_LOG_DIR}/ai.ncue.net_ssl_error.log + CustomLog ${APACHE_LOG_DIR}/ai.ncue.net_ssl_access.log combined + + + + Require all granted + Require not env bad_bot + + + + SSLCertificateFile /etc/letsencrypt/live/ncue.net-0001/fullchain.pem + SSLCertificateKeyFile /etc/letsencrypt/live/ncue.net-0001/privkey.pem + + diff --git a/docs/AX-APPLY-ARRAY-ERROR-ANALYSIS.md b/docs/AX-APPLY-ARRAY-ERROR-ANALYSIS.md new file mode 100644 index 0000000..ded1692 --- /dev/null +++ b/docs/AX-APPLY-ARRAY-ERROR-ANALYSIS.md @@ -0,0 +1,44 @@ +# AX 과제 신청 "malformed array literal: """ 오류 원인 분석 + +## 1. 오류 발생 조건 + +- **오류 메시지**: `malformed array literal: ""` +- **발생 시점**: 필수값만 입력 후 저장 시 (techStack, risks 필드 비어 있음) +- **PostgreSQL**: `text[]` 컬럼에 빈 문자열 `""`이 전달되면 발생 + +## 2. 데이터 흐름 추적 + +### 2.1 프론트엔드 (ax-apply.ejs) + +``` +FormData 수집 → fd.forEach로 data 객체 생성 → JSON.stringify(data) → POST /api/ax-apply +``` + +**techStackStr, risksStr가 비어 있을 때:** +- `fd.forEach`: k="techStackStr", v="" (빈 문자열) +- `k === 'techStackStr' && v` → v가 ""(falsy)이므로 **건너뜀** +- `k !== 'techStackStr'` → k가 techStackStr이므로 **건너뜀** +- **결과**: data에 techStackStr, risksStr가 **포함되지 않음** (undefined) + +→ 이론상 빈 문자열이 전송되지 않아야 함. + +### 2.2 백엔드 (server.js) + +**node-pg 알려진 이슈**: JavaScript 빈 배열 `[]`을 `text[]` 파라미터로 전달하면, node-pg가 `""`로 직렬화함. +- PostgreSQL은 `""`를 배열 리터럴로 파싱할 수 없음 → `malformed array literal: ""` 발생 + +**해결책**: 배열 컬럼에 **파라미터 대신 SQL 리터럴 `'{}'`** 사용 (이미 server.js에 적용됨) + +## 3. 가능한 원인 + +| 원인 | 가능성 | 설명 | +|------|--------|------| +| **배포 서버 미재시작** | 높음 | 수정된 server.js가 반영되지 않음. PM2 등으로 재시작 필요 | +| **브라우저/FormData 차이** | 낮음 | 일부 환경에서 빈 필드가 ""로 전송될 수 있음 | +| **기타 배열 필드** | 낮음 | dataTypes, qualitativeEffects 등 (폼에 없음) | + +## 4. 권장 조치 + +1. **서버 재시작**: `pm2 restart webplatform` (배포 환경) +2. **프론트엔드 방어**: 전송 직전 배열 관련 빈 문자열 제거 +3. **배포 반영**: 최신 server.js가 배포 서버에 반영되었는지 확인 diff --git a/docs/DEPLOYMENT-xavis.ncue.net.md b/docs/DEPLOYMENT-xavis.ncue.net.md new file mode 100644 index 0000000..e536584 --- /dev/null +++ b/docs/DEPLOYMENT-xavis.ncue.net.md @@ -0,0 +1,239 @@ +# xavis.ncue.net 서버 설치 가이드 (Apache2) + +이 문서는 XAVIS 웹플랫폼을 **xavis.ncue.net** 서버에 Apache2를 사용해 배포하는 방법을 설명합니다. + +--- + +## 1. 사전 요구사항 + +| 항목 | 버전/설명 | +|------|-----------| +| Node.js | v18 이상 권장 | +| npm | Node.js와 함께 설치 | +| PostgreSQL | DB는 `ncue.net` 원격 서버 사용 (이미 설정됨) | +| Apache2 | mod_proxy, mod_proxy_http 필요 | +| LibreOffice | PPTX→PDF 변환용 (선택, `brew install --cask libreoffice` 또는 `apt install libreoffice`) | +| poppler-utils | pdftoppm 포함, 썸네일/슬라이드 이미지용 (`apt install poppler-utils`) | + +--- + +## 2. 서버에 프로젝트 배포 + +### 2.1 프로젝트 클론 + +```bash +# 배포 디렉터리로 이동 (예: /var/www) +cd /var/www + +# Git 클론 +sudo git clone https://git.ncue.net/xavis/webplatform.git +cd webplatform +``` + +### 2.2 소유권 설정 + +```bash +# 웹 서버 사용자(예: www-data)에게 소유권 부여 +sudo chown -R www-data:www-data /var/www/webplatform +``` + +### 2.3 의존성 설치 + +```bash +cd /var/www/webplatform +npm install --production +``` + +### 2.4 환경 변수 설정 + +```bash +# .env 파일이 없다면 .env.example 복사 +cp .env.example .env + +# .env 수정 (에디터로) +nano .env +``` + +**필수 수정 항목 예시:** + +```env +PORT=8030 +ADMIN_TOKEN=실제_관리자_토큰_변경 + +# PostgreSQL (ncue.net 원격 DB) +ENABLE_POSTGRES=1 +DB_HOST=ncue.net +DB_PORT=5432 +DB_DATABASE=ai_web_platform +DB_USERNAME=ncue +DB_PASSWORD=실제_DB_비밀번호 + +# 채팅 API 키 (선택) +OPENAI_API_KEY= +CLAUDE_API_KEY= +GENAI_API_KEY= +``` + +--- + +## 3. Node.js 앱 실행 (PM2 권장) + +앱을 백그라운드에서 안정적으로 실행하려면 PM2를 사용합니다. + +### 3.1 PM2 설치 + +```bash +# PM2 전역 설치 (Node.js/npm 필요) +sudo npm install -g pm2 + +# 설치 확인 +pm2 --version +``` + +> **참고:** `pm2: command not found` 오류가 나면 위 명령으로 PM2를 먼저 설치한 뒤 앱을 실행하세요. + +### 3.2 PM2로 앱 실행 + +```bash +cd /var/www/webplatform +pm2 start server.js --name webplatform +pm2 save +pm2 startup # 시스템 부팅 시 자동 시작 설정 +``` + +### 3.3 PM2 명령어 + +```bash +pm2 status # 상태 확인 +pm2 logs webplatform # 로그 보기 +pm2 restart webplatform # 재시작 +pm2 stop webplatform # 중지 +``` + +--- + +## 4. Apache2 리버스 프록시 설정 + +Apache2가 80/443 포트에서 요청을 받아 Node.js 앱(8030)으로 전달합니다. + +### 4.1 필요한 모듈 활성화 + +```bash +sudo a2enmod proxy +sudo a2enmod proxy_http +sudo a2enmod headers +sudo a2enmod ssl +sudo systemctl restart apache2 +``` + +### 4.2 가상 호스트 설정 + +**설정 파일:** `docs/apache-xavis.ncue.net.conf` (git.ncue.net 스타일, HTTPS) + +```bash +sudo cp docs/apache-xavis.ncue.net.conf /etc/apache2/sites-available/xavis.ncue.net.conf +``` + +> SSL 인증서 경로가 `ncue.net`과 다르면(예: xavis.ncue.net 전용 인증서) 설정 파일에서 +> `SSLCertificateFile`, `SSLCertificateKeyFile` 경로를 수정하세요. + +### 4.3 HTTPS (SSL) 설정 (권장) + +Let's Encrypt 사용 예: + +```bash +sudo apt install certbot python3-certbot-apache +sudo certbot --apache -d xavis.ncue.net +``` + +인증서 발급 후 Apache가 자동으로 443 포트 VirtualHost를 생성합니다. +이 경우 위 `ProxyPass` 설정이 443 VirtualHost에도 적용되는지 확인하세요. + +### 4.4 사이트 활성화 및 재시작 + +```bash +sudo a2ensite xavis.ncue.net.conf +sudo apache2ctl configtest +sudo systemctl reload apache2 +``` + +--- + +## 5. 디렉터리 권한 + +```bash +# uploads, data 디렉터리 쓰기 권한 +sudo mkdir -p /var/www/webplatform/uploads/thumbnails +sudo mkdir -p /var/www/webplatform/uploads/slides +sudo mkdir -p /var/www/webplatform/data +sudo chown -R www-data:www-data /var/www/webplatform/uploads +sudo chown -R www-data:www-data /var/www/webplatform/data +``` + +--- + +## 6. 선택: LibreOffice 및 poppler 설치 (PPT 썸네일/슬라이드) + +PPTX 파일의 썸네일과 슬라이드 이미지를 생성하려면: + +```bash +# Ubuntu/Debian +sudo apt update +sudo apt install libreoffice poppler-utils + +# CentOS/RHEL +sudo yum install libreoffice poppler-utils +``` + +설치 후 PM2로 앱 재시작: + +```bash +pm2 restart webplatform +``` + +--- + +## 7. 방화벽 확인 + +```bash +# 80, 443 포트 허용 (Apache) +sudo ufw allow 80 +sudo ufw allow 443 +sudo ufw reload +``` + +8030 포트는 localhost에서만 사용하므로 외부에 열 필요 없습니다. + +--- + +## 8. 배포 체크리스트 + +- [ ] Git 클론 완료 +- [ ] `npm install --production` 완료 +- [ ] `.env` 설정 (DB, ADMIN_TOKEN 등) +- [ ] PM2로 앱 실행 (`pm2 start server.js --name webplatform`) +- [ ] Apache2 mod_proxy, mod_proxy_http 활성화 +- [ ] VirtualHost 설정 및 사이트 활성화 +- [ ] `uploads/`, `data/` 디렉터리 권한 설정 +- [ ] (선택) LibreOffice, poppler-utils 설치 +- [ ] `https://xavis.ncue.net` 접속 확인 + +--- + +## 9. 트러블슈팅 + +### 502 Bad Gateway +- Node.js 앱이 8030 포트에서 실행 중인지 확인: `pm2 status` +- `.env`의 `PORT=8030`과 Apache `ProxyPass` 포트가 일치하는지 확인 + +### 403 Forbidden +- `uploads/`, `data/` 디렉터리 권한 확인 +- Apache 사용자(`www-data`)가 해당 디렉터리에 쓰기 권한이 있는지 확인 + +### PostgreSQL 연결 실패 +- `DB_HOST`, `DB_PORT`, `DB_USERNAME`, `DB_PASSWORD` 확인 +- 서버에서 `ncue.net:5432`로 접근 가능한지 확인: `nc -zv ncue.net 5432` + +### 썸네일/슬라이드 이미지 미생성 +- LibreOffice, poppler-utils 설치 여부 확인 +- PM2 로그 확인: `pm2 logs webplatform` diff --git a/lib/link-preview.js b/lib/link-preview.js new file mode 100644 index 0000000..4e62a38 --- /dev/null +++ b/lib/link-preview.js @@ -0,0 +1,97 @@ +/** + * 외부 URL의 HTML에서 Open Graph / Twitter 카드 이미지 URL 추출 (link.ncue.net 등과 유사한 미리보기) + */ + +const OG_FETCH_TIMEOUT_MS = 15000; +const DEFAULT_UA = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; + +function decodeBasicHtmlEntities(s) { + return (s || "") + .replace(/&/g, "&") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, "<") + .replace(/>/g, ">"); +} + +function resolveUrl(src, baseHref) { + try { + const t = (src || "").trim(); + if (!t) return null; + return new URL(t, baseHref).href; + } catch { + return null; + } +} + +function extractOgImageFromHtml(html) { + if (!html || typeof html !== "string") return null; + const patterns = [ + /]*property=["']og:image["'][^>]*content=["']([^"']+)["']/i, + /]*content=["']([^"']+)["'][^>]*property=["']og:image["']/i, + /]*name=["']twitter:image:src["'][^>]*content=["']([^"']+)["']/i, + /]*content=["']([^"']+)["'][^>]*name=["']twitter:image:src["']/i, + /]*name=["']twitter:image["'][^>]*content=["']([^"']+)["']/i, + /]*content=["']([^"']+)["'][^>]*name=["']twitter:image["']/i, + ]; + for (const p of patterns) { + const m = html.match(p); + if (m && m[1]) return decodeBasicHtmlEntities(m[1].trim()); + } + return null; +} + +/** + * @param {string} pageUrl + * @returns {Promise} 절대 URL의 og:image, 없으면 null + */ +async function fetchOpenGraphImageUrl(pageUrl) { + const normalized = (pageUrl || "").trim(); + if (!normalized) return null; + let base; + try { + base = new URL(normalized); + } catch { + return null; + } + if (base.protocol !== "http:" && base.protocol !== "https:") return null; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), OG_FETCH_TIMEOUT_MS); + try { + const res = await fetch(base.href, { + redirect: "follow", + signal: controller.signal, + headers: { + "User-Agent": DEFAULT_UA, + Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7", + }, + }); + if (!res.ok) return null; + const ct = (res.headers.get("content-type") || "").toLowerCase(); + if (!ct.includes("text/html") && !ct.includes("application/xhtml")) { + return null; + } + const html = await res.text(); + const raw = extractOgImageFromHtml(html); + if (!raw) return null; + const abs = resolveUrl(raw, base.href); + if (!abs) return null; + let imgUrl; + try { + imgUrl = new URL(abs); + } catch { + return null; + } + if (imgUrl.protocol !== "http:" && imgUrl.protocol !== "https:") return null; + return imgUrl.href; + } catch { + return null; + } finally { + clearTimeout(timer); + } +} + +module.exports = { fetchOpenGraphImageUrl }; diff --git a/lib/meeting-date-format.js b/lib/meeting-date-format.js new file mode 100644 index 0000000..340350a --- /dev/null +++ b/lib/meeting-date-format.js @@ -0,0 +1,20 @@ +/** + * PostgreSQL DATE 또는 날짜 전용 값을 YYYY-MM-DD로 직렬화. + * `new Date(x).toISOString().slice(0, 10)`은 UTC 기준이라 + * 서버/클라이언트 TZ가 한국 등일 때 **하루 전 날짜**로 잘릴 수 있음. + */ +function formatMeetingDateOnly(val) { + if (val == null || val === "") return null; + if (typeof val === "string") { + const s = val.trim().slice(0, 10); + if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s; + } + const d = val instanceof Date ? val : new Date(val); + if (Number.isNaN(d.getTime())) return null; + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} + +module.exports = { formatMeetingDateOnly }; diff --git a/lib/meeting-minutes-store.js b/lib/meeting-minutes-store.js new file mode 100644 index 0000000..481faf3 --- /dev/null +++ b/lib/meeting-minutes-store.js @@ -0,0 +1,458 @@ +/** + * 회의록 AI: PostgreSQL 또는 data/meeting-ai.json 폴백 + */ +const path = require("path"); +const fs = require("fs/promises"); +const { v4: uuidv4 } = require("uuid"); +const { extractMeetingSummary } = require("./meeting-minutes-summary"); +const { formatMeetingDateOnly } = require("./meeting-date-format"); +const taskChecklistStore = require("./task-checklist-store"); + +const MEETING_AI_FILE = path.join(__dirname, "..", "data", "meeting-ai.json"); + +const defaultPromptRow = (email) => ({ + user_email: email, + include_title_line: true, + include_attendees: true, + include_summary: true, + include_action_items: true, + include_checklist: true, + custom_instructions: null, +}); + +let writeChain = Promise.resolve(); + +function withFileLock(fn) { + const p = writeChain.then(() => fn()); + writeChain = p.catch(() => {}); + return p; +} + +async function readFileStore() { + try { + const raw = await fs.readFile(MEETING_AI_FILE, "utf8"); + const j = JSON.parse(raw); + if (!j || typeof j !== "object") return { prompts: {}, meetings: [] }; + if (!j.prompts || typeof j.prompts !== "object") j.prompts = {}; + if (!Array.isArray(j.meetings)) j.meetings = []; + return j; + } catch { + return { prompts: {}, meetings: [] }; + } +} + +async function writeFileStore(data) { + const dir = path.dirname(MEETING_AI_FILE); + await fs.mkdir(dir, { recursive: true }); + const tmp = `${MEETING_AI_FILE}.${process.pid}.tmp`; + await fs.writeFile(tmp, JSON.stringify(data, null, 2), "utf8"); + await fs.rename(tmp, MEETING_AI_FILE); +} + +function nowIso() { + return new Date().toISOString(); +} + +function rowToMeeting(row) { + return { + ...row, + created_at: row.created_at || nowIso(), + updated_at: row.updated_at || nowIso(), + }; +} + +/** + * @param {import("pg").Pool | null} pgPool + * @param {string} email + */ +async function ensureUserAndDefaultPrompt(pgPool, email) { + if (pgPool) { + await pgPool.query(`INSERT INTO meeting_ai_users (email) VALUES ($1) ON CONFLICT (email) DO NOTHING`, [email]); + await pgPool.query( + `INSERT INTO meeting_ai_prompts (user_email, include_title_line, include_attendees, include_summary, include_action_items, include_checklist) + VALUES ($1, true, true, true, true, true) + ON CONFLICT (user_email) DO NOTHING`, + [email] + ); + return; + } + await withFileLock(async () => { + const data = await readFileStore(); + if (!data.prompts[email]) { + data.prompts[email] = defaultPromptRow(email); + await writeFileStore(data); + } + }); +} + +/** + * @param {import("pg").Pool | null} pgPool + * @param {string} email + */ +async function getPromptRow(pgPool, email) { + if (pgPool) { + const r = await pgPool.query(`SELECT * FROM meeting_ai_prompts WHERE user_email = $1`, [email]); + return r.rows?.[0] || null; + } + const data = await readFileStore(); + const p = data.prompts[email]; + if (!p) return null; + return { + id: p.id || null, + user_email: email, + include_title_line: p.include_title_line !== false, + include_attendees: p.include_attendees !== false, + include_summary: p.include_summary !== false, + include_action_items: p.include_action_items !== false, + include_checklist: true, + custom_instructions: p.custom_instructions, + created_at: p.created_at || null, + updated_at: p.updated_at || null, + }; +} + +/** + * @param {import("pg").Pool | null} pgPool + */ +async function upsertPrompt(pgPool, email, fields) { + if (pgPool) { + await pgPool.query( + `INSERT INTO meeting_ai_prompts ( + user_email, include_title_line, include_attendees, include_summary, include_action_items, include_checklist, custom_instructions + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (user_email) DO UPDATE SET + include_title_line = EXCLUDED.include_title_line, + include_attendees = EXCLUDED.include_attendees, + include_summary = EXCLUDED.include_summary, + include_action_items = EXCLUDED.include_action_items, + include_checklist = EXCLUDED.include_checklist, + custom_instructions = EXCLUDED.custom_instructions, + updated_at = NOW()`, + [ + email, + fields.includeTitleLine, + fields.includeAttendees, + fields.includeSummary, + fields.includeActionItems, + true, + fields.customInstructions || null, + ] + ); + const r = await pgPool.query(`SELECT * FROM meeting_ai_prompts WHERE user_email = $1`, [email]); + return r.rows?.[0] || null; + } + return withFileLock(async () => { + const data = await readFileStore(); + const t = nowIso(); + const prev = data.prompts[email] || defaultPromptRow(email); + data.prompts[email] = { + ...prev, + include_title_line: fields.includeTitleLine, + include_attendees: fields.includeAttendees, + include_summary: fields.includeSummary, + include_action_items: fields.includeActionItems, + include_checklist: true, + custom_instructions: fields.customInstructions || null, + updated_at: t, + created_at: prev.created_at || t, + }; + await writeFileStore(data); + return await getPromptRow(null, email); + }); +} + +/** + * @param {import("pg").Pool | null} pgPool + */ +async function listMeetings(pgPool, email) { + if (pgPool) { + const r = await pgPool.query( + `SELECT id, user_email, title, source_text, transcript_text, generated_minutes, summary_text, audio_file_path, audio_original_name, chat_model, transcription_model, meeting_date, created_at, updated_at + FROM meeting_ai_meetings WHERE user_email = $1 ORDER BY created_at DESC LIMIT 200`, + [email] + ); + return r.rows || []; + } + const data = await readFileStore(); + return data.meetings + .filter((m) => m.user_email === email) + .sort((a, b) => new Date(b.created_at) - new Date(a.created_at)) + .slice(0, 200); +} + +/** + * @param {import("pg").Pool | null} pgPool + */ +async function getMeeting(pgPool, id, email) { + if (pgPool) { + const r = await pgPool.query(`SELECT * FROM meeting_ai_meetings WHERE id = $1::uuid AND user_email = $2`, [id, email]); + return r.rows?.[0] || null; + } + const data = await readFileStore(); + return data.meetings.find((m) => m.id === id && m.user_email === email) || null; +} + +/** + * @param {import("pg").Pool | null} pgPool + * @returns {Promise<{ row: object | null, audio_file_path?: string }>} + */ +async function deleteMeeting(pgPool, id, email) { + await taskChecklistStore.deleteItemsByMeetingId(pgPool, id, email); + if (pgPool) { + const prev = await pgPool.query( + `SELECT audio_file_path FROM meeting_ai_meetings WHERE id = $1::uuid AND user_email = $2`, + [id, email] + ); + const row = prev.rows?.[0]; + const del = await pgPool.query(`DELETE FROM meeting_ai_meetings WHERE id = $1::uuid AND user_email = $2 RETURNING id`, [ + id, + email, + ]); + return { deleted: !!del.rowCount, audio_file_path: row?.audio_file_path }; + } + return withFileLock(async () => { + const data = await readFileStore(); + const idx = data.meetings.findIndex((m) => m.id === id && m.user_email === email); + if (idx < 0) return { deleted: false, audio_file_path: null }; + const audio = data.meetings[idx].audio_file_path; + data.meetings.splice(idx, 1); + await writeFileStore(data); + return { deleted: true, audio_file_path: audio }; + }); +} + +/** + * @param {import("pg").Pool | null} pgPool + */ +async function insertMeetingText(pgPool, { email, title, sourceText, generated, model, meetingDate }) { + const summaryText = extractMeetingSummary(generated || "", 1200); + if (pgPool) { + const ins = await pgPool.query( + `INSERT INTO meeting_ai_meetings (user_email, title, source_text, transcript_text, generated_minutes, summary_text, chat_model, meeting_date) + VALUES ($1, $2, $3, NULL, $4, $5, $6, $7) RETURNING *`, + [email, title || "제목 없음", sourceText, generated, summaryText || null, model, meetingDate || null] + ); + return ins.rows?.[0]; + } + return withFileLock(async () => { + const data = await readFileStore(); + const t = nowIso(); + const row = rowToMeeting({ + id: uuidv4(), + user_email: email, + title: title || "제목 없음", + source_text: sourceText, + transcript_text: null, + generated_minutes: generated, + summary_text: summaryText || null, + audio_file_path: null, + audio_original_name: null, + chat_model: model, + transcription_model: null, + meeting_date: meetingDate || null, + created_at: t, + updated_at: t, + }); + data.meetings.push(row); + await writeFileStore(data); + return row; + }); +} + +/** + * @param {import("pg").Pool | null} pgPool + */ +async function insertMeetingAudio(pgPool, { email, title, transcript, generated, relPath, originalName, model, whisperModel, meetingDate }) { + const summaryText = extractMeetingSummary(generated || "", 1200); + if (pgPool) { + const ins = await pgPool.query( + `INSERT INTO meeting_ai_meetings (user_email, title, source_text, transcript_text, generated_minutes, summary_text, audio_file_path, audio_original_name, chat_model, transcription_model, meeting_date) + VALUES ($1, $2, NULL, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING *`, + [ + email, + title || "제목 없음", + transcript, + generated, + summaryText || null, + relPath, + (originalName || "").slice(0, 500), + model, + whisperModel, + meetingDate || null, + ] + ); + return ins.rows?.[0]; + } + return withFileLock(async () => { + const data = await readFileStore(); + const t = nowIso(); + const row = rowToMeeting({ + id: uuidv4(), + user_email: email, + title: title || "제목 없음", + source_text: null, + transcript_text: transcript, + generated_minutes: generated, + summary_text: summaryText || null, + audio_file_path: relPath, + audio_original_name: (originalName || "").slice(0, 500), + chat_model: model, + transcription_model: whisperModel, + meeting_date: meetingDate || null, + created_at: t, + updated_at: t, + }); + data.meetings.push(row); + await writeFileStore(data); + return row; + }); +} + +/** + * 회의록에 체크리스트 추출 JSON 스냅샷 저장 (업무 체크리스트 자동 연동) + * @param {import("pg").Pool | null} pgPool + * @param {string} meetingId + * @param {string} email + * @param {object} snapshotObj + */ +function formatMeetingDateIso(d) { + return formatMeetingDateOnly(d); +} + +/** + * 업무 체크리스트 툴팁용: 회의 제목·일자·요약 (회의 id 목록) + * @param {import("pg").Pool | null} pgPool + * @param {string} email + * @param {string[]} ids + * @returns {Promise>} + */ +async function getMeetingMetaForIds(pgPool, email, ids) { + const uid = [...new Set(ids.map((x) => String(x).trim()).filter(Boolean))]; + const map = new Map(); + if (uid.length === 0) return map; + + if (pgPool) { + const r = await pgPool.query( + `SELECT id, title, meeting_date, summary_text, generated_minutes FROM meeting_ai_meetings + WHERE user_email = $1 AND id = ANY($2::uuid[])`, + [email, uid] + ); + for (const row of r.rows || []) { + const id = String(row.id); + const raw = row.summary_text && String(row.summary_text).trim(); + const summary = raw || extractMeetingSummary(row.generated_minutes || "", 800); + map.set(id, { + meetingTitle: (row.title && String(row.title).trim()) || "제목 없음", + meetingDate: formatMeetingDateIso(row.meeting_date), + meetingSummary: summary || "", + }); + } + return map; + } + + const data = await readFileStore(); + for (const m of data.meetings || []) { + if (m.user_email !== email) continue; + const id = String(m.id); + if (!uid.includes(id)) continue; + const raw = m.summary_text && String(m.summary_text).trim(); + const summary = raw || extractMeetingSummary(m.generated_minutes || "", 800); + map.set(id, { + meetingTitle: (m.title && String(m.title).trim()) || "제목 없음", + meetingDate: formatMeetingDateIso(m.meeting_date), + meetingSummary: summary || "", + }); + } + return map; +} + +/** + * 회의록·전사/원문 수동 수정 저장 (요약은 generated_minutes 기준 재계산) + * @param {import("pg").Pool | null} pgPool + * @param {string} meetingId + * @param {string} email + * @param {{ generatedMinutes: string, transcriptText?: string, sourceText?: string }} fields + */ +async function updateMeetingContent(pgPool, meetingId, email, fields) { + const gen = (fields.generatedMinutes ?? "").toString(); + const summaryText = extractMeetingSummary(gen, 1200); + const hasT = Object.prototype.hasOwnProperty.call(fields, "transcriptText"); + const hasS = Object.prototype.hasOwnProperty.call(fields, "sourceText"); + if (pgPool) { + const params = [meetingId, email, gen, summaryText || null]; + let extra = ""; + if (hasT) { + params.push(fields.transcriptText ?? ""); + extra += `, transcript_text = $${params.length}`; + } + if (hasS) { + params.push(fields.sourceText ?? ""); + extra += `, source_text = $${params.length}`; + } + const r = await pgPool.query( + `UPDATE meeting_ai_meetings SET generated_minutes = $3, summary_text = $4${extra}, updated_at = NOW() + WHERE id = $1::uuid AND user_email = $2 + RETURNING *`, + params + ); + return r.rows?.[0] || null; + } + return withFileLock(async () => { + const data = await readFileStore(); + const m = data.meetings.find((x) => x.id === meetingId && x.user_email === email); + if (!m) return null; + m.generated_minutes = gen; + m.summary_text = summaryText || null; + if (hasT) m.transcript_text = fields.transcriptText ?? ""; + if (hasS) m.source_text = fields.sourceText ?? ""; + m.updated_at = nowIso(); + await writeFileStore(data); + return m; + }); +} + +/** + * @param {import("pg").Pool | null} pgPool + * @param {string} meetingId + * @param {string} email + * @param {string} generatedMinutes + */ +async function updateMeetingGeneratedMinutes(pgPool, meetingId, email, generatedMinutes) { + return updateMeetingContent(pgPool, meetingId, email, { generatedMinutes }); +} + +async function updateMeetingChecklistSnapshot(pgPool, meetingId, email, snapshotObj) { + if (pgPool) { + await pgPool.query( + `UPDATE meeting_ai_meetings SET checklist_snapshot = $3::jsonb, updated_at = NOW() + WHERE id = $1::uuid AND user_email = $2`, + [meetingId, email, JSON.stringify(snapshotObj)] + ); + return; + } + await withFileLock(async () => { + const data = await readFileStore(); + const m = data.meetings.find((x) => x.id === meetingId && x.user_email === email); + if (m) { + m.checklist_snapshot = snapshotObj; + m.updated_at = nowIso(); + } + await writeFileStore(data); + }); +} + +module.exports = { + MEETING_AI_FILE, + ensureUserAndDefaultPrompt, + getPromptRow, + upsertPrompt, + listMeetings, + getMeeting, + deleteMeeting, + insertMeetingText, + insertMeetingAudio, + updateMeetingChecklistSnapshot, + updateMeetingContent, + updateMeetingGeneratedMinutes, + getMeetingMetaForIds, +}; diff --git a/lib/meeting-minutes-summary.js b/lib/meeting-minutes-summary.js new file mode 100644 index 0000000..1a0cbb9 --- /dev/null +++ b/lib/meeting-minutes-summary.js @@ -0,0 +1,78 @@ +/** + * 회의록 Markdown에서 업무 체크리스트 툴팁용 짧은 요약 추출 (저장 시 summary_text 보조용) + */ + +function stripMd(s) { + if (!s) return ""; + return s + .replace(/\r\n/g, "\n") + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/__([^_]+)__/g, "$1") + .replace(/^[-*•]\s+/gm, "") + .replace(/^\d+\.\s+/gm, "") + .replace(/\s+/g, " ") + .trim(); +} + +function truncate(s, maxLen) { + const t = (s || "").trim(); + if (t.length <= maxLen) return t; + return t.slice(0, maxLen - 1).trim() + "…"; +} + +/** 체크리스트/액션 등 툴팁에 부적합한 섹션 제목 */ +const SKIP_SECTION = /체크리스트|액션|후속\s*확인|참석|결정\s*사항|action\s*items/i; + +/** 요약으로 쓰기 좋은 섹션 제목 */ +const PREFERRED = /요약|개요|핵심|summary|논의\s*안건|회의\s*내용|discussion/i; + +/** + * @param {string} markdown + * @param {number} [maxLen] + * @returns {string} + */ +function extractMeetingSummary(markdown, maxLen = 800) { + const md = (markdown || "").trim(); + if (!md) return ""; + + const lines = md.split(/\r?\n/); + /** @type {{ h: string, body: string }[]} */ + const sections = []; + for (let i = 0; i < lines.length; ) { + const line = lines[i]; + const hm = line.match(/^#{1,6}\s+(.+)$/); + if (!hm) { + i++; + continue; + } + const heading = hm[1].replace(/\*\*/g, "").trim(); + const body = []; + let j = i + 1; + while (j < lines.length) { + const tj = lines[j].trim(); + if (/^#{1,6}\s+/.test(tj)) break; + body.push(lines[j]); + j++; + } + const joined = body.join("\n").trim(); + if (joined.length >= 4) sections.push({ h: heading, body: joined }); + i = j; + } + + const preferred = sections.find((s) => PREFERRED.test(s.h) && !SKIP_SECTION.test(s.h)); + if (preferred) return truncate(stripMd(preferred.body), maxLen); + + const firstOk = sections.find((s) => !SKIP_SECTION.test(s.h) && s.body.length >= 12); + if (firstOk) return truncate(stripMd(firstOk.body), maxLen); + + let plain = md.replace(/^#{1,6}\s+.*$/gm, "").trim(); + plain = plain.replace(/^[-*•]\s+.*$/gm, "").trim(); + if (plain.length < 30) plain = stripMd(md); + return truncate(stripMd(plain), maxLen); +} + +module.exports = { + extractMeetingSummary, + stripMd, + truncate, +}; diff --git a/lib/meeting-minutes.js b/lib/meeting-minutes.js new file mode 100644 index 0000000..553bd5a --- /dev/null +++ b/lib/meeting-minutes.js @@ -0,0 +1,752 @@ +/** + * 회의록 AI: OpenAI 음성 전사 + Chat Completions 회의록 생성 + */ +const fsSync = require("fs"); +const os = require("os"); +const path = require("path"); +const { execFile } = require("child_process"); +const { promisify } = require("util"); +const execFileAsync = promisify(execFile); + +/** OpenAI audio.transcriptions 요청당 파일 크기 상한(문서 기준 약 25MB) */ +const OPENAI_TRANSCRIPTION_MAX_BYTES = 25 * 1024 * 1024; +/** 단일 요청으로 보낼 때 여유를 둔 상한 */ +const SAFE_SINGLE_REQUEST_BYTES = 24 * 1024 * 1024; +/** ffmpeg 분할 시 세그먼트 길이(초). 16kHz mono PCM 기준 한 세그먼트가 API 한도를 넘지 않도록 설정 */ +const FFMPEG_SEGMENT_SECONDS = 600; +/** + * gpt-4o-mini-transcribe / gpt-4o-transcribe: 요청당 "instructions + audio" 토큰 상한이 있어 + * 길이·파일 크기가 작아도 한 파일 전체를 한 번에 보내면 400이 날 수 있음 → 짧게 분할 전사. + * .env OPENAI_TRANSCRIBE_SEGMENT_SEC 로 조정 가능 (초, 기본 120). 한도 초과 시 30·15 등으로 낮춤. + */ +function getGpt4oTranscribeSegmentSeconds() { + const n = Number(process.env.OPENAI_TRANSCRIBE_SEGMENT_SEC); + if (Number.isFinite(n) && n >= 15 && n <= 600) return Math.floor(n); + return 120; +} + +/** 환경변수 미지정 시 기본 전사 모델 */ +const DEFAULT_TRANSCRIPTION_MODEL = (process.env.OPENAI_WHISPER_MODEL || "gpt-4o-mini-transcribe").trim(); + +/** @deprecated 하위 호환 — DEFAULT_TRANSCRIPTION_MODEL 과 동일 */ +const DEFAULT_WHISPER_MODEL = DEFAULT_TRANSCRIPTION_MODEL; + +/** 화면·API 공통: 허용 전사 모델(OpenAI audio.transcriptions.model) */ +const TRANSCRIPTION_UI_MODELS = new Set(["gpt-4o-mini-transcribe", "gpt-4o-transcribe"]); + +/** + * @param {string} uiModel + * @returns {string} OpenAI audio.transcriptions 에 넣을 model + */ +function resolveTranscriptionApiModel(uiModel) { + const u = (uiModel || DEFAULT_TRANSCRIPTION_MODEL).trim(); + if (TRANSCRIPTION_UI_MODELS.has(u)) return u; + return DEFAULT_TRANSCRIPTION_MODEL; +} + +/** + * DB에 저장된 옵션으로 시스템 프롬프트 구성 + * @param {object} settings - meeting_ai_prompts 행(카멜 또는 스네이크) + */ +/** 액션 아이템 — 정의·목적·What/Who/When (회의록 생성 시스템 프롬프트용) */ +const ACTION_ITEMS_GUIDANCE_LINES = [ + "【액션 아이템(Action Item)】", + "정의: 회의 중 논의된 내용에 따라 구성원들이 완료해야 하는 구체적인 작업, 활동 또는 조치입니다.", + "목적: 누가(Who), 언제(When), 무엇을(What), 필요 시 어떻게(How)까지 명확히 하여 후속 조치를 추적·관리합니다.", + "필수 요소: What(수행해야 할 구체적 작업), Who(작업을 완료할 책임이 있는 특정 개인), When(완료해야 하는 구체적 일시·기한). 원문에 없으면 추측하지 말고 ‘미정’·‘TBD’ 등으로 표기하세요.", + "작성 예시: \"김대리(Who) - 10월 24일까지(When) - OO 프로젝트 보고서 초안 작성 및 참석자 배포(What)\".", + "회의록에는 반드시 별도 마크다운 섹션(예: ## 액션 아이템 또는 ## Action Items)으로 번호 목록·하위 항목·표 등으로 정리하세요. 액션 아이템과 회의 체크리스트 섹션은 서로 구분하세요.", +]; + +/** + * 회의록·담당자 표기 시 참고할 사내 임직원 성명(쉼표 구분, 중복은 로드 시 제거) + * 전사 오타·유사 발음 교정용 — 영어·외국어·외부 인명은 원문 유지 + */ +const MEETING_EMPLOYEE_NAMES_RAW = + "강봉조, 강선규, 강성국, 강성준, 강신균, 강인창, 강종덕, 고영철, 곽병우, 구본엽, 구병철, 권기현, 권순영, 권현철, 김광오, 김광용, 김기덕, 김기연, 김기홍, 김다경, 김대환, 김도균, 김동욱, 김상진, 김성빈, 김성희, 김수지, 김승현, 김의수, 김용현, 김재복, 김정섭, 김정훈, 김태식, 김태우, 김하영, 김항래, 김혜정, 김형규, 김형철, 김효규, 김창열, 남서연, 노은식, 노윤규, 노현주, 류덕현, 박경덕, 박기형, 박대희, 박병찬, 박상욱, 박상현, 박용영, 박정관, 박종철, 박현규, 배문우, 배준영, 서민호, 서원민, 설재민, 성필영, 송제웅, 송지연, 송홍규, 신동균, 신극돈, 신에스더, 신우재, 신화섭, 안종석, 양동환, 양성민, 양소라, 양준삼, 오승우, 오주헌, 우현, 유용일, 유서연, 유성호, 유주상, 유철명, 유휘상, 유웨이, 윤도상, 윤비시아, 윤상혁, 윤지은, 윤종석, 은재민, 이가람, 이강열, 이규민, 이길현, 이동명, 이동석, 이리종철, 이민호, 이병훈, 이사우, 이상규, 이상설, 이상윤, 이상훈, 이성희, 이승묵, 이아정, 이영환, 이재국, 이재동, 이정용, 이정열, 이주승, 이태용, 이석제, 임영규, 임창민, 임현도, 임호균, 장병주, 장소라, 전문호, 정관욱, 정광연, 정대진, 정안용, 정일구, 정재형, 정정주, 정진용, 정인선, 정효진, 조익환, 조정숙, 조현우, 지준경, 진현우, 진형민, 채광석, 천지영, 최봉진, 최상현, 최세영, 최연봉, 최연이, 최원녕, 최인환, 최정운, 최철, 최환성, 한수진, 한준희, 한혜연, 허수연, 허수정, 현진섭, 형성복"; + +function getMeetingEmployeeNamesDeduped() { + const seen = new Set(); + const out = []; + for (const part of MEETING_EMPLOYEE_NAMES_RAW.split(/[,\s]+/)) { + const t = part.trim(); + if (!t || seen.has(t)) continue; + seen.add(t); + out.push(t); + } + return out; +} + +/** @type {string[]|null} */ +let _employeeNamesForPromptCache = null; +function getMeetingEmployeeNamesCommaSeparated() { + if (!_employeeNamesForPromptCache) { + _employeeNamesForPromptCache = getMeetingEmployeeNamesDeduped().join(", "); + } + return _employeeNamesForPromptCache; +} + +const EMPLOYEE_NAME_GUIDANCE_LINES = [ + "【임직원 인명 표기】", + "참석자·발언자·액션 아이템의 담당자(Who)·회의 체크리스트에 언급된 주요 담당자 등, 사람 이름을 쓸 때 원문·전사가 음성 인식 오류·유사 발음으로 틀릴 수 있습니다.", + "아래는 사내 임직원 성명 참고 목록입니다. 문맥상 동일 인물로 확실할 때만, 목록에서 가장 가까운 표기로 통일해 주세요. 억지로 맞추지 마세요.", + "영어·외국어 표기, 또는 위 목록과 완전히 다른 고유 인명(외부 인물·고객 등)은 원문 그대로 두어도 됩니다.", + "임직원 참고 목록: " + getMeetingEmployeeNamesCommaSeparated(), +]; + +/** 회의 체크리스트 — 정의·목적·전·중·후 + 업무 체크리스트 AI 연동 */ +const MEETING_CHECKLIST_GUIDANCE_LINES = [ + "【회의 체크리스트(Meeting Checklist)】", + "정의: 회의가 원활하게 진행되고 목표를 달성할 수 있도록 사전에 준비하거나, 회의 후 검토해야 할 항목들을 목록화한 것입니다.", + "목적: 준비 부족으로 인한 시간 낭비를 방지하고, 회의 전·중·후 전 과정을 구조화하여 효율을 높입니다.", + "원문에서 도출 가능한 범위에서, 회의 전 준비·회의 중 진행 점검·회의 후 확인·후속 등을 [ ] 체크리스트 형태로 나열할 수 있습니다.", + "업무 체크리스트 AI 연동: 위 체크리스트 섹션(예: ## 후속 확인 체크리스트, ## 회의 체크리스트)을 반드시 포함하고, 각 항목은 완료 여부를 파악·표시할 수 있는 형태(예: [ ] 항목, 체크박스)로 작성하세요. ‘사용자 추가 지시’에 체크리스트 관련 문구가 없더라도 이 요구는 동일하게 적용됩니다.", +]; + +function buildMeetingMinutesSystemPrompt(settings) { + if (!settings || typeof settings !== "object") settings = {}; + const includeTitle = settings.includeTitleLine !== false && settings.include_title_line !== false; + const includeAttendees = settings.includeAttendees !== false && settings.include_attendees !== false; + const includeSummary = settings.includeSummary !== false && settings.include_summary !== false; + const includeActionItems = settings.includeActionItems !== false && settings.include_action_items !== false; + const custom = + (settings.customInstructions && String(settings.customInstructions)) || + (settings.custom_instructions && String(settings.custom_instructions)) || + ""; + + const lines = [ + "당신은 사내 회의록을 정리하는 전문가입니다. 입력된 회의 원문(또는 음성 전사)을 바탕으로 읽기 쉬운 회의록을 한국어로 작성합니다.", + ]; + EMPLOYEE_NAME_GUIDANCE_LINES.forEach((line) => lines.push(line)); + lines.push(""); + lines.push("출력 형식 요구:"); + if (includeTitle) lines.push("- 맨 위에 회의 제목 한 줄(입력에 제목이 없으면 내용에서 추정)."); + if (includeAttendees) lines.push("- 참석자·발언자가 드러나면 구분해 정리."); + if (includeSummary) lines.push("- 핵심 요약(불릿 또는 짧은 단락)."); + lines.push("- 불필요한 장황한 서론 없이, 마크다운 구조(##, ###, -, 표)를 사용해 읽기 쉽게 구분하세요."); + lines.push(""); + lines.push("【원문·전사와 회의록 분리】"); + lines.push("- 음성 전사·회의 원문 전체를 회의록 본문에 다시 붙여 넣지 마세요. 원문/전사는 시스템에서 별도 필드(전사 기록·회의 원문)로 이미 보관됩니다."); + lines.push("- ‘스크립트’, ‘스크랩트’(오타), ‘원문 전사’, ‘전사문’, ‘Verbatim’ 등 원문을 통째로 실어 나르는 제목의 섹션을 만들지 마세요. 요약·결정·액션·체크리스트만 회의록에 포함하세요."); + lines.push("- 회의 제목, 참석자, 요약, 결정 사항, 액션 아이템 등은 반드시 마크다운 제목(예: ## 회의 제목, ### 요약)으로 구분해 주세요."); + if (includeActionItems) { + lines.push(""); + ACTION_ITEMS_GUIDANCE_LINES.forEach((line) => lines.push(line)); + } + lines.push(""); + MEETING_CHECKLIST_GUIDANCE_LINES.forEach((line) => lines.push(line)); + lines.push(""); + lines.push("【말미 섹션 금지】"); + lines.push( + "- 회의 체크리스트·액션 아이템 이후에 ‘추가 메모’, ‘확인 필요 사항’, ‘추가 메모 / 확인 필요 사항’ 등 제목의 섹션을 두지 마세요. CSV·표 제안, 설문/스크립트 초안, ‘필요하시면’ 안내 등 말미 부가 안내도 포함하지 않습니다." + ); + lines.push( + "- 체크리스트 섹션을 마지막으로 두고, 그 아래에 시연·피드백 제출 방식(문서/슬랙/이메일) 회신, 액션 우선순위 재정렬·담당·기한 확정 안내, DRM·후보군 추가 작성 제안 같은 **운영/후속 안내 문단**을 붙이지 마세요." + ); + lines.push( + "- ‘추가 권고’, ‘회의록 작성자의 제안’, 엑셀 담당자 배정 템플릿·추적 체크리스트 제안 등 **회의 본문과 무관한 조언·제안 섹션**을 두지 마세요." + ); + if (custom.trim()) { + lines.push(""); + lines.push("사용자 추가 지시:"); + lines.push(custom.trim()); + } + return lines.join("\n"); +} + +/** + * 스크립트/스크랩트(오타) 등 원문 통째 반복 섹션의 제목인지 (마크다운 # 제목) + * @param {string} title + */ +function isVerbatimScriptSectionTitle(title) { + const t = (title || "").trim(); + if (!t) return false; + if (/^원문\s*전사/.test(t)) return true; + if (/^전사문(\s|$)/.test(t)) return true; + if (/^Verbatim/i.test(t)) return true; + if (/^Full\s+transcript/i.test(t)) return true; + if (/^스크랩트/i.test(t)) return scriptSectionRestIsVerbatim(t, /^스크랩트/i); + if (/^스크립트/i.test(t)) return scriptSectionRestIsVerbatim(t, /^스크립트/i); + return false; +} + +function scriptSectionRestIsVerbatim(fullTitle, prefixRe) { + const m = prefixRe.exec(fullTitle); + if (!m) return false; + const rest = fullTitle.slice(m[0].length).trim(); + if (!rest) return true; + if (/^[\(:(]/.test(rest)) return true; + if (/^(검토|논의|요약|개선|작성|확인|점검)(\b|[\s,.])/i.test(rest)) return false; + return true; +} + +/** + * # 없이 한 줄로만 쓴 스크립트/스크랩트 블록 시작인지 + * @param {string} line + */ +function isPlainScriptSectionStartLine(line) { + const raw = String(line || "").trim(); + if (!raw) return false; + if (/^#{1,6}\s/.test(raw)) return false; + const t = raw.replace(/^\*\*\s*|\s*\*\*$/g, "").trim(); + if (/^스크랩트/i.test(t)) return scriptSectionRestIsVerbatim(t, /^스크랩트/i); + if (/^스크립트/i.test(t)) return scriptSectionRestIsVerbatim(t, /^스크립트/i); + return false; +} + +/** 스크립트 블록 다음에 오는 일반 회의록 단락(제목 없이 시작하는 경우)에서 스킵 중단 */ +function isLikelyMinutesSectionPlainLine(line) { + const t = String(line || "").trim(); + if (!t) return false; + if (/^#{1,6}\s/.test(t)) return false; + return ( + /^회의\s*제목\s*[::]/.test(t) || + /^참석자\s*[\((::]/.test(t) || + /^요약\s*[\((]/.test(t) || + /^논의\s*안건/.test(t) || + /^논의\s*요약/.test(t) || + /^결정\s*사항/.test(t) || + /^액션\s*아이템/.test(t) || + /^회의\s*체크리스트/.test(t) || + /^후속\s*확인/.test(t) || + /^회의\s*개요/.test(t) || + /^회의\s*일시/.test(t) || + /^목적\s*[::]/.test(t) + ); +} + +/** + * 회의록 마크다운에서 원문/전사를 반복하는 섹션 제거 (전사·원문은 별도 필드에 있음) + * @param {string} markdown + * @returns {string} + */ +function stripVerbatimScriptSections(markdown) { + const text = String(markdown || ""); + const lines = text.split("\n"); + const out = []; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + const hm = /^(#{1,6})\s+(.+)$/.exec(line); + if (hm && isVerbatimScriptSectionTitle(hm[2])) { + const level = hm[1].length; + i++; + while (i < lines.length) { + const L = lines[i]; + if (isLikelyMinutesSectionPlainLine(L)) break; + const th = /^(#{1,6})\s+(.+)$/.exec(L); + if (th) { + const lv = th[1].length; + if (lv < level) break; + if (lv === level && !isVerbatimScriptSectionTitle(th[2])) break; + } + i++; + } + continue; + } + if (isPlainScriptSectionStartLine(line)) { + i++; + while (i < lines.length) { + const L = lines[i]; + if (isLikelyMinutesSectionPlainLine(L)) break; + const t = L.trim(); + if (/^#{1,6}\s/.test(t)) break; + i++; + } + continue; + } + out.push(line); + i++; + } + return out.join("\n").replace(/\n{3,}/g, "\n\n").trim(); +} + +/** + * 구형/비마크다운 회의록: '회의 제목:', '참석자', '요약' 등 단락을 ## 제목으로 승격해 뷰에서 제목 크기가 나오게 함 + * @param {string} markdown + * @returns {string} + */ +function enhanceMeetingMinutesHeadingLines(markdown) { + const lines = String(markdown || "").split("\n"); + const out = []; + const headingRes = [ + /^회의\s*제목\s*[::]/, + /^참석자\s*[\((::]/, + /^참석자\s*$/i, + /^요약\s*[\((]/, + /^논의\s*안건/, + /^논의\s*요약/, + /^결정\s*사항/, + /^액션\s*아이템/, + /^회의\s*체크리스트/, + /^후속\s*확인/, + /^회의\s*개요/, + /^회의\s*일시/, + /^목적\s*[::]/, + ]; + for (const line of lines) { + const trimmedEnd = line.trimEnd(); + const t = trimmedEnd.trim(); + if (!t) { + out.push(line); + continue; + } + if (/^#{1,6}\s/.test(t)) { + out.push(line); + continue; + } + if (/^\s*[-*•]|^\s*\d+[.)]\s/.test(line)) { + out.push(line); + continue; + } + if (/^>\s/.test(t)) { + out.push(line); + continue; + } + let asHeading = false; + for (const re of headingRes) { + if (re.test(t)) { + asHeading = true; + break; + } + } + if (asHeading) { + out.push("## " + t); + } else { + out.push(line); + } + } + return out.join("\n"); +} + +/** + * 체크리스트 이후 말미에 붙는 제목(추가 메모·추가 권고 등) + * @param {string} title + */ +function isAdditionalNotesSectionTitle(title) { + const t = (title || "").trim(); + if (!t) return false; + if (/추가\s*메모/i.test(t)) return true; + if (/확인\s*필요\s*사항/i.test(t) && /(추가|메모|\/\s*확인)/i.test(t)) return true; + return false; +} + +function isTrailingJunkSectionTitle(title) { + if (isAdditionalNotesSectionTitle(title)) return true; + const t = (title || "").trim(); + if (!t) return false; + if (/추가\s*권고/i.test(t)) return true; + if (/회의록\s*작성자의\s*제안/i.test(t)) return true; + return false; +} + +function isPlainTrailingJunkSectionStartLine(line) { + const t = String(line || "").trim(); + if (!t || /^#{1,6}\s/.test(t)) return false; + if (/^\s*[-*•\d]/.test(line)) return false; + if (/추가\s*메모/i.test(t)) return true; + if (/확인\s*필요\s*사항/i.test(t) && /추가\s*메모|메모\s*\//i.test(t)) return true; + if (/추가\s*권고/i.test(t)) return true; + if (/회의록\s*작성자의\s*제안/i.test(t)) return true; + return false; +} + +function stripTrailingJunkSectionsFromStart(markdown) { + const lines = String(markdown || "").split("\n"); + const out = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const hm = /^(#{1,6})\s+(.+)$/.exec(line); + if (hm && isTrailingJunkSectionTitle(hm[2])) { + break; + } + if (isPlainTrailingJunkSectionStartLine(line)) { + break; + } + out.push(line); + } + return out.join("\n").replace(/\n{3,}/g, "\n\n").trim(); +} + +/** ## 회의 체크리스트 등 마지막 체크리스트 제목 */ +function isMeetingChecklistSectionTitle(title) { + const t = (title || "").trim(); + if (!t) return false; + if (/회의\s*체크리스트|후속\s*확인\s*체크리스트|후속\s*체크리스트/i.test(t)) return true; + if (/^체크리스트\s*[\((]/.test(t)) return true; + if (/^체크리스트\s*$/i.test(t)) return true; + return false; +} + +/** 체크리스트 항목 뒤에 붙는 운영/후속 안내 문단(제거 대상) */ +function isPostChecklistBoilerplateLine(t) { + const s = String(t || "").trim(); + if (!s) return false; + if (/필요\s*시\s*시연/i.test(s)) return true; + if (/필요\s*시\s*위\s*액션\s*아이템별/i.test(s)) return true; + if (/피드백\s*제출\s*방식/i.test(s) && /(문서|슬랙|이메일)/i.test(s)) return true; + if (/우선순위\s*\(\s*긴급/i.test(s)) return true; + if (/지금\s*바로\s*준비해/i.test(s)) return true; + if (/DRM\s*파일\s*리스트/i.test(s) && /(작성|범위)/i.test(s)) return true; + if (/1\s*~\s*2팀\s*후보군|후보군\s*제안/i.test(s)) return true; + if (/담당자와\s*구체\s*기한/i.test(s) && /Timeline|타임라인/i.test(s)) return true; + if (/추가\s*메모/i.test(s) && /확인\s*필요/i.test(s)) return true; + if (/^추가\s*메모|^추가\s*메모\s*\//i.test(s)) return true; + if (/^확인\s*필요\s*사항/i.test(s)) return true; + if (/원하시면\s*각\s*액션\s*아이템/i.test(s)) return true; + if (/담당자\s*배정\s*템플릿/i.test(s)) return true; + if (/추적용\s*체크리스트/i.test(s)) return true; + if (/엑셀\s*형식/i.test(s) && /(템플릿|배정|체크리스트)/i.test(s)) return true; + if (/어떤\s*항목부터\s*우선\s*정리/i.test(s)) return true; + if (/추가\s*권고/i.test(s) && /(회의록|제안|작성자)/i.test(s)) return true; + return false; +} + +/** + * 마지막 「회의 체크리스트」 ## 섹션 이후의 말미(안내 문단·추가 섹션) 제거 + * @param {string} markdown + * @returns {string} + */ +function stripTrailingAfterMeetingChecklistSection(markdown) { + const lines = String(markdown || "").split("\n"); + let startIdx = -1; + for (let i = 0; i < lines.length; i++) { + const hm = /^(##)\s+(.+)$/.exec(lines[i].trimEnd()); + if (!hm) continue; + if (isMeetingChecklistSectionTitle(hm[2])) { + startIdx = i; + } + } + if (startIdx < 0) return markdown; + + const out = lines.slice(0, startIdx + 1); + let seenListAfterChecklist = false; + for (let i = startIdx + 1; i < lines.length; i++) { + const line = lines[i]; + const t = line.trim(); + if (!t) { + out.push(line); + continue; + } + if (/^#{1,6}\s/.test(t)) { + const titleOnly = t.replace(/^#+\s+/, "").trim(); + if (isTrailingJunkSectionTitle(titleOnly)) { + break; + } + const level = (t.match(/^(#+)/) || [""])[0].length; + if (level <= 2) { + if (isMeetingChecklistSectionTitle(titleOnly)) { + out.push(line); + continue; + } + break; + } + out.push(line); + continue; + } + if (/^\s*[-*•]/.test(line) || /^\s*\d+[.)]\s/.test(line)) { + seenListAfterChecklist = true; + out.push(line); + continue; + } + if (/^\s*\|/.test(line)) { + out.push(line); + continue; + } + if (/^>\s/.test(t)) { + out.push(line); + continue; + } + if (isPlainTrailingJunkSectionStartLine(line)) { + break; + } + if (seenListAfterChecklist && isPostChecklistBoilerplateLine(t)) { + break; + } + out.push(line); + } + return out.join("\n").replace(/\n{3,}/g, "\n\n").trim(); +} + +/** + * 문서 전체에서 알려진 말미 안내 한 줄 제거(체크리스트 밖에 남은 경우) + * @param {string} markdown + * @returns {string} + */ +function removeKnownBoilerplateLines(markdown) { + return String(markdown || "") + .split("\n") + .filter((line) => { + const t = line.trim(); + if (!t) return true; + if (isPostChecklistBoilerplateLine(t)) return false; + return true; + }) + .join("\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +/** + * API·저장·생성 공통: 스크립트 제거 → 체크리스트까지만 → 말미 안내 제거 → 말미 섹션(추가 메모·추가 권고 등) 제거 → 제목 승격 + * @param {string} markdown + * @returns {string} + */ +function prepareMeetingMinutesForApi(markdown) { + let md = stripVerbatimScriptSections(markdown); + md = stripTrailingAfterMeetingChecklistSection(md); + md = removeKnownBoilerplateLines(md); + md = stripTrailingJunkSectionsFromStart(md); + return enhanceMeetingMinutesHeadingLines(md); +} + +/** + * @param {import("openai").default} openai + * @param {string} filePath + * @param {string} [uiModel] - gpt-4o-mini-transcribe | gpt-4o-transcribe + */ +async function transcribeMeetingAudioOnce(openai, filePath, uiModel = DEFAULT_TRANSCRIPTION_MODEL) { + const apiModel = resolveTranscriptionApiModel(uiModel); + const stream = fsSync.createReadStream(filePath); + /** gpt-4o 전사 계열은 문서상 response_format 이 json 제한인 경우가 많음 */ + const isGpt4oStyleTranscribe = + apiModel.startsWith("gpt-4o") && apiModel.includes("transcribe") && !apiModel.includes("diarize"); + const params = { + file: stream, + model: apiModel, + language: "ko", + }; + if (isGpt4oStyleTranscribe) { + params.response_format = "json"; + } + const transcription = await openai.audio.transcriptions.create(params); + const raw = transcription?.text; + return (typeof raw === "string" ? raw : "").trim(); +} + +/** + * 16kHz mono PCM WAV로 분할(세그먼트당 OpenAI 한도 이하). 서버에 ffmpeg 필요. + * @param {string} inputPath + * @param {number} [segmentSeconds] - 기본 FFMPEG_SEGMENT_SECONDS (gpt-4o 전사는 더 짧게) + * @returns {{ files: string[], tmpDir: string }} + */ +async function ffmpegSplitAudioForTranscription(inputPath, segmentSeconds) { + const seg = + typeof segmentSeconds === "number" && segmentSeconds >= 15 && segmentSeconds <= 3600 + ? segmentSeconds + : FFMPEG_SEGMENT_SECONDS; + const tmpDir = fsSync.mkdtempSync(path.join(os.tmpdir(), "mm-tr-")); + const outPattern = path.join(tmpDir, "seg_%03d.wav"); + try { + await execFileAsync("ffmpeg", [ + "-hide_banner", + "-loglevel", + "error", + "-y", + "-i", + inputPath, + "-f", + "segment", + "-segment_time", + String(seg), + "-reset_timestamps", + "1", + "-ac", + "1", + "-ar", + "16000", + "-acodec", + "pcm_s16le", + outPattern, + ]); + } catch (e) { + try { + fsSync.rmSync(tmpDir, { recursive: true, force: true }); + } catch (_) { + /* ignore */ + } + const code = e && e.code; + if (code === "ENOENT") { + throw new Error( + "25MB를 초과하는 음성은 ffmpeg로 분할해 전사합니다. 서버에 ffmpeg가 설치되어 PATH에 있어야 합니다. " + ); + } + throw new Error((e && e.message) || "ffmpeg 음성 분할에 실패했습니다."); + } + const files = fsSync + .readdirSync(tmpDir) + .filter((f) => /^seg_\d+\.wav$/.test(f)) + .sort() + .map((f) => path.join(tmpDir, f)); + if (!files.length) { + try { + fsSync.rmSync(tmpDir, { recursive: true, force: true }); + } catch (_) { + /* ignore */ + } + throw new Error("ffmpeg 분할 결과가 비어 있습니다."); + } + for (const f of files) { + if (fsSync.statSync(f).size > OPENAI_TRANSCRIPTION_MAX_BYTES) { + try { + fsSync.rmSync(tmpDir, { recursive: true, force: true }); + } catch (_) { + /* ignore */ + } + throw new Error("분할된 세그먼트가 여전히 너무 큽니다. 관리자에게 문의하세요."); + } + } + return { files, tmpDir }; +} + +/** + * @param {import("openai").default} openai + * @param {string} filePath + * @param {string} [uiModel] + * @param {number} [segmentSeconds] - ffmpeg 분할 길이(초) + */ +async function transcribeMeetingAudioChunked(openai, filePath, uiModel, segmentSeconds) { + const { files, tmpDir } = await ffmpegSplitAudioForTranscription(filePath, segmentSeconds); + try { + const parts = []; + for (const fp of files) { + const text = await transcribeMeetingAudioOnce(openai, fp, uiModel); + parts.push(text); + } + return parts.join("\n\n").trim(); + } finally { + try { + fsSync.rmSync(tmpDir, { recursive: true, force: true }); + } catch (_) { + /* ignore */ + } + } +} + +/** + * 단일 파일 전사. OpenAI 요청당 한도(약 25MB)를 넘으면 ffmpeg로 분할 후 순차 전사. + * gpt-4o 전사 계열은 오디오 토큰 상한으로, 용량이 작아도 전체를 한 요청에 넣으면 400이 날 수 있어 짧은 구간으로 분할한다. + * @param {import("openai").default} openai + * @param {string} filePath + * @param {string} [uiModel] - gpt-4o-mini-transcribe | gpt-4o-transcribe + */ +async function transcribeMeetingAudio(openai, filePath, uiModel = DEFAULT_TRANSCRIPTION_MODEL) { + const apiModel = resolveTranscriptionApiModel(uiModel); + const isGpt4oStyleTranscribe = + apiModel.startsWith("gpt-4o") && apiModel.includes("transcribe") && !apiModel.includes("diarize"); + const size = fsSync.statSync(filePath).size; + const gpt4oSeg = getGpt4oTranscribeSegmentSeconds(); + + if (isGpt4oStyleTranscribe) { + return transcribeMeetingAudioChunked(openai, filePath, uiModel, gpt4oSeg); + } + + if (size <= SAFE_SINGLE_REQUEST_BYTES) { + return transcribeMeetingAudioOnce(openai, filePath, uiModel); + } + return transcribeMeetingAudioChunked(openai, filePath, uiModel); +} + +/** + * @param {import("openai").default} openai + * @param {object} opts + * @param {string} opts.systemPrompt + * @param {string} opts.userContent + * @param {string} opts.uiModel - gpt-5-mini | gpt-5.4 + * @param {(m: string) => string} opts.resolveApiModel + */ +async function generateMeetingMinutes(openai, { systemPrompt, userContent, uiModel, resolveApiModel }) { + const apiModel = resolveApiModel(uiModel || "gpt-5-mini"); + const completion = await openai.chat.completions.create({ + model: apiModel, + messages: [ + { role: "system", content: systemPrompt }, + { + role: "user", + content: `아래는 회의 원문 또는 전사입니다. 위 지시에 맞게 회의록을 작성해 주세요.\n\n---\n\n${userContent}`, + }, + ], + }); + const raw = (completion.choices?.[0]?.message?.content || "").trim(); + return prepareMeetingMinutesForApi(raw); +} + +const CHECKLIST_EXTRACT_SYSTEM = `You extract actionable work items from Korean meeting minutes (Markdown). +Return ONLY valid JSON with this exact shape (no markdown fence, no extra keys): +{"items":[{"title":"string","detail":"string or empty","assignee":"string or empty","dueNote":"string or empty"}]} +Priority (Action Items are as important as checklists — they are real work): +1) **Action Items / 액션 아이템** sections: Every numbered line (1. 2. 3. or 1) 2) 3)) MUST become a **separate** item. Title = the numbered line’s main task name; put 담당/기한/할 일 lines into assignee, dueNote, detail as appropriate. +2) **회의 체크리스트 / 후속 확인 체크리스트** sections: Each [ ] or bullet follow-up as one item. +3) If an action item has 담당:, 기한:, 할 일: sub-lines, map them to assignee, dueNote, detail. +- For assignee (담당자): follow the spelling already used in the minutes. Korean person names should match the company roster implied in the meeting minutes system prompt when the minutes corrected them; do not invent new spellings. +- Do not merge multiple numbered actions into one item. +- Deduplicate only exact duplicate titles. +- If nothing found, return {"items":[]}. +- All human-readable text in Korean.`; + +/** + * 회의록 Markdown에서 구조화 체크리스트 추출 (JSON). 업무 체크리스트 DB 자동 반영용. + * @param {import("openai").default} openai + * @param {{ minutesMarkdown: string, uiModel: string, resolveApiModel: (m: string) => string }} opts + * @returns {Promise>} + */ +async function extractChecklistStructured(openai, { minutesMarkdown, uiModel, resolveApiModel }) { + const apiModel = resolveApiModel(uiModel || "gpt-5-mini"); + const text = (minutesMarkdown || "").trim(); + if (!text) return []; + const completion = await openai.chat.completions.create({ + model: apiModel, + response_format: { type: "json_object" }, + messages: [ + { role: "system", content: CHECKLIST_EXTRACT_SYSTEM }, + { + role: "user", + content: + "아래 회의록에서 업무 항목을 JSON으로 추출하세요.\n" + + "액션 아이템(번호 목록)과 회의 체크리스트 항목을 모두 포함하고, 번호마다 별도 item으로 나누세요.\n\n" + + "---\n\n" + + text, + }, + ], + }); + const raw = (completion.choices?.[0]?.message?.content || "{}").trim(); + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + return []; + } + const arr = Array.isArray(parsed.items) ? parsed.items : []; + return arr + .map((x) => { + const title = String(x.title || x.Title || "").trim(); + const detail = String(x.detail ?? x.Detail ?? "").trim(); + const assigneeRaw = x.assignee ?? x.Assignee ?? x.담당; + const dueRaw = x.dueNote ?? x.due_note ?? x.DueNote ?? x.기한; + return { + title, + detail, + assignee: assigneeRaw != null && String(assigneeRaw).trim() ? String(assigneeRaw).trim() : null, + due_note: dueRaw != null && String(dueRaw).trim() ? String(dueRaw).trim() : null, + completed: false, + }; + }) + .filter((x) => x.title.length > 0); +} + +module.exports = { + buildMeetingMinutesSystemPrompt, + stripVerbatimScriptSections, + stripTrailingJunkSectionsFromStart, + stripAdditionalNotesSection: stripTrailingJunkSectionsFromStart, + prepareMeetingMinutesForApi, + enhanceMeetingMinutesHeadingLines, + transcribeMeetingAudio, + generateMeetingMinutes, + extractChecklistStructured, + DEFAULT_TRANSCRIPTION_MODEL, + DEFAULT_WHISPER_MODEL, + TRANSCRIPTION_UI_MODELS, + resolveTranscriptionApiModel, +}; diff --git a/lib/ops-state.js b/lib/ops-state.js new file mode 100644 index 0000000..9a4efee --- /dev/null +++ b/lib/ops-state.js @@ -0,0 +1,35 @@ +/** + * OPS_STATE: DEV(개발), PROD(운영·임직원 이메일 로그인), SUPER(데모·제한 완화). + * 과거 값 REAL 은 PROD 와 동일하게 처리합니다. + */ +function normalizeOpsState() { + const raw = (process.env.OPS_STATE || "DEV").trim().toUpperCase(); + if (raw === "REAL") return "PROD"; + if (raw === "DEV" || raw === "PROD" || raw === "SUPER") return raw; + return "DEV"; +} + +function isOpsStateDev() { + return normalizeOpsState() === "DEV"; +} + +function isOpsStateProd() { + return normalizeOpsState() === "PROD"; +} + +function isOpsStateSuper() { + return normalizeOpsState() === "SUPER"; +} + +/** 임직원 이메일(@xavis.co.kr) 매직 링크 로그인을 강제하는 모드 (REAL 구 값 포함) */ +function isOpsProdMode() { + return isOpsStateProd(); +} + +module.exports = { + normalizeOpsState, + isOpsStateDev, + isOpsStateProd, + isOpsStateSuper, + isOpsProdMode, +}; diff --git a/lib/parse-checklist-from-minutes.js b/lib/parse-checklist-from-minutes.js new file mode 100644 index 0000000..cf9b465 --- /dev/null +++ b/lib/parse-checklist-from-minutes.js @@ -0,0 +1,354 @@ +/** + * 회의록 generated_minutes(Markdown)에서 체크리스트·액션 섹션 추출 + * LLM 출력은 ##/### 혼용, 번호 접두, 굵게 표기 등이 섞이므로 규칙을 넓게 둔다. + */ + +const CHECKLIST_HEADINGS = [ + /^##\s+후속\s*확인\s*체크리스트\s*$/i, + /^##\s+체크리스트\s*\([^)]*\)\s*$/i, + /^##\s+체크리스트\s*$/i, + /^##\s+후속\s*확인\s*$/i, +]; + +const ACTION_HEADINGS = [ + /^##\s+Action\s+Items\s*$/i, + /^##\s+Action\s+Item\s*$/i, + /^##\s+액션\s*아이템\s*$/i, + /^##\s+액션\s*$/i, +]; + +function stripMd(s) { + if (!s) return ""; + return s + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/__([^_]+)__/g, "$1") + .trim(); +} + +/** + * 제목 줄에서 비교용 텍스트 (굵게·번호 접두 제거) + * @param {string} line + */ +function normalizeHeadingText(line) { + return line + .trim() + .replace(/^#{1,6}\s+/, "") + .replace(/^\d+[.)]\s*/, "") + .replace(/\*\*/g, "") + .replace(/\s+/g, " ") + .trim() + .toLowerCase(); +} + +/** + * @param {string} trimmed — 한 줄 (마크다운 제목 가능) + */ +function isChecklistHeadingLine(trimmed) { + const t = normalizeHeadingText(trimmed); + if (!t) return false; + if (/액션\s*아이템/.test(t) && /체크리스트/.test(t)) return true; + if (/^액션\s*아이템\s*$/.test(t) || /^action\s+items\s*$/.test(t)) return false; + if (t.includes("체크리스트")) return true; + if (t.includes("후속") && (t.includes("확인") || t.includes("체크"))) return true; + if (t.includes("follow") && t.includes("check")) return true; + if (t.includes("follow-up") || t.includes("follow up")) return true; + return false; +} + +/** + * @param {string} trimmed + */ +function isActionHeadingLine(trimmed) { + const t = normalizeHeadingText(trimmed); + if (!t) return false; + if (/^액션\s*아이템\s*$/.test(t) || /^action\s+items\s*$/i.test(t) || /^action\s+item\s*$/i.test(t)) return true; + if (t.includes("액션") && (t.includes("아이템") || t.includes("항목"))) return true; + if (t.includes("action") && (t.includes("item") || t.includes("items"))) return true; + return false; +} + +/** + * @param {string} text + * @param {(trimmedLine: string) => boolean} predicate + * @returns {string|null} + */ +function extractSectionByHeadingPredicate(text, predicate) { + const lines = text.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trim(); + const hm = trimmed.match(/^(#{1,6})(\s+.+)$/); + if (!hm) continue; + const level = hm[1].length; + if (!predicate(trimmed)) continue; + const body = []; + for (let j = i + 1; j < lines.length; j++) { + const tj = lines[j].trim(); + const nextHm = tj.match(/^(#{1,6})\s+/); + if (nextHm) { + const nextLevel = nextHm[1].length; + if (nextLevel <= level) break; + } + body.push(lines[j]); + } + const joined = body.join("\n").trim(); + if (joined.length) return joined; + } + return null; +} + +/** + * 다음 ## 제목 전까지 본문 추출 (레거시: ## 만 구분) + * @param {string} text + * @param {RegExp[]} headingMatchers + * @returns {string|null} + */ +function extractSectionAfterHeading(text, headingMatchers) { + const lines = text.split(/\r?\n/); + for (let i = 0; i < lines.length; i++) { + const trimmed = lines[i].trim(); + for (const re of headingMatchers) { + if (re.test(trimmed)) { + const body = []; + for (let j = i + 1; j < lines.length; j++) { + if (/^##\s+/.test(lines[j].trim())) break; + body.push(lines[j]); + } + const joined = body.join("\n").trim(); + return joined.length ? joined : null; + } + } + } + return null; +} + +/** + * @typedef {{ title: string, detail: string, assignee: string|null, due_note: string|null, completed: boolean }} ParsedItem + */ + +/** + * @param {string} body + * @returns {ParsedItem[]} + */ +function parseBulletItems(body) { + if (!body || !body.trim()) return []; + const lines = body.split(/\r?\n/); + /** @type {ParsedItem[]} */ + const items = []; + /** @type {ParsedItem|null} */ + let cur = null; + const flush = () => { + if (cur) { + items.push(cur); + cur = null; + } + }; + for (const raw of lines) { + const t = raw.trim(); + if (!t) continue; + let m = t.match(/^\s*[-*•]\s+\[([ xX✓])\]\s*(.+)$/); + if (m) { + flush(); + items.push({ + title: stripMd(m[2].trim()), + detail: "", + assignee: null, + due_note: null, + completed: /[xX✓]/.test(m[1]), + }); + continue; + } + m = t.match(/^\s*\[\s*([ xX✓])\s*\]\s+(.+)$/); + if (m) { + flush(); + items.push({ + title: stripMd(m[2].trim()), + detail: "", + assignee: null, + due_note: null, + completed: /[xX✓]/.test(m[1]), + }); + continue; + } + m = t.match(/^\s*[☐☑✓✔]\s*(.+)$/); + if (m) { + flush(); + const done = /^[☑✓✔]/.test(t.trim()); + items.push({ + title: stripMd(m[1].trim()), + detail: "", + assignee: null, + due_note: null, + completed: done, + }); + continue; + } + m = t.match(/^\s*[-*•]\s+(.+)$/); + if (m) { + flush(); + cur = { title: stripMd(m[1].trim()), detail: "", assignee: null, due_note: null, completed: false }; + continue; + } + m = t.match(/^\s*\d+\.\s+(.+)$/); + if (m) { + flush(); + cur = { title: stripMd(m[1].trim()), detail: "", assignee: null, due_note: null, completed: false }; + continue; + } + if (cur) { + cur.detail += (cur.detail ? "\n" : "") + t; + } else if (items.length) { + const last = items[items.length - 1]; + last.detail += (last.detail ? "\n" : "") + t; + } + } + flush(); + return items.filter((x) => x.title.length > 0); +} + +/** + * 액션 아이템 번호 목록 블록 (담당/기한/할 일) + * @param {string} body + * @returns {ParsedItem[]} + */ +function refineActionLines(detailText) { + let assignee = null; + let due_note = null; + const rest = []; + for (const line of (detailText || "").split(/\r?\n/)) { + const r = line.trim(); + if (!r) continue; + const t = r.replace(/^\*\s+/, "").trim(); + if (/담당\s*:/i.test(t)) { + const m = t.match(/담당\s*:\s*(.+)$/i); + if (m) assignee = stripMd(m[1].replace(/\*\*/g, "").replace(/^\*+|\*+$/g, "").trim()); + continue; + } + if (/기한\s*:/i.test(t)) { + const m = t.match(/기한\s*:\s*(.+)$/i); + if (m) due_note = stripMd(m[1].replace(/\*\*/g, "").replace(/^\*+|\*+$/g, "").trim()); + continue; + } + if (/할\s*일\s*:/i.test(t)) { + const m = t.match(/할\s*일\s*:\s*(.+)$/i); + if (m) rest.push(stripMd(m[1])); + continue; + } + rest.push(r); + } + return { assignee, due_note, detail: rest.join("\n").trim() }; +} + +function parseNumberedActionBlocks(body) { + if (!body || !body.trim()) return []; + const lines = body.split(/\r?\n/); + /** @type {ParsedItem[]} */ + const out = []; + let i = 0; + while (i < lines.length) { + const line = lines[i].trim(); + /** `1. 제목` 또는 `1) 제목` */ + const nm = line.match(/^(\d+)[.)]\s+(.+)$/); + if (nm) { + const title = stripMd(nm[2].trim()); + const rest = []; + i++; + while (i < lines.length) { + const lt = lines[i].trim(); + if (/^\d+[.)]\s+/.test(lt)) break; + if (lt) rest.push(lines[i]); + i++; + } + const rawDetail = rest.join("\n").trim(); + const refined = refineActionLines(rawDetail); + out.push({ + title, + detail: refined.detail, + assignee: refined.assignee, + due_note: refined.due_note, + completed: false, + }); + continue; + } + i++; + } + return out.filter((x) => x.title.length > 0); +} + +/** + * 업무 체크리스트 자동 동기화용: 규칙 기반으로 액션(번호 목록) → 체크리스트 순으로 항목 수집(제목 기준 중복 제거) + * @param {string} markdown + * @returns {ParsedItem[]} + */ +function parseAllRuleBasedWorkItems(markdown) { + const text = (markdown || "").trim(); + if (!text) return []; + const actions = parseItemsFromMinutes(text, "actions"); + const checklist = parseItemsFromMinutes(text, "checklist"); + const seen = new Set(); + /** @type {ParsedItem[]} */ + const out = []; + for (const it of [...actions, ...checklist]) { + const t = (it.title || "").trim().toLowerCase(); + if (!t) continue; + if (seen.has(t)) continue; + seen.add(t); + out.push(it); + } + return out; +} + +/** + * 체크리스트 섹션 본문 찾기: 유연 매칭 → 레거시 정규식 + * @param {string} text + * @returns {string|null} + */ +function extractChecklistSectionBody(text) { + const flexible = extractSectionByHeadingPredicate(text, isChecklistHeadingLine); + if (flexible) return flexible; + return extractSectionAfterHeading(text, CHECKLIST_HEADINGS); +} + +/** + * @param {string} text + * @returns {string|null} + */ +function extractActionSectionBody(text) { + const flexible = extractSectionByHeadingPredicate(text, isActionHeadingLine); + if (flexible) return flexible; + return extractSectionAfterHeading(text, ACTION_HEADINGS); +} + +/** + * @param {string} generatedMinutes + * @param {'checklist'|'actions'} mode + * @returns {ParsedItem[]} + */ +function parseItemsFromMinutes(generatedMinutes, mode = "checklist") { + const text = (generatedMinutes || "").trim(); + if (!text) return []; + if (mode === "actions") { + const section = extractActionSectionBody(text); + if (!section) return []; + const numbered = parseNumberedActionBlocks(section); + if (numbered.length) return numbered; + return parseBulletItems(section); + } + const section = extractChecklistSectionBody(text); + if (!section) return []; + return parseBulletItems(section); +} + +module.exports = { + extractSectionAfterHeading, + extractSectionByHeadingPredicate, + extractChecklistSectionBody, + extractActionSectionBody, + parseBulletItems, + parseNumberedActionBlocks, + parseItemsFromMinutes, + parseAllRuleBasedWorkItems, + CHECKLIST_HEADINGS, + ACTION_HEADINGS, + isChecklistHeadingLine, + isActionHeadingLine, +}; diff --git a/lib/task-checklist-store.js b/lib/task-checklist-store.js new file mode 100644 index 0000000..5717b49 --- /dev/null +++ b/lib/task-checklist-store.js @@ -0,0 +1,323 @@ +/** + * 업무 체크리스트: PostgreSQL 또는 data/meeting-ai-checklist.json 폴백 + */ +const path = require("path"); +const fs = require("fs/promises"); +const { v4: uuidv4 } = require("uuid"); + +const CHECKLIST_FILE = path.join(__dirname, "..", "data", "meeting-ai-checklist.json"); + +let writeChain = Promise.resolve(); + +function withFileLock(fn) { + const p = writeChain.then(() => fn()); + writeChain = p.catch(() => {}); + return p; +} + +function nowIso() { + return new Date().toISOString(); +} + +async function readFileStore() { + try { + const raw = await fs.readFile(CHECKLIST_FILE, "utf8"); + const j = JSON.parse(raw); + if (!j || typeof j !== "object") return { items: [] }; + if (!Array.isArray(j.items)) j.items = []; + return j; + } catch { + return { items: [] }; + } +} + +async function writeFileStore(data) { + const dir = path.dirname(CHECKLIST_FILE); + await fs.mkdir(dir, { recursive: true }); + const tmp = `${CHECKLIST_FILE}.${process.pid}.tmp`; + await fs.writeFile(tmp, JSON.stringify(data, null, 2), "utf8"); + await fs.rename(tmp, CHECKLIST_FILE); +} + +function rowToApi(row) { + if (!row) return null; + return { + id: row.id, + userEmail: row.user_email, + meetingId: row.meeting_id || null, + title: row.title || "", + detail: row.detail || "", + assignee: row.assignee || null, + dueNote: row.due_note || null, + completed: row.completed === true, + completedAt: row.completed_at ? new Date(row.completed_at).toISOString() : null, + completionNote: + row.completion_note == null || !String(row.completion_note).trim() + ? null + : String(row.completion_note), + sortOrder: Number(row.sort_order) || 0, + source: row.source || "manual", + createdAt: row.created_at ? new Date(row.created_at).toISOString() : nowIso(), + updatedAt: row.updated_at ? new Date(row.updated_at).toISOString() : nowIso(), + }; +} + +/** + * @param {import("pg").Pool | null} pgPool + * @param {string} email + * @param {{ completed?: boolean|null, meetingId?: string|null }} [filters] + */ +async function listItems(pgPool, email, filters = {}) { + if (pgPool) { + const params = [email]; + let sql = `SELECT * FROM meeting_ai_checklist_items WHERE user_email = $1`; + if (filters.completed === true) sql += ` AND completed = true`; + else if (filters.completed === false) sql += ` AND completed = false`; + if (filters.meetingId) { + params.push(filters.meetingId); + sql += ` AND meeting_id = $${params.length}::uuid`; + } + sql += ` ORDER BY completed ASC, sort_order ASC, updated_at DESC`; + const r = await pgPool.query(sql, params); + return (r.rows || []).map(rowToApi); + } + const data = await readFileStore(); + let items = data.items.filter((x) => x.user_email === email); + if (filters.completed === true) items = items.filter((x) => x.completed === true); + else if (filters.completed === false) items = items.filter((x) => !x.completed); + if (filters.meetingId) items = items.filter((x) => x.meeting_id === filters.meetingId); + items.sort((a, b) => { + if (a.completed !== b.completed) return a.completed ? 1 : -1; + return new Date(b.updated_at) - new Date(a.updated_at); + }); + return items.map(rowToApi); +} + +/** + * @param {import("pg").Pool | null} pgPool + */ +async function insertItem(pgPool, email, fields) { + const title = (fields.title || "").toString().trim().slice(0, 2000); + if (!title) throw new Error("제목이 필요합니다."); + const detail = (fields.detail || "").toString().slice(0, 8000); + const assignee = fields.assignee != null ? String(fields.assignee).slice(0, 300) : null; + const dueNote = fields.dueNote != null ? String(fields.dueNote).slice(0, 300) : null; + const meetingId = fields.meetingId || null; + const source = fields.source === "imported" ? "imported" : "manual"; + const completed = fields.completed === true; + const sortOrder = Number.isFinite(Number(fields.sortOrder)) ? Number(fields.sortOrder) : 0; + + const completionNote = + fields.completionNote != null && String(fields.completionNote).trim() + ? String(fields.completionNote).slice(0, 8000) + : null; + + if (pgPool) { + const r = await pgPool.query( + `INSERT INTO meeting_ai_checklist_items ( + user_email, meeting_id, title, detail, assignee, due_note, completed, completed_at, sort_order, source, completion_note + ) VALUES ($1, $2::uuid, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING *`, + [ + email, + meetingId, + title, + detail || null, + assignee, + dueNote, + completed, + completed ? new Date() : null, + sortOrder, + source, + completionNote, + ] + ); + return rowToApi(r.rows?.[0]); + } + return withFileLock(async () => { + const data = await readFileStore(); + const t = nowIso(); + const row = { + id: uuidv4(), + user_email: email, + meeting_id: meetingId, + title, + detail: detail || null, + assignee, + due_note: dueNote, + completed, + completed_at: completed ? t : null, + completion_note: completionNote, + sort_order: sortOrder, + source, + created_at: t, + updated_at: t, + }; + data.items.push(row); + await writeFileStore(data); + return rowToApi(row); + }); +} + +/** + * @param {import("pg").Pool | null} pgPool + */ +async function updateItem(pgPool, id, email, fields) { + if (pgPool) { + const cur = await pgPool.query( + `SELECT * FROM meeting_ai_checklist_items WHERE id = $1::uuid AND user_email = $2`, + [id, email] + ); + if (!cur.rows?.[0]) return null; + const row = cur.rows[0]; + const title = fields.title != null ? String(fields.title).trim().slice(0, 2000) : row.title; + const detail = + fields.detail !== undefined + ? fields.detail != null && String(fields.detail).trim() + ? String(fields.detail).slice(0, 8000) + : null + : row.detail; + const assignee = fields.assignee !== undefined ? (fields.assignee ? String(fields.assignee).slice(0, 300) : null) : row.assignee; + const dueNote = fields.dueNote !== undefined ? (fields.dueNote ? String(fields.dueNote).slice(0, 300) : null) : row.due_note; + let completed = row.completed; + let completedAt = row.completed_at; + let completionNote = row.completion_note; + if (fields.completed !== undefined) { + completed = fields.completed === true; + completedAt = completed ? new Date() : null; + } + if (fields.completionNote !== undefined) { + completionNote = + fields.completionNote != null && String(fields.completionNote).trim() + ? String(fields.completionNote).slice(0, 8000) + : null; + } + const r = await pgPool.query( + `UPDATE meeting_ai_checklist_items SET + title = $3, detail = $4, assignee = $5, due_note = $6, + completed = $7, completed_at = $8, completion_note = $9, updated_at = NOW() + WHERE id = $1::uuid AND user_email = $2 + RETURNING *`, + [id, email, title, detail, assignee, dueNote, completed, completedAt, completionNote] + ); + return rowToApi(r.rows?.[0]); + } + return withFileLock(async () => { + const data = await readFileStore(); + const idx = data.items.findIndex((x) => x.id === id && x.user_email === email); + if (idx < 0) return null; + const row = data.items[idx]; + if (fields.title != null) row.title = String(fields.title).trim().slice(0, 2000); + if (fields.detail !== undefined) { + row.detail = + fields.detail != null && String(fields.detail).trim() ? String(fields.detail).slice(0, 8000) : null; + } + if (fields.assignee !== undefined) row.assignee = fields.assignee ? String(fields.assignee).slice(0, 300) : null; + if (fields.dueNote !== undefined) row.due_note = fields.dueNote ? String(fields.dueNote).slice(0, 300) : null; + if (fields.completed !== undefined) { + row.completed = fields.completed === true; + row.completed_at = row.completed ? nowIso() : null; + } + if (fields.completionNote !== undefined) { + row.completion_note = + fields.completionNote != null && String(fields.completionNote).trim() + ? String(fields.completionNote).slice(0, 8000) + : null; + } + row.updated_at = nowIso(); + await writeFileStore(data); + return rowToApi(row); + }); +} + +/** + * @param {import("pg").Pool | null} pgPool + */ +async function deleteItem(pgPool, id, email) { + if (pgPool) { + const r = await pgPool.query(`DELETE FROM meeting_ai_checklist_items WHERE id = $1::uuid AND user_email = $2 RETURNING id`, [ + id, + email, + ]); + return { deleted: !!r.rowCount }; + } + return withFileLock(async () => { + const data = await readFileStore(); + const idx = data.items.findIndex((x) => x.id === id && x.user_email === email); + if (idx < 0) return { deleted: false }; + data.items.splice(idx, 1); + await writeFileStore(data); + return { deleted: true }; + }); +} + +/** + * 회의록 삭제 시 연동: 해당 회의에서 가져온 업무 체크리스트 항목 일괄 삭제 + * @param {import("pg").Pool | null} pgPool + * @param {string} meetingId + * @param {string} email + * @returns {Promise<{ removed: number }>} + */ +async function deleteItemsByMeetingId(pgPool, meetingId, email) { + if (!meetingId) return { removed: 0 }; + if (pgPool) { + const r = await pgPool.query( + `DELETE FROM meeting_ai_checklist_items WHERE meeting_id = $1::uuid AND user_email = $2`, + [meetingId, email] + ); + return { removed: r.rowCount || 0 }; + } + return withFileLock(async () => { + const data = await readFileStore(); + const mid = String(meetingId); + const before = data.items.length; + data.items = data.items.filter((x) => !(String(x.meeting_id || "") === mid && x.user_email === email)); + const removed = before - data.items.length; + if (removed > 0) await writeFileStore(data); + return { removed }; + }); +} + +/** + * 회의록 본문에서 항목 파싱 후 삽입 (중복 title+meeting_id 스킵) + * @param {import("pg").Pool | null} pgPool + * @param {string} email + * @param {string} meetingId + * @param {Array<{ title: string, detail?: string, assignee?: string|null, due_note?: string|null, completed?: boolean }>} parsed + */ +async function insertImportedBatch(pgPool, email, meetingId, parsed) { + const inserted = []; + const existing = await listItems(pgPool, email, { meetingId }); + const keys = new Set(existing.map((e) => `${(e.title || "").trim()}::${e.meetingId || ""}`)); + + let order = existing.length; + for (const p of parsed) { + const title = (p.title || "").trim(); + if (!title) continue; + const key = `${title}::${meetingId}`; + if (keys.has(key)) continue; + keys.add(key); + const item = await insertItem(pgPool, email, { + title, + detail: p.detail || "", + assignee: p.assignee || null, + dueNote: p.due_note || null, + meetingId, + source: "imported", + completed: !!p.completed, + sortOrder: order++, + }); + inserted.push(item); + } + return inserted; +} + +module.exports = { + CHECKLIST_FILE, + listItems, + insertItem, + updateItem, + deleteItem, + deleteItemsByMeetingId, + insertImportedBatch, +}; diff --git a/lib/thumbnail-events-store.js b/lib/thumbnail-events-store.js new file mode 100644 index 0000000..b598a96 --- /dev/null +++ b/lib/thumbnail-events-store.js @@ -0,0 +1,185 @@ +/** + * PPT 썸네일 작업 이벤트 로그 — PostgreSQL 우선, PG 없을 때만 data/thumbnail-events.json 폴백 + */ +const fs = require("fs/promises"); +const { v4: uuidv4 } = require("uuid"); + +/** + * @param {import("pg").QueryResultRow} row + */ +function mapRowToEvent(row) { + if (!row) return null; + return { + id: row.id, + at: row.occurred_at ? new Date(row.occurred_at).toISOString() : null, + type: row.event_type, + lectureId: row.lecture_id || undefined, + lectureTitle: row.lecture_title || undefined, + reason: row.reason || undefined, + force: row.force_flag === true, + queueSizeAfter: row.queue_size_after != null ? Number(row.queue_size_after) : undefined, + retryCount: row.retry_count != null ? Number(row.retry_count) : undefined, + durationMs: row.duration_ms != null ? Number(row.duration_ms) : undefined, + error: row.error_text || undefined, + }; +} + +/** + * @param {import("pg").Pool | null} pgPool + * @param {string} jsonPath + */ +async function readThumbnailEvents(pgPool, jsonPath) { + if (pgPool) { + const r = await pgPool.query( + `SELECT id, occurred_at, event_type, lecture_id, lecture_title, reason, force_flag, + queue_size_after, retry_count, duration_ms, error_text + FROM lecture_thumbnail_events + ORDER BY occurred_at ASC` + ); + return (r.rows || []).map(mapRowToEvent); + } + try { + const raw = await fs.readFile(jsonPath, "utf-8"); + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +/** + * @param {import("pg").Pool} pgPool + * @param {number} keep + */ +async function pruneThumbnailEvents(pgPool, keep) { + const k = Math.max(Number(keep) || 200, 20); + await pgPool.query( + `DELETE FROM lecture_thumbnail_events a + USING ( + SELECT id FROM ( + SELECT id, ROW_NUMBER() OVER (ORDER BY occurred_at DESC) AS rn + FROM lecture_thumbnail_events + ) t WHERE t.rn > $1 + ) d + WHERE a.id = d.id`, + [k] + ); +} + +/** + * @param {import("pg").Pool | null} pgPool + * @param {string} jsonPath + * @param {number} keep + * @param {object} payload + */ +async function appendThumbnailEvent(pgPool, jsonPath, keep, payload) { + const id = uuidv4(); + const at = new Date().toISOString(); + if (pgPool) { + await pgPool.query( + `INSERT INTO lecture_thumbnail_events ( + id, occurred_at, event_type, lecture_id, lecture_title, reason, force_flag, + queue_size_after, retry_count, duration_ms, error_text + ) VALUES ($1, $2::timestamptz, $3, $4::uuid, $5, $6, $7, $8, $9, $10, $11)`, + [ + id, + at, + payload.type || "unknown", + payload.lectureId || null, + payload.lectureTitle || null, + payload.reason || null, + payload.force === true, + payload.queueSizeAfter != null ? payload.queueSizeAfter : null, + payload.retryCount != null ? payload.retryCount : null, + payload.durationMs != null ? payload.durationMs : null, + payload.error || null, + ] + ); + await pruneThumbnailEvents(pgPool, keep); + return; + } + const events = await readThumbnailEvents(null, jsonPath); + events.push({ id, at, ...payload }); + const sliced = events.slice(-Math.max(keep, 20)); + await fs.writeFile(jsonPath, JSON.stringify(sliced, null, 2), "utf-8"); +} + +/** + * @param {import("pg").Pool | null} pgPool + * @param {string} jsonPath + */ +async function clearThumbnailEvents(pgPool, jsonPath) { + if (pgPool) { + await pgPool.query(`DELETE FROM lecture_thumbnail_events`); + return; + } + await fs.writeFile(jsonPath, "[]", "utf-8"); +} + +/** + * 기존 JSON 파일을 한 번 DB로 옮긴 뒤 파일을 백업 이름으로 변경 (PG 사용 시) + * @param {import("pg").Pool | null} pgPool + * @param {string} jsonPath + */ +/** + * @param {import("pg").Pool | null} pgPool + * @param {string} jsonPath + * @param {number} [keep] + */ +async function migrateThumbnailEventsFromJson(pgPool, jsonPath, keep) { + if (!pgPool) return; + const keepN = Math.max(Number(keep) || 200, 20); + let rows; + try { + const raw = await fs.readFile(jsonPath, "utf-8"); + const parsed = JSON.parse(raw); + rows = Array.isArray(parsed) ? parsed : []; + } catch { + return; + } + if (rows.length === 0) return; + + for (const evt of rows) { + if (!evt || !evt.id || !evt.type) continue; + try { + await pgPool.query( + `INSERT INTO lecture_thumbnail_events ( + id, occurred_at, event_type, lecture_id, lecture_title, reason, force_flag, + queue_size_after, retry_count, duration_ms, error_text + ) VALUES ($1::uuid, $2::timestamptz, $3, $4::uuid, $5, $6, $7, $8, $9, $10, $11) + ON CONFLICT (id) DO NOTHING`, + [ + evt.id, + evt.at || new Date().toISOString(), + evt.type, + evt.lectureId || null, + evt.lectureTitle || null, + evt.reason || null, + evt.force === true, + evt.queueSizeAfter != null ? evt.queueSizeAfter : null, + evt.retryCount != null ? evt.retryCount : null, + evt.durationMs != null ? evt.durationMs : null, + evt.error || null, + ] + ); + } catch { + /* ignore row */ + } + } + await pruneThumbnailEvents(pgPool, keepN); + try { + const fsSync = require("fs"); + if (fsSync.existsSync(jsonPath)) { + fsSync.renameSync(jsonPath, `${jsonPath}.migrated.bak`); + } + } catch (e) { + console.warn("[thumbnail-events] JSON migration rename failed:", e?.message || e); + } +} + +module.exports = { + readThumbnailEvents, + appendThumbnailEvent, + clearThumbnailEvents, + migrateThumbnailEventsFromJson, +}; diff --git a/ops-auth.js b/ops-auth.js new file mode 100644 index 0000000..7618b93 --- /dev/null +++ b/ops-auth.js @@ -0,0 +1,446 @@ +/** + * OPS_STATE=PROD(구 REAL) 일 때 이메일(@xavis.co.kr) 매직 링크 인증 + */ +const crypto = require("crypto"); +const { isOpsProdMode } = require("./lib/ops-state"); +const path = require("path"); +const fsSync = require("fs"); +const { v4: uuidv4 } = require("uuid"); + +let nodemailer = null; +try { + nodemailer = require("nodemailer"); +} catch { + /* optional */ +} + +const OPS_AUTH_COOKIE = "ops_user_session"; +const ALLOWED_EMAIL_SUFFIX = "@xavis.co.kr"; +/** 메일 안 인증 링크 유효 시간(토큰 만료) */ +const MAGIC_LINK_MAX_AGE_MS = 15 * 60 * 1000; + +/** OPS_SESSION_TZ 기준 달력 날짜 키 (YYYY-MM-DD) */ +function calendarDateKeyInTz(tsMs, tz) { + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone: tz, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(new Date(tsMs)); + const y = parts.find((p) => p.type === "year").value; + const mo = parts.find((p) => p.type === "month").value; + const da = parts.find((p) => p.type === "day").value; + return `${y}-${mo}-${da}`; +} + +/** + * 로그인 세션 만료: OPS_SESSION_TZ(기본 Asia/Seoul)에서 해당 일자의 마지막 순간(23:59:59.999에 해당하는 epoch ms) + */ +function getOpsSessionExpiresAtMs(nowMs = Date.now()) { + const tz = (process.env.OPS_SESSION_TZ || "Asia/Seoul").trim() || "Asia/Seoul"; + const cur = calendarDateKeyInTz(nowMs, tz); + let lo = nowMs; + let hi = nowMs + 48 * 60 * 60 * 1000; + let guard = 0; + while (calendarDateKeyInTz(hi, tz) === cur && guard < 400) { + hi += 24 * 60 * 60 * 1000; + guard++; + } + if (calendarDateKeyInTz(hi, tz) === cur) { + hi = nowMs + 400 * 24 * 60 * 60 * 1000; + } + while (hi - lo > 1) { + const mid = Math.floor((lo + hi) / 2); + if (calendarDateKeyInTz(mid, tz) === cur) lo = mid; + else hi = mid; + } + return hi - 1; +} + +function isOpsProd() { + return isOpsProdMode(); +} + +function getAuthSecret() { + return (process.env.AUTH_SECRET || process.env.ADMIN_TOKEN || "xavis-admin").trim(); +} + +function getBaseUrl() { + const b = (process.env.BASE_URL || "").trim().replace(/\/$/, ""); + if (b) return b; + const port = process.env.PORT || 8030; + return `http://localhost:${port}`; +} + +function sanitizeReturnTo(v) { + const s = (v || "").toString().trim(); + if (!s.startsWith("/") || s.startsWith("//")) return "/learning"; + return s; +} + +function appendVerifiedParam(returnPath) { + const s = sanitizeReturnTo(returnPath); + return s.includes("?") ? `${s}&verified=1` : `${s}?verified=1`; +} + +function isAllowedXavisEmail(email) { + const s = String(email || "") + .trim() + .toLowerCase(); + if (!s.includes("@")) return false; + return s.endsWith(ALLOWED_EMAIL_SUFFIX); +} + +function signSessionCookie(email, expMs) { + const exp = expMs; + const payload = `${email}|${exp}`; + const sig = crypto.createHmac("sha256", getAuthSecret()).update(payload).digest("hex"); + return Buffer.from(JSON.stringify({ email, exp, sig })).toString("base64url"); +} + +function parseSessionCookie(val) { + if (!val || typeof val !== "string") return null; + try { + const j = JSON.parse(Buffer.from(val, "base64url").toString("utf8")); + if (!j.email || typeof j.exp !== "number") return null; + if (Date.now() > j.exp) return null; + const payload = `${j.email}|${j.exp}`; + const sig = crypto.createHmac("sha256", getAuthSecret()).update(payload).digest("hex"); + if (sig !== j.sig) return null; + return String(j.email).toLowerCase(); + } catch { + return null; + } +} + +function getOpsSessionEmail(req) { + return parseSessionCookie(req.cookies?.[OPS_AUTH_COOKIE]); +} + +function loadMagicLinks(MAGIC_LINK_PATH) { + try { + const raw = fsSync.readFileSync(MAGIC_LINK_PATH, "utf8"); + const arr = JSON.parse(raw); + return Array.isArray(arr) ? arr : []; + } catch { + return []; + } +} + +function saveMagicLinks(MAGIC_LINK_PATH, list) { + fsSync.mkdirSync(path.dirname(MAGIC_LINK_PATH), { recursive: true }); + fsSync.writeFileSync(MAGIC_LINK_PATH, JSON.stringify(list, null, 2), "utf8"); +} + +function pruneExpired(list) { + const now = Date.now(); + return list.filter((x) => x.expiresAt > now); +} + +/** 클라이언트에 내부 주소·스택이 노출되지 않도록 SMTP 오류 메시지 정리 */ +function publicSmtpErrorMessage(err) { + const raw = String(err?.message || err || ""); + const code = err?.code || err?.cause?.code; + if (code === "ECONNREFUSED" || raw.includes("ECONNREFUSED")) { + return "메일 서버에 연결할 수 없습니다. PROD 환경의 SMTP_HOST·포트가 앱 서버에서 접근 가능한지(방화벽·사내 전용 게이트웨이 여부)를 확인하세요."; + } + if (code === "ETIMEDOUT" || raw.includes("ETIMEDOUT") || /timeout/i.test(raw)) { + return "메일 서버 연결 시간이 초과되었습니다."; + } + if (code === "ENOTFOUND" || raw.includes("ENOTFOUND")) { + return "메일 서버 주소(SMTP_HOST)를 찾을 수 없습니다."; + } + if (code === "ECONNRESET" || raw.includes("ECONNRESET")) { + return "메일 서버와의 연결이 끊겼습니다. TLS/포트(SMTP_SECURE, SMTP_PORT) 설정을 확인하세요."; + } + return "인증 메일을 보내지 못했습니다. 잠시 후 다시 시도하거나 관리자에게 문의하세요."; +} + +function createSmtpTransport() { + const host = (process.env.SMTP_HOST || "").trim(); + const port = Number(process.env.SMTP_PORT || 587); + const secure = process.env.SMTP_SECURE === "1"; + const user = (process.env.SMTP_USER || "").trim(); + const pass = (process.env.SMTP_PASS || "").trim(); + const requireTlsEnv = process.env.SMTP_REQUIRE_TLS; + const requireTLS = + requireTlsEnv === "0" + ? false + : requireTlsEnv === "1" || (!secure && port === 587); + return nodemailer.createTransport({ + host, + port, + secure, + auth: user ? { user, pass } : undefined, + requireTLS, + connectionTimeout: 15000, + greetingTimeout: 15000, + socketTimeout: 20000, + tls: { minVersion: "TLSv1.2" }, + }); +} + +function isOpsPublicPath(req) { + const p = req.path || ""; + if (p === "/login") return true; + if (p === "/logout") return true; + /** 쿼리형·경로형 인증 링크 모두 허용 (메일 클라이언트는 ?token= 링크를 깨뜨리는 경우가 있음) */ + if (p === "/auth/verify" || p.startsWith("/auth/verify/")) return true; + if (p === "/api/auth/request-link" && req.method === "POST") return true; + return false; +} + +/** HTML 이메일용 이스케이프 (속성·본문 공통) */ +function escapeHtmlEmail(s) { + return String(s) + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); +} + +/** + * 인증 메일 본문 HTML — 인라인 스타일·테이블 기반(주요 클라이언트 호환) + */ +function buildMagicLinkEmailHtml(linkUrl, linkMinutes) { + const href = String(linkUrl).replace(/&/g, "&").replace(/"/g, """); + const linkText = escapeHtmlEmail(linkUrl); + return ` + + + + +인증 안내 + + + + + + +
+ + + + +
+

XAVIS

+

AI Platform

+

계정 인증을 위해 아래의 인증 링크를 안내드립니다.

+

아래 버튼을 누르시면 인증이 완료되며, 이후 서비스를 정상적으로 이용하실 수 있습니다.

+ + + + +
+ 인증 완료하기 +
+

버튼이 동작하지 않으면 아래 주소를 브라우저에 복사해 붙여 넣어 주세요.

+

${linkText}

+
+

※ 본 인증 링크는 보안을 위해 발송 시점으로부터 ${linkMinutes}분 동안만 유효합니다.

+

※ 인증 시간이 만료된 경우, 다시 인증 메일을 요청해 주시기 바랍니다.

+

본 메일은 발신 전용이며, 문의 사항이 있으실 경우 AI혁신팀으로 문의 부탁합니다.

+

감사합니다.

+
+
+

© XAVIS · 본 메일은 시스템에 의해 자동 발송되었습니다.

+
+ +`; +} + +async function sendMagicLinkEmail(to, linkUrl) { + const host = (process.env.SMTP_HOST || "").trim(); + if (!nodemailer || !host) { + console.warn("[OPS] SMTP 미설정 — 아래 링크를 수동으로 전달하거나 SMTP_HOST 등을 설정하세요."); + console.warn("[OPS] Magic link for", to, "→", linkUrl); + return; + } + const from = (process.env.SMTP_FROM || (process.env.SMTP_USER || "").trim() || "noreply@xavis.co.kr").trim(); + const transporter = createSmtpTransport(); + const linkMinutes = Math.max(1, Math.floor(MAGIC_LINK_MAX_AGE_MS / 60000)); + const textBody = [ + "계정 인증을 위해 아래의 인증 링크를 안내드립니다.", + "아래 링크를 클릭하시면 인증이 완료되며, 이후 서비스를 정상적으로 이용하실 수 있습니다.", + "", + "인증 링크:", + linkUrl, + "", + `※ 본 인증 링크는 보안을 위해 발송 시점으로부터 ${linkMinutes}분 동안만 유효합니다.`, + "※ 인증 시간이 만료된 경우, 다시 인증 메일을 요청해 주시기 바랍니다.", + "", + "본 메일은 발신 전용이며, 문의 사항이 있으실 경우 AI혁신팀으로 문의 부탁합니다.", + "", + "감사합니다.", + ].join("\n"); + const htmlBody = buildMagicLinkEmailHtml(linkUrl, linkMinutes); + await transporter.sendMail({ + from, + to, + subject: "[인증] XAVIS AI Platform 인증 링크", + text: textBody, + html: htmlBody, + }); +} + +module.exports = function createOpsAuth(DATA_DIR, hooks = {}) { + const MAGIC_LINK_PATH = path.join(DATA_DIR, "ops-magic-links.json"); + const BASE_URL = getBaseUrl(); + + function opsAccessMiddleware(req, res, next) { + if (!isOpsProd()) { + res.locals.opsAuthRequired = false; + res.locals.opsUserEmail = null; + return next(); + } + res.locals.opsAuthRequired = true; + if (isOpsPublicPath(req)) { + return next(); + } + const email = getOpsSessionEmail(req); + if (email) { + res.locals.opsUserEmail = email; + return next(); + } + if (req.path.startsWith("/api/")) { + return res.status(401).json({ error: "로그인이 필요합니다." }); + } + const returnTo = req.originalUrl || "/learning"; + return res.redirect("/login?returnTo=" + encodeURIComponent(returnTo)); + } + + function registerRoutes(app) { + app.get("/login", (req, res) => { + if (!isOpsProd()) { + return res.redirect("/learning"); + } + if (getOpsSessionEmail(req)) { + return res.redirect(sanitizeReturnTo(req.query.returnTo)); + } + return res.render("login", { + returnTo: sanitizeReturnTo(req.query.returnTo), + layoutMinimal: true, + }); + }); + + app.get("/logout", async (req, res) => { + const sessionEmail = getOpsSessionEmail(req); + if (typeof hooks.onLogout === "function" && sessionEmail) { + try { + await hooks.onLogout({ email: sessionEmail, req }); + } catch (hookErr) { + console.error("[OPS] onLogout hook:", hookErr?.message || hookErr); + } + } + res.clearCookie(OPS_AUTH_COOKIE, { path: "/" }); + if (isOpsProd()) { + return res.redirect("/login"); + } + return res.redirect("/learning"); + }); + + app.post("/api/auth/request-link", async (req, res) => { + try { + if (!isOpsProd()) { + return res.status(400).json({ error: "PROD(운영) 모드가 아닙니다." }); + } + const email = String(req.body?.email || "") + .trim() + .toLowerCase(); + if (!email) { + return res.status(400).json({ error: "이메일을 입력해 주세요." }); + } + if (!isAllowedXavisEmail(email)) { + return res.status(403).json({ error: "허용된 임직원이 아닙니다." }); + } + let list = pruneExpired(loadMagicLinks(MAGIC_LINK_PATH)); + const token = uuidv4(); + const expiresAt = Date.now() + MAGIC_LINK_MAX_AGE_MS; + const ret = sanitizeReturnTo(req.body?.returnTo); + list.push({ + token, + email, + expiresAt, + used: false, + createdAt: new Date().toISOString(), + returnTo: ret, + }); + saveMagicLinks(MAGIC_LINK_PATH, list); + /** 경로에 토큰만 두기 (쿼리 `?token=`는 일부 웹메일/Outlook에서 href가 잘림) */ + const linkUrl = `${BASE_URL}/auth/verify/${encodeURIComponent(token)}`; + await sendMagicLinkEmail(email, linkUrl); + if (typeof hooks.onMagicLinkRequested === "function") { + try { + await hooks.onMagicLinkRequested({ email, returnTo: ret, req }); + } catch (hookErr) { + console.error("[OPS] onMagicLinkRequested hook:", hookErr?.message || hookErr); + } + } + return res.json({ + ok: true, + message: "회사 메일로 인증 링크를 보냈습니다. 메일함을 확인해 주세요.", + }); + } catch (err) { + console.error("[OPS] request-link:", err?.message || err); + return res.status(500).json({ error: publicSmtpErrorMessage(err) }); + } + }); + + async function handleMagicLinkVerify(req, res) { + if (!isOpsProd()) { + return res.redirect("/learning"); + } + const token = String(req.params.token || req.query.token || "").trim(); + if (!token) { + return res.status(400).send("유효하지 않은 링크입니다."); + } + let list = pruneExpired(loadMagicLinks(MAGIC_LINK_PATH)); + const idx = list.findIndex((x) => x.token === token && !x.used); + if (idx === -1) { + return res.status(400).send("유효하지 않거나 만료된 링크입니다."); + } + const row = list[idx]; + if (Date.now() > row.expiresAt) { + return res.status(400).send("만료된 링크입니다."); + } + row.used = true; + saveMagicLinks(MAGIC_LINK_PATH, list); + if (typeof hooks.onLoginSuccess === "function") { + try { + await hooks.onLoginSuccess({ + email: row.email, + returnTo: row.returnTo, + req, + }); + } catch (hookErr) { + console.error("[OPS] onLoginSuccess hook:", hookErr?.message || hookErr); + } + } + const sessionExp = getOpsSessionExpiresAtMs(); + const cookieVal = signSessionCookie(row.email, sessionExp); + const secure = process.env.NODE_ENV === "production"; + res.cookie(OPS_AUTH_COOKIE, cookieVal, { + httpOnly: true, + maxAge: Math.max(0, sessionExp - Date.now()), + sameSite: "lax", + path: "/", + secure, + }); + const dest = appendVerifiedParam(sanitizeReturnTo(row.returnTo || "/learning")); + return res.redirect(dest); + } + + /** 구 메일 호환: ?token= */ + app.get("/auth/verify", handleMagicLinkVerify); + /** 권장: 쿼리 없이 경로만 (메일 HTML 파서 깨짐 방지) */ + app.get("/auth/verify/:token", handleMagicLinkVerify); + } + + return { + middleware: opsAccessMiddleware, + registerRoutes, + isOpsProd, + /** @deprecated REAL→PROD 이전 호환용 */ + isOpsReal: isOpsProd, + }; +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..3617e55 --- /dev/null +++ b/package.json @@ -0,0 +1,39 @@ +{ + "name": "webplatform", + "version": "1.0.0", + "description": "", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "node server.js", + "db:schema": "node scripts/apply-schema.js", + "db:migrate-from-ncue": "node scripts/migrate-db-ncue-to-env.js", + "db:normalize-meeting-minutes": "node scripts/normalize-stored-meeting-minutes.js", + "test": "echo \"No tests configured\"", + "test:ax-apply": "node scripts/test-ax-apply.js" + }, + "repository": { + "type": "git", + "url": "https://git.ncue.net/xavis/webplatform.git" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@anthropic-ai/sdk": "^0.78.0", + "@google/genai": "^1.45.0", + "cookie-parser": "^1.4.7", + "dompurify": "^3.3.3", + "dotenv": "^17.3.1", + "ejs": "^5.0.1", + "express": "^5.2.1", + "jszip": "^3.10.1", + "marked": "^17.0.5", + "multer": "^2.1.1", + "mysql2": "^3.19.1", + "nodemailer": "^6.10.1", + "openai": "^6.29.0", + "pg": "^8.20.0", + "uuid": "^13.0.0" + } +} diff --git a/public/images/aiplatform-logo.png b/public/images/aiplatform-logo.png new file mode 100644 index 0000000..05a3491 Binary files /dev/null and b/public/images/aiplatform-logo.png differ diff --git a/public/images/xavis-logo.png b/public/images/xavis-logo.png new file mode 100644 index 0000000..074f471 Binary files /dev/null and b/public/images/xavis-logo.png differ diff --git a/public/js/learning-infinite.js b/public/js/learning-infinite.js new file mode 100644 index 0000000..bdaf733 --- /dev/null +++ b/public/js/learning-infinite.js @@ -0,0 +1,227 @@ +/** + * 학습센터 / 뉴스레터 목록: 무한 스크롤·실시간 검색 + * (인라인 스크립트는 HTML 엔티티(&) 처리로 SyntaxError가 날 수 있어 외부 파일로 분리) + */ +(function () { + var cfg = document.getElementById("lecture-page-config"); + var apiPath = (cfg && cfg.getAttribute("data-learning-api")) || "/api/learning/lectures"; + var viewerBasePath = (cfg && cfg.getAttribute("data-viewer-base")) || "/learning"; + + var form = document.querySelector(".filter-panel form"); + var qInput = document.getElementById("learning-filter-q"); + var resultsRoot = document.getElementById("lecture-results-root"); + var countEl = document.getElementById("lecture-total-count"); + + var radios = document.querySelectorAll('#category-filter input[name="category"]'); + if (form && radios.length) { + radios.forEach(function (radio) { + radio.addEventListener("change", function () { + form.submit(); + }); + }); + } + + var scrollObserver = null; + var scrollLoading = false; + var scrollListener = null; + var scrollRootsBound = []; + var wheelHandler = null; + var pollTimer = null; + + function disconnectInfiniteScroll() { + if (scrollObserver) { + scrollObserver.disconnect(); + scrollObserver = null; + } + if (scrollListener) { + scrollRootsBound.forEach(function (t) { + t.removeEventListener("scroll", scrollListener); + }); + scrollRootsBound = []; + window.removeEventListener("resize", scrollListener); + scrollListener = null; + } + if (wheelHandler) { + document.removeEventListener("wheel", wheelHandler, true); + wheelHandler = null; + } + if (pollTimer) { + clearInterval(pollTimer); + pollTimer = null; + } + } + + function setupInfiniteScroll() { + disconnectInfiniteScroll(); + var loadingEl = document.getElementById("infinite-scroll-loading"); + + function shouldLoadMore() { + var sentinel = document.getElementById("infinite-scroll-sentinel"); + if (!sentinel || sentinel.getAttribute("data-has-next") !== "true") return false; + var rect = sentinel.getBoundingClientRect(); + var vh = window.innerHeight || document.documentElement.clientHeight; + var margin = 3200; + return rect.top <= vh + margin; + } + + function loadNextPage() { + if (scrollLoading) return; + var sentinel = document.getElementById("infinite-scroll-sentinel"); + var grid = document.getElementById("lecture-grid"); + if (!sentinel || !grid) return; + if (sentinel.getAttribute("data-has-next") !== "true") return; + var nextPage = sentinel.getAttribute("data-next-page"); + if (!nextPage) return; + + scrollLoading = true; + if (loadingEl) loadingEl.style.display = "block"; + var params = new URLSearchParams(window.location.search); + params.set("page", nextPage); + fetch(apiPath + "?" + params.toString(), { + credentials: "same-origin", + headers: { Accept: "application/json" }, + }) + .then(function (r) { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + }) + .then(function (data) { + if (data.html) { + grid.insertAdjacentHTML("beforeend", data.html); + } + if (data.hasNext) { + sentinel.setAttribute("data-next-page", String(data.nextPage || parseInt(nextPage, 10) + 1)); + sentinel.setAttribute("data-has-next", "true"); + } else { + disconnectInfiniteScroll(); + var foot = document.getElementById("lecture-infinite-footer"); + if (foot && foot.parentNode) foot.remove(); + } + }) + .catch(function (e) { + var msg = ""; + if (e) { + if (e.message) msg = e.message; + else msg = String(e); + } + console.warn("[learning] 다음 페이지 로드 실패(재시도 가능):", msg); + }) + .finally(function () { + scrollLoading = false; + if (loadingEl) loadingEl.style.display = "none"; + setTimeout(function () { + if (shouldLoadMore()) loadNextPage(); + }, 0); + }); + } + + function loadMoreIfNeeded() { + if (!shouldLoadMore()) return; + loadNextPage(); + } + + var sentinelEl = document.getElementById("infinite-scroll-sentinel"); + var gridEl = document.getElementById("lecture-grid"); + if (!sentinelEl || !gridEl) return; + + var rafScheduled = false; + scrollListener = function () { + if (scrollLoading || rafScheduled) return; + rafScheduled = true; + requestAnimationFrame(function () { + rafScheduled = false; + loadMoreIfNeeded(); + }); + }; + + function addScrollRoot(r) { + if (r && scrollRootsBound.indexOf(r) === -1) scrollRootsBound.push(r); + } + addScrollRoot(window); + addScrollRoot(document.scrollingElement || document.documentElement); + scrollRootsBound.forEach(function (t) { + t.addEventListener("scroll", scrollListener, { passive: true }); + }); + window.addEventListener("resize", scrollListener, { passive: true }); + + wheelHandler = function () { + if (scrollLoading) return; + requestAnimationFrame(loadMoreIfNeeded); + }; + document.addEventListener("wheel", wheelHandler, { passive: true, capture: true }); + + pollTimer = setInterval(function () { + if (scrollLoading) return; + loadMoreIfNeeded(); + }, 400); + + scrollObserver = new IntersectionObserver( + function (entries) { + if (!entries.some(function (e) { + return e.isIntersecting; + })) + return; + loadNextPage(); + }, + { root: null, rootMargin: "0px 0px 1600px 0px", threshold: 0 } + ); + scrollObserver.observe(sentinelEl); + + setTimeout(loadMoreIfNeeded, 0); + setTimeout(loadMoreIfNeeded, 400); + + var loadMoreBtn = document.getElementById("lecture-load-more-btn"); + if (loadMoreBtn) { + loadMoreBtn.addEventListener("click", function () { + loadNextPage(); + }); + } + } + + function applyLiveSearch() { + if (!form || !resultsRoot) return; + var params = new URLSearchParams(new FormData(form)); + params.set("page", "1"); + fetch(apiPath + "?" + params.toString()) + .then(function (r) { + return r.json(); + }) + .then(function (data) { + if (data.error) return; + var total = typeof data.totalCount === "number" ? data.totalCount : 0; + if (countEl) countEl.textContent = total; + var html = (data.html || "").trim(); + if (total === 0 || !html) { + disconnectInfiniteScroll(); + resultsRoot.innerHTML = '

등록된 항목이 없습니다.

'; + } else { + var nextP = data.nextPage != null ? data.nextPage : 2; + var sentinelBlock = data.hasNext + ? '' + : ""; + resultsRoot.innerHTML = '
' + data.html + "
" + sentinelBlock; + setupInfiniteScroll(); + } + if (window.history && window.history.replaceState) { + var forUrl = new URLSearchParams(params); + forUrl.delete("page"); + var u = new URL(viewerBasePath, window.location.origin); + u.search = forUrl.toString() ? "?" + forUrl.toString() : ""; + window.history.replaceState({}, "", u.pathname + u.search); + } + }) + .catch(function () {}); + } + + var searchDebounce; + if (qInput) { + qInput.addEventListener("input", function () { + clearTimeout(searchDebounce); + searchDebounce = setTimeout(applyLiveSearch, 280); + }); + } + + setupInfiniteScroll(); +})(); diff --git a/public/resources/ai-success/1774520876124-6719be4f-039d-4227-8fe7-2c23d908023c.pdf b/public/resources/ai-success/1774520876124-6719be4f-039d-4227-8fe7-2c23d908023c.pdf new file mode 100644 index 0000000..5b5670b Binary files /dev/null and b/public/resources/ai-success/1774520876124-6719be4f-039d-4227-8fe7-2c23d908023c.pdf differ diff --git a/public/resources/ai-success/1774520936441-c8e17615-dad1-4a08-9a26-f6303b666058.pdf b/public/resources/ai-success/1774520936441-c8e17615-dad1-4a08-9a26-f6303b666058.pdf new file mode 100644 index 0000000..5b5670b Binary files /dev/null and b/public/resources/ai-success/1774520936441-c8e17615-dad1-4a08-9a26-f6303b666058.pdf differ diff --git a/public/resources/ai-success/자비스_AI성공사례_조정숙과장.pdf b/public/resources/ai-success/자비스_AI성공사례_조정숙과장.pdf new file mode 100644 index 0000000..5b5670b Binary files /dev/null and b/public/resources/ai-success/자비스_AI성공사례_조정숙과장.pdf differ diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..3564c74 --- /dev/null +++ b/public/styles.css @@ -0,0 +1,3250 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans KR", sans-serif; + background: #f3f4f7; + color: #1f2937; +} + +.app-shell { + min-height: 100vh; + display: flex; +} + +.left-nav { + width: 92px; + flex-shrink: 0; + background: #ffffff; + border-right: 1px solid #e5e7eb; + padding: 16px 10px; + position: sticky; + top: 0; + height: 100vh; + display: flex; + flex-direction: column; + gap: 8px; + overflow-y: auto; + /* 본문(content-area)이 flex 오버플로로 겹칠 때에도 좌측 메뉴 클릭이 먹도록 위에 둠 */ + z-index: 2; +} + +/* 로고 ~ 구분선 간격: 기존 8px 대비 1.5배(12px), 구분선 ~ 채팅은 flex gap(8px)+보정으로 전체 약 1.5배 */ +.nav-logo-section { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding-bottom: 3px; + flex-shrink: 0; +} + +.nav-logo-divider { + width: 72%; + max-width: 64px; + height: 1px; + background: #e5e7eb; + margin: 0 auto; + flex-shrink: 0; +} + +.logo-link { + display: block; + margin: 0 auto; + text-align: center; +} + +.logo-img { + max-width: 168px; + width: 100%; + height: auto; + display: block; + margin: 0 auto; +} + +/* 상단 XAVIS 로고: 기본(168px) 대비 약 50% */ +.logo-img-xavis { + max-width: 84px; +} + +.logo-img-aiplatform { + cursor: default; + user-select: none; + -webkit-user-drag: none; +} + +.nav-item { + font-size: 12px; + color: #374151; + text-decoration: none; + text-align: center; + border-radius: 10px; + padding: 8px 4px; + display: block; + cursor: pointer; +} + +.nav-item:hover { + background: #f3f4f6; +} + +.nav-item.active { + background: #e8f0fe; + color: #1d4ed8; +} + +.nav-item.nav-item-ghost { + color: #9ca3af; + font-size: 11px; +} + +/* 이메일(OPS) 인증 사용자: 관리자 버튼 위 로그아웃 */ +.nav-item.nav-item-ops-logout { + color: #111827; + font-weight: 600; + font-size: 13px; +} + +.nav-footer { + margin-top: auto; + padding-top: 8px; +} + +.nav-separator { + height: 1px; + background: #e5e7eb; + margin-bottom: 8px; +} + +/* 관리자 전용 링크(사용자 현황관리)와 구분선 사이 간격 */ +.nav-footer > .nav-item + .nav-separator { + margin-top: 8px; +} + +button.nav-item { + background: none; + border: none; + width: 100%; + font: inherit; +} + +/* 좁은 화면: 좌측 메뉴는 햄버거로 열기 (하단·관리자 구분선 바로 위에 배치) */ +.nav-mobile-toggle { + display: none; + position: fixed; + top: auto; + left: max(12px, env(safe-area-inset-left, 0px)); + /* nav-footer: 관리자(~48px) + 구분선 아래 여백(8px) + 구분선(1px) + 햄버거와의 간격(8px) */ + bottom: calc(48px + 8px + 1px + 8px + env(safe-area-inset-bottom, 0px)); + z-index: 102; + width: 44px; + height: 44px; + padding: 0; + border: 1px solid #e5e7eb; + border-radius: 12px; + background: #fff; + box-shadow: 0 2px 10px rgba(15, 23, 42, 0.08); + color: #374151; + cursor: pointer; + align-items: center; + justify-content: center; + transition: background 0.15s ease, box-shadow 0.15s ease; +} + +.nav-mobile-toggle:hover { + background: #f9fafb; + box-shadow: 0 4px 14px rgba(15, 23, 42, 0.1); +} + +.nav-mobile-toggle:focus-visible { + outline: 2px solid #2563eb; + outline-offset: 2px; +} + +.nav-drawer-backdrop { + position: fixed; + inset: 0; + z-index: 100; + margin: 0; + padding: 0; + border: none; + background: rgba(15, 23, 42, 0.45); + cursor: pointer; +} + +.content-area { + flex: 1; + min-width: 0; + position: relative; + z-index: 1; +} + +.topbar { + background: #ffffff; + border-bottom: 1px solid #e5e7eb; + height: 68px; + padding: 0 24px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.topbar h1 { + margin: 0; + font-size: 30px; +} + +.top-action { + border: none; + background: #f59e0b; + color: #fff; + border-radius: 999px; + padding: 10px 16px; + cursor: pointer; + font-weight: 600; +} + +.top-action:disabled, +.top-action.ax-sample-readonly { + background: #d1d5db !important; + color: #9ca3af !important; + cursor: not-allowed !important; + pointer-events: none; +} + +.top-action-link { + text-decoration: none; + border: none; + background: #f59e0b; + color: #fff; + border-radius: 999px; + padding: 10px 16px; + cursor: pointer; + font-weight: 600; + font-family: inherit; + font-size: 14px; +} + +.container { + max-width: 1260px; + margin: 20px auto 40px; + padding: 0 22px; + display: grid; + gap: 16px; +} + +/* AI 탐색: 메인 콘텐츠를 뷰포트 전체 너비로 사용 */ +.container.container-ai-full { + max-width: none; + width: 100%; + margin: 20px 0 40px; + padding: 0 24px; + box-sizing: border-box; +} + +.container-ai-full .search-input { + max-width: none; + box-sizing: border-box; +} + +.panel { + background: #ffffff; + border-radius: 16px; + padding: 18px; + border: 1px solid #e5e7eb; +} + +.hero { + background: linear-gradient(135deg, #161b33 0%, #2f3c7e 100%); + color: #fff; +} + +.hero h2 { + margin: 0 0 8px; + font-size: 24px; +} + +.hero p { + margin: 0; + color: #e5e7eb; +} + +.panel h2 { + margin-top: 0; +} + +.section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.count-chip { + border-radius: 999px; + border: 1px solid #d1d5db; + padding: 3px 10px; + font-size: 13px; + color: #4b5563; +} + +.form-grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +.form-grid label { + display: grid; + gap: 6px; + font-size: 14px; +} + +.form-grid .full { + grid-column: 1 / -1; +} + +input, +select, +textarea, +button { + font: inherit; +} + +input, +select, +textarea { + border: 1px solid #d1d5db; + border-radius: 8px; + padding: 10px; +} + +button { + border: none; + background: #f59e0b; + color: #fff; + border-radius: 8px; + padding: 10px 14px; + cursor: pointer; + width: fit-content; +} + +.lecture-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 12px; +} + +.lecture-card { + display: grid; + gap: 8px; + border: 1px solid #e5e7eb; + border-radius: 12px; + padding: 10px; + background: #fff; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + +.lecture-card:hover { + transform: translateY(-1px); + box-shadow: 0 8px 22px rgba(0, 0, 0, 0.08); +} + +.lecture-link { + display: grid; + gap: 8px; + text-decoration: none; + color: inherit; +} + +.lecture-card h3 { + margin: 0; +} + +.lecture-card p { + margin: 0; + color: #4b5563; + font-size: 14px; +} + +.lecture-card small { + color: #6b7280; +} + +.success-story-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 14px; +} + +.success-story-card { + display: grid; + gap: 8px; + border: 1px solid #e5e7eb; + border-radius: 12px; + padding: 0; + background: #fff; + transition: transform 0.15s ease, box-shadow 0.15s ease; + overflow: hidden; +} + +.success-story-card:hover { + transform: translateY(-2px); + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.09); +} + +.success-story-card--locked { + cursor: not-allowed; +} + +.success-story-card--locked:hover { + transform: none; + box-shadow: none; +} + +.success-story-card--locked .success-story-link { + cursor: not-allowed; +} + +.success-story-link { + display: grid; + gap: 8px; + text-decoration: none; + color: inherit; + padding: 12px 14px 14px; +} + +.success-thumb { + border-radius: 10px; + min-height: 72px; + padding: 12px 14px; + background: linear-gradient(135deg, #0f766e, #14b8a6); + color: #fff; + display: flex; + flex-direction: column; + justify-content: center; + gap: 4px; +} + +.success-thumb-icon { + font-size: 22px; + opacity: 0.95; +} + +.success-thumb-kicker { + font-size: 12px; + font-weight: 600; + letter-spacing: 0.02em; +} + +.success-badge { + font-size: 12px; + color: #6b7280; +} + +.success-story-card h3 { + margin: 0; + font-size: 16px; + line-height: 1.35; + color: #111827; +} + +.success-excerpt { + margin: 0; + color: #4b5563; + font-size: 14px; + line-height: 1.45; + display: -webkit-box; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.success-meta { + color: #9ca3af; + font-size: 12px; +} + +.success-hero h2 { + margin-top: 0; +} + +.success-filter .filter-actions { + align-self: end; +} + +.success-detail-panel { + max-width: 820px; +} + +.success-detail-header { + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid #e5e7eb; +} + +.success-detail-meta { + margin: 0 0 8px; + font-size: 14px; + color: #6b7280; +} + +.success-detail-title { + margin: 0 0 10px; + font-size: 1.45rem; + line-height: 1.35; + color: #111827; +} + +.success-detail-lead { + margin: 0 0 12px; + color: #4b5563; + font-size: 15px; + line-height: 1.5; +} + +.success-pdf-link { + margin: 12px 0 0; +} + +.ai-case-viewer .ai-case-ppt-tools { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.ai-case-tool-sep { + color: #d1d5db; + margin: 0 2px; +} + +.ai-case-inline-link { + color: #2563eb; + text-decoration: none; + font-weight: 500; +} + +.ai-case-inline-link:hover { + text-decoration: underline; +} + +button.ai-case-inline-link { + background: none; + border: none; + padding: 0; + font: inherit; + font-weight: 500; + cursor: pointer; + color: #2563eb; + text-decoration: none; +} + +button.ai-case-inline-link:hover { + text-decoration: underline; +} + +.ai-case-tag-row { + margin-top: 8px; + margin-bottom: 4px; +} + +.success-detail-body-in-card { + margin-top: 0; +} + +.breadcrumb-detail { + margin: 0 0 12px; + font-size: 14px; +} + +.breadcrumb-detail a { + color: #2563eb; + text-decoration: none; +} + +.breadcrumb-detail a:hover { + text-decoration: underline; +} + +.detail-title-short { + font-size: 1.1rem; +} + +.admin-hint { + font-size: 13px; + line-height: 1.5; +} + +.admin-story-list { + list-style: none; + margin: 0; + padding: 0; +} + +.admin-story-list li { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + padding: 10px 0; + border-bottom: 1px solid #f3f4f6; +} + +.admin-story-list .btn-sm { + margin-left: auto; +} + +.btn-sm { + padding: 6px 10px; + font-size: 13px; + border-radius: 8px; +} + +.form-message { + margin-top: 12px; + font-size: 14px; + color: #059669; +} + +.badge { + display: inline-flex; + width: fit-content; + border-radius: 999px; + padding: 3px 8px; + font-size: 12px; + font-weight: 600; +} + +.badge.youtube { + background: #fee2e2; + color: #b91c1c; +} + +.badge.ppt { + background: #dbeafe; + color: #1d4ed8; +} + +.badge.news { + background: #ecfdf5; + color: #047857; +} + +.badge.link { + background: #ede9fe; + color: #5b21b6; +} + +.badge.video { + background: #cffafe; + color: #0e7490; +} + +.thumb { + border-radius: 10px; + padding: 12px; + min-height: 88px; + display: grid; + gap: 3px; + overflow: hidden; + position: relative; +} + +.thumb.youtube { + background: linear-gradient(135deg, #7f1d1d, #ef4444); + color: #fff; +} + +.thumb.ppt { + background: linear-gradient(135deg, #1d4ed8, #60a5fa); + color: #fff; +} + +.thumb.news { + background: linear-gradient(135deg, #0f766e, #34d399); + color: #fff; +} + +.thumb.link { + background: linear-gradient(135deg, #4c1d95, #a78bfa); + color: #fff; +} + +.thumb.video { + background: linear-gradient(135deg, #0e7490, #22d3ee); + color: #fff; +} + +.thumb-fallback-video { + font-size: 28px; + line-height: 1; + opacity: 0.95; +} + +.lecture-video-wrap { + margin-top: 16px; + border-radius: 12px; + overflow: hidden; + background: #0f172a; + max-width: 960px; +} + +.lecture-video-player { + display: block; + width: 100%; + max-height: min(70vh, 720px); + background: #000; +} + +.lecture-news-desc { + font-size: 15px; + line-height: 1.6; + margin: 0 0 16px; +} + +.lecture-news-actions { + margin: 0 0 12px; +} + +.thumb-image { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + opacity: 0.35; +} + +.thumb-image-youtube { + opacity: 1; +} + +/* link·news: og:image 미리보기 */ +.thumb-image-og { + opacity: 1; + object-fit: cover; +} + +/* visibility:hidden 은 IntersectionObserver 교차 판정을 막을 수 있음 */ +.infinite-scroll-sentinel { + height: 4px; + width: 100%; + overflow: hidden; + pointer-events: none; +} + +.lecture-load-more-wrap { + text-align: center; + padding: 12px 0 4px; +} +.lecture-load-more-btn { + border: 1px solid #d1d5db; + background: #fff; + color: #374151; + border-radius: 999px; + padding: 10px 22px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + font-family: inherit; +} +.lecture-load-more-btn:hover { + background: #f9fafb; + border-color: #9ca3af; +} + +.thumb-fallback { + position: absolute; + right: 8px; + top: 8px; + font-size: 11px; + padding: 2px 6px; + border-radius: 999px; + background: rgba(17, 24, 39, 0.4); + color: #fff; + z-index: 1; +} + +.thumb-kicker { + font-size: 11px; + opacity: 0.9; + position: relative; + z-index: 1; +} + +.thumb strong { + line-height: 1.3; + position: relative; + z-index: 1; +} + +.thumb small { + opacity: 0.9; + position: relative; + z-index: 1; +} + +.tag-row { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.tag-chip { + border-radius: 999px; + padding: 2px 8px; + background: #f3f4f6; + color: #4b5563; + font-size: 12px; +} + +.filter-panel, +.admin-panel { + display: grid; + gap: 10px; +} + +/* 학습센터 /admin: 관리자 비활성 시 등록·필터·목록만 비활성(활성화 패널은 제외) */ +.admin-body-gated--locked { + pointer-events: none; + opacity: 0.68; + user-select: none; + transition: opacity 0.15s ease; +} +.admin-body-gated--unlocked { + pointer-events: auto; + opacity: 1; +} + +.filter-grid { + display: grid; + gap: 10px; + grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); +} + +.filter-grid label { + display: grid; + gap: 6px; +} + +.filter-row-bottom { + grid-column: 1 / -1; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 12px 0 0; +} + +.category-radios { + display: flex; + flex-wrap: nowrap; + gap: 16px; + align-items: center; +} + +.category-radios label { + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; + font-size: 14px; + color: #374151; +} + +.category-radios input[type="radio"] { + margin: 0; +} + +.category-radios-inline { + display: flex; + flex-wrap: wrap; + gap: 12px 20px; + align-items: center; +} + +.category-radios-inline label { + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; + font-size: 14px; + color: #374151; +} + +.category-radios-inline input[type="radio"] { + margin: 0; +} + +.filter-actions { + display: flex; + align-items: center; + gap: 10px; + flex-shrink: 0; +} + +.link-download { + font-size: 0.9em; + font-weight: normal; + color: #1d4ed8; + text-decoration: underline; + margin-left: 8px; +} +.link-download:hover { + color: #1e40af; +} + +.link-muted { + color: #6b7280; + text-decoration: none; +} + +.admin-mode-header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.admin-mode-header h2 { + margin: 0; +} + +.admin-mode-toggle { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.admin-status { + font-size: 13px; + font-weight: 600; + padding: 4px 10px; + border-radius: 999px; +} + +.admin-status.active { + background: #dcfce7; + color: #166534; +} + +.admin-status.inactive { + background: #fef3c7; + color: #92400e; +} + +.btn-admin-off { + display: inline-block; + padding: 6px 14px; + font-size: 13px; + color: #6b7280; + background: #f3f4f6; + border: 1px solid #e5e7eb; + border-radius: 8px; + text-decoration: none; +} + +.btn-admin-off:hover { + background: #e5e7eb; + color: #374151; +} + +.admin-activate-inline { + display: inline-flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.admin-activate-inline input[type="password"] { + padding: 6px 10px; + border: 1px solid #d1d5db; + border-radius: 8px; + font-size: 14px; + min-width: 140px; +} + +.admin-activate-inline button { + padding: 6px 14px; + font-size: 13px; + background: #f59e0b; + color: #fff; + border: none; + border-radius: 8px; + cursor: pointer; +} + +.admin-activate-inline button:hover { + background: #d97706; +} + +.admin-inline { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.queue-status { + display: flex; + gap: 10px; + flex-wrap: wrap; + color: #374151; + font-size: 13px; +} + +.api-hint { + margin: 4px 0 0; + color: #6b7280; + font-size: 13px; +} + +.dashboard-grid { + display: grid; + gap: 10px; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); +} + +.chart-grid { + display: grid; + gap: 10px; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); +} + +.kpi-grid { + display: grid; + gap: 10px; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} + +.kpi-card { + border: 1px solid #e5e7eb; + border-radius: 10px; + padding: 10px; + background: #fff; + display: grid; + gap: 4px; +} + +.kpi-card small { + color: #6b7280; +} + +.kpi-card b { + font-size: 24px; +} + +.mini-panel { + border: 1px solid #e5e7eb; + border-radius: 10px; + padding: 10px; + background: #fff; +} + +.mini-panel h3 { + margin: 0 0 8px; + font-size: 15px; +} + +.plain-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 6px; +} + +.plain-list li { + display: grid; + gap: 2px; + font-size: 13px; +} + +.bar-chart { + min-height: 180px; + border: 1px solid #f3f4f6; + border-radius: 8px; + padding: 8px; + display: grid; + grid-template-columns: repeat(24, minmax(0, 1fr)); + gap: 4px; + align-items: end; +} + +.bar-item { + display: grid; + gap: 4px; + justify-items: center; +} + +.bar-item small { + font-size: 10px; + color: #6b7280; +} + +.bar { + width: 100%; + min-height: 2px; + border-radius: 4px; +} + +.bar.processed { + background: #2563eb; +} + +.bar.failed { + background: #dc2626; +} + +.inline { + display: inline-flex; + gap: 6px; + align-items: center; + font-size: 13px; +} + +.event-table-wrap { + overflow: auto; + border: 1px solid #e5e7eb; + border-radius: 10px; +} + +.event-table { + width: 100%; + border-collapse: collapse; + min-width: 900px; + background: #fff; +} + +.event-table th, +.event-table td { + border-bottom: 1px solid #f3f4f6; + padding: 10px; + text-align: left; + vertical-align: top; + font-size: 13px; +} + +.event-table th { + background: #f9fafb; + font-weight: 600; +} + +.evt-type { + width: fit-content; + border-radius: 999px; + padding: 2px 8px; + color: #fff; + font-size: 11px; +} + +.evt-type.enqueue { + background: #6b7280; +} + +.evt-type.start { + background: #2563eb; +} + +.evt-type.success { + background: #059669; +} + +.evt-type.failed, +.evt-type.worker-error { + background: #dc2626; +} + +.admin-ok { + color: #166534; + margin: 0; +} + +.admin-warn { + color: #92400e; + margin: 0; +} + +.admin-error { + color: #b91c1c; + margin: 0 0 8px 0; +} + +.delete-form { + display: flex; + justify-content: end; +} + +.lecture-card-actions { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; + padding: 8px 0; + margin-bottom: 4px; + border-bottom: 1px solid #e5e7eb; +} + +.lecture-card-actions .btn-edit { + display: inline-block; + padding: 6px 12px; + font-size: 13px; + color: #1d4ed8; + background: #eff6ff; + border: 1px solid #bfdbfe; + border-radius: 8px; + text-decoration: none; + cursor: pointer; +} + +.lecture-card-actions .btn-edit:hover { + background: #dbeafe; +} + +.delete-form-inline { + display: inline; +} + +.delete-form-inline button { + padding: 6px 12px; + font-size: 13px; +} + +.form-actions { + display: flex; + gap: 12px; + align-items: center; +} + +.muted { + color: #9ca3af; + font-size: 12px; +} + +.thumb-state-row { + display: grid; + gap: 4px; +} + +.state-chip { + width: fit-content; + border-radius: 999px; + padding: 2px 8px; + font-size: 12px; + font-weight: 600; +} + +.state-chip.ready { + background: #dcfce7; + color: #166534; +} + +.state-chip.processing { + background: #dbeafe; + color: #1d4ed8; +} + +.state-chip.pending { + background: #f3f4f6; + color: #374151; +} + +.state-chip.failed { + background: #fee2e2; + color: #b91c1c; +} + +.error-text { + color: #b91c1c; +} + +.danger { + background: #dc2626; +} + +.ghost { + background: #4b5563; +} + +.viewer-wrap { + max-width: 1260px; + margin: 20px auto; + padding: 0 24px 32px; +} + +.back-link { + text-decoration: none; + display: inline-block; + margin-bottom: 10px; +} + +.description { + color: #4b5563; +} + +.youtube-frame { + width: 100%; + aspect-ratio: 16 / 9; + border-radius: 12px; + overflow: hidden; + border: 1px solid #d1d5db; + background: #000; +} + +.youtube-frame iframe { + width: 100%; + height: 100%; +} + +.ppt-tools { + margin: 14px 0; + color: #374151; +} + +.slide-list { + display: grid; + gap: 12px; +} + +.slide-card { + border: 1px solid #e5e7eb; + border-radius: 12px; + background: #fff; + padding: 16px; +} + +.slide-card h2 { + margin: 0; + font-size: 18px; +} + +.slide-card header p { + margin: 4px 0 0; + color: #4b5563; +} + +.slide-image-wrap { + margin: 12px 0; + border-radius: 8px; + overflow: hidden; + background: #f3f4f6; +} +.slide-image { + display: block; + max-width: 100%; + height: auto; +} + +.slide-card ul { + margin: 10px 0 0; + padding-left: 20px; + display: grid; + gap: 6px; +} + +.empty { + color: #6b7280; +} + +.list-hint { + margin: 0 0 12px; + color: #6b7280; + font-size: 13px; +} + +.list-query-actions { + margin-top: 12px; +} + +.form-hint { + display: block; + margin-top: 4px; + color: #6b7280; + font-size: 13px; +} + +.subtitle { + margin: 0 0 16px; + color: #6b7280; + font-size: 14px; +} + +.search-bar-wrap { + margin-bottom: 12px; +} + +.search-input { + width: 100%; + max-width: 400px; + padding: 10px 12px 10px 36px; + border: 1px solid #d1d5db; + border-radius: 8px; +} + +.filter-tags { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} + +.filter-tag { + padding: 6px 12px; + border: 1px solid #d1d5db; + border-radius: 999px; + background: #fff; + font-size: 13px; + cursor: pointer; +} + +.filter-tag:hover { + background: #f3f4f6; +} + +.ai-card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 16px; +} + +.ai-card { + border: 1px solid #e5e7eb; + border-radius: 12px; + padding: 16px; +} + +/* 검색 필터: [hidden]이 a.ai-card-link { display: block } 등에 밀리지 않도록 */ +.ai-card[hidden] { + display: none !important; +} + +.ai-card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.ai-card-icon { + font-size: 24px; +} + +.status-chip { + font-size: 12px; + padding: 2px 8px; + border-radius: 6px; +} + +.status-chip.public { + background: #dcfce7; + color: #166534; +} + +.status-chip.private { + background: #fef3c7; + color: #92400e; +} + +.ai-card h3 { + margin: 0 0 8px; + font-size: 16px; +} + +.ai-card p { + margin: 0 0 8px; + font-size: 14px; + color: #4b5563; +} + +a.ai-card-link { + display: block; + text-decoration: none; + color: inherit; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +a.ai-card-link:hover { + border-color: #cbd5e1; + box-shadow: 0 4px 14px rgba(15, 23, 42, 0.08); +} + +a.ai-card-link:focus-visible { + outline: 2px solid #f59e0b; + outline-offset: 2px; +} + +/* AI 탐색: DEV·비관리자 — 프롬프트 외 비활성 */ +.ai-card.ai-card-disabled { + opacity: 0.55; + pointer-events: none; + cursor: not-allowed; + user-select: none; +} + +.ai-explore-action-disabled { + opacity: 0.55; + pointer-events: none; + cursor: not-allowed; + user-select: none; +} + +body.ai-explore-dev-guest .search-input:disabled { + opacity: 0.65; + cursor: not-allowed; +} + +/* AI 프롬프트 라이브러리 (NotebookLM 스타일 빌더 느낌의 히어로 + 카드 + 미리보기) */ +.prompts-back { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 14px; + color: #4b5563; + text-decoration: none; + margin-bottom: 16px; +} + +.prompts-back:hover { + color: #1f2937; +} + +.prompts-hero { + background: linear-gradient(135deg, #0f172a 0%, #1e3a5f 55%, #1e40af 100%); + color: #fff; + border-radius: 16px; + padding: 28px 32px; + margin-bottom: 20px; + border: 1px solid rgba(255, 255, 255, 0.12); + box-shadow: 0 12px 40px rgba(15, 23, 42, 0.25); +} + +.prompts-hero h1 { + margin: 0 0 8px; + font-size: 26px; + font-weight: 700; + letter-spacing: -0.02em; +} + +.prompts-hero .prompts-lead { + margin: 0 0 12px; + color: #e2e8f0; + font-size: 15px; + line-height: 1.5; + max-width: 720px; +} + +.prompts-hero .prompts-stats { + margin: 0; + font-size: 13px; + color: #94a3b8; + font-weight: 500; +} + +.prompts-layout { + display: grid; + grid-template-columns: minmax(280px, 1fr) minmax(360px, 1.15fr); + gap: 20px; + align-items: start; +} + +.prompts-grid-title { + margin: 0 0 12px; + font-size: 17px; + color: #111827; +} + +.prompts-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 12px; +} + +.prompt-template-card { + text-align: left; + border: 1px solid #e5e7eb; + border-radius: 12px; + padding: 14px 16px; + background: #fff; + cursor: pointer; + font: inherit; + transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease; +} + +.prompt-template-card:hover { + border-color: #cbd5e1; + box-shadow: 0 4px 12px rgba(15, 23, 42, 0.06); +} + +.prompt-template-card.is-selected { + border-color: #3b82f6; + background: #eff6ff; + box-shadow: 0 0 0 1px #3b82f6; +} + +.prompt-template-card h3 { + margin: 0 0 6px; + font-size: 15px; + color: #111827; +} + +.prompt-template-card p { + margin: 0 0 8px; + font-size: 13px; + color: #6b7280; + line-height: 1.45; +} + +.prompt-template-card .prompt-mini-tag { + display: inline-block; + font-size: 11px; + padding: 2px 8px; + border-radius: 999px; + background: #f3f4f6; + color: #4b5563; +} + +.prompts-preview-panel { + position: sticky; + top: 88px; + border: 1px solid #e5e7eb; + border-radius: 16px; + padding: 18px; + background: #fafafa; + min-height: 320px; +} + +.prompts-preview-panel h2 { + margin: 0 0 12px; + font-size: 17px; +} + +.prompts-preview-empty { + margin: 0; + color: #9ca3af; + font-size: 14px; +} + +.prompts-preview-toolbar { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 12px; +} + +.prompts-copy-btn { + border: none; + background: #f59e0b; + color: #fff; + border-radius: 999px; + padding: 8px 16px; + font-weight: 600; + font-size: 13px; + cursor: pointer; + font-family: inherit; +} + +.prompts-copy-btn:hover { + background: #ea580c; +} + +.prompts-copy-btn:disabled { + background: #d1d5db; + color: #9ca3af; + cursor: not-allowed; +} + +.prompts-body-textarea { + width: 100%; + min-height: 280px; + padding: 14px; + border: 1px solid #e5e7eb; + border-radius: 12px; + font-size: 14px; + line-height: 1.55; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + resize: vertical; + box-sizing: border-box; + background: #fff; +} + +.prompts-body-textarea:focus { + outline: none; + border-color: #3b82f6; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); +} + +.prompts-hint { + margin: 10px 0 0; + font-size: 12px; + color: #6b7280; +} + +@media (max-width: 960px) { + .prompts-layout { + grid-template-columns: 1fr; + } + + .prompts-preview-panel { + position: static; + } +} + +.chat-area { + display: flex; + flex-direction: column; + height: 100vh; +} + +.chat-api-warning { + margin: 0 24px 12px; + padding: 10px 14px; + border-radius: 10px; + background: #fef3c7; + border: 1px solid #fcd34d; + color: #92400e; + font-size: 13px; + line-height: 1.45; +} + +.chat-api-warning code { + font-size: 12px; + background: rgba(255, 255, 255, 0.6); + padding: 1px 4px; + border-radius: 4px; +} + +.chat-msg-streaming .chat-msg-content { + min-height: 1.5em; +} + +.chat-typing-dots { + display: inline-flex; + gap: 4px; + align-items: center; + color: #9ca3af; + font-weight: 700; +} + +.chat-typing-dots span { + animation: chatTypingDot 1.1s ease-in-out infinite; + opacity: 0.35; +} + +.chat-typing-dots span:nth-child(2) { + animation-delay: 0.15s; +} + +.chat-typing-dots span:nth-child(3) { + animation-delay: 0.3s; +} + +@keyframes chatTypingDot { + 0%, + 80%, + 100% { + opacity: 0.25; + transform: translateY(0); + } + 40% { + opacity: 1; + transform: translateY(-2px); + } +} + +.chat-send-btn.is-busy { + opacity: 0.85; + cursor: wait; +} + +.chat-main { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + max-width: 800px; + margin: 0 auto; + width: 100%; + padding: 0 24px 24px; +} + +.chat-messages { + flex: 1; + overflow-y: auto; + padding: 20px 0; + display: flex; + flex-direction: column; + align-items: stretch; +} + +.chat-welcome { + padding: 40px 0; + text-align: center; +} + +.chat-welcome h2 { + margin: 0 0 12px; + font-size: 24px; + color: #1f2937; +} + +.chat-welcome p { + margin: 0; + color: #6b7280; + font-size: 15px; +} + +.chat-msg { + margin-bottom: 16px; + max-width: 85%; +} + +.chat-msg-user { + align-self: flex-end; + width: fit-content; + max-width: 85%; +} + +.chat-msg-assistant { + align-self: flex-start; +} + +.chat-msg-user .chat-msg-content { + background: #e5e7eb; + color: #1f2937; + padding: 12px 16px; + border-radius: 16px 16px 4px 16px; + text-align: right; + border: 1px solid #d1d5db; +} + +.chat-msg-assistant .chat-msg-content { + background: #f3f4f6; + color: #1f2937; + padding: 12px 16px; + border-radius: 16px 16px 16px 4px; +} + +/** 어시스턴트 메시지: marked + DOMPurify 렌더 결과 */ +.chat-msg-assistant .chat-md-body { + font-size: 14px; + line-height: 1.55; + word-wrap: break-word; + overflow-wrap: break-word; +} + +.chat-msg-assistant .chat-md-body > *:first-child { + margin-top: 0; +} + +.chat-msg-assistant .chat-md-body > *:last-child { + margin-bottom: 0; +} + +.chat-msg-assistant .chat-md-body h1, +.chat-msg-assistant .chat-md-body h2, +.chat-msg-assistant .chat-md-body h3, +.chat-msg-assistant .chat-md-body h4 { + margin: 12px 0 8px; + font-weight: 700; + color: #111827; + line-height: 1.35; +} + +.chat-msg-assistant .chat-md-body h1 { + font-size: 1.2rem; +} + +.chat-msg-assistant .chat-md-body h2 { + font-size: 1.1rem; +} + +.chat-msg-assistant .chat-md-body h3, +.chat-msg-assistant .chat-md-body h4 { + font-size: 1.02rem; +} + +.chat-msg-assistant .chat-md-body p { + margin: 8px 0; +} + +.chat-msg-assistant .chat-md-body hr { + border: none; + border-top: 1px solid #d1d5db; + margin: 14px 0; +} + +.chat-msg-assistant .chat-md-body ul, +.chat-msg-assistant .chat-md-body ol { + margin: 8px 0; + padding-left: 1.35em; +} + +.chat-msg-assistant .chat-md-body li { + margin: 4px 0; +} + +.chat-msg-assistant .chat-md-body ul { + list-style: disc; +} + +.chat-msg-assistant .chat-md-body ol { + list-style: decimal; +} + +.chat-msg-assistant .chat-md-body blockquote { + margin: 8px 0; + padding: 6px 0 6px 12px; + border-left: 3px solid #d1d5db; + color: #4b5563; +} + +.chat-msg-assistant .chat-md-body strong { + font-weight: 700; + color: #111827; +} + +.chat-msg-assistant .chat-md-body a { + color: #2563eb; + text-decoration: underline; + text-underline-offset: 2px; + word-break: break-word; +} + +.chat-msg-assistant .chat-md-body a:hover { + color: #1d4ed8; +} + +.chat-msg-assistant .chat-md-body pre { + margin: 10px 0; + padding: 10px 12px; + border-radius: 8px; + background: #1f2937; + color: #e5e7eb; + font-size: 12px; + line-height: 1.45; + overflow-x: auto; +} + +.chat-msg-assistant .chat-md-body pre code { + background: none; + padding: 0; + font-size: inherit; +} + +.chat-msg-assistant .chat-md-body :not(pre) > code { + padding: 2px 6px; + border-radius: 4px; + background: #e5e7eb; + font-size: 0.9em; +} + +.chat-msg-assistant .chat-md-body table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + margin: 10px 0; +} + +.chat-msg-assistant .chat-md-body th, +.chat-msg-assistant .chat-md-body td { + border: 1px solid #e5e7eb; + padding: 6px 8px; + text-align: left; +} + +.chat-msg-assistant .chat-md-body th { + background: #f9fafb; + font-weight: 600; +} + +.chat-status-line { + margin-top: 8px; + font-size: 12px; + color: #6b7280; +} + +.chat-sources { + margin-top: 10px; + padding: 10px 12px; + border-radius: 10px; + background: #fff; + border: 1px solid #e5e7eb; + font-size: 12px; + line-height: 1.45; +} + +.chat-sources-title { + font-weight: 600; + color: #374151; + margin-bottom: 6px; +} + +.chat-sources-list { + margin: 0; + padding-left: 1.1em; + color: #4b5563; +} + +.chat-sources-list a { + color: #2563eb; + text-decoration: underline; + text-underline-offset: 2px; +} + +.chat-sources-list a:hover { + color: #1d4ed8; +} + +.chat-input-wrap { + padding: 16px 0; + border-top: 1px solid #e5e7eb; +} + +.chat-form { + display: flex; + gap: 12px; + align-items: flex-end; +} + +.chat-model-select { + flex-shrink: 0; + width: 160px; + height: 44px; + padding: 0 12px; + border: 1px solid #d1d5db; + border-radius: 12px; + font: inherit; + font-size: 14px; + background: #fff; + cursor: pointer; +} + +.chat-model-select:focus { + outline: none; + border-color: #f59e0b; +} + +.chat-form textarea { + flex: 1; + min-height: 44px; + max-height: 120px; + padding: 12px 16px; + border: 1px solid #d1d5db; + border-radius: 12px; + resize: none; + font: inherit; +} + +.chat-form textarea:focus { + outline: none; + border-color: #f59e0b; + box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.2); +} + +.chat-send-btn { + width: 44px; + height: 44px; + border-radius: 12px; + background: #f59e0b; + color: #fff; + border: none; + font-size: 18px; + cursor: pointer; + flex-shrink: 0; +} + +.chat-send-btn:hover:not(:disabled) { + background: #d97706; +} + +.chat-send-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.chat-disclaimer { + margin: 12px 0 0; + font-size: 12px; + color: #9ca3af; +} + +.chat-placeholder { + padding: 24px; + text-align: center; +} + +.chat-placeholder .muted { + color: #9ca3af; + font-size: 13px; + margin-top: 8px; +} + +.form-actions { + display: flex; + gap: 12px; + margin-top: 16px; +} + +.btn-ghost { + background: #fff !important; + color: #374151 !important; + border: 1px solid #d1d5db !important; +} + +.upload-zone { + border: 2px dashed #d1d5db; + border-radius: 12px; + padding: 32px; + text-align: center; + color: #6b7280; + font-size: 14px; + cursor: pointer; +} + +.upload-zone:hover { + border-color: #f59e0b; + background: #fffbeb; +} + +.breadcrumb { + margin: 0 0 16px; + font-size: 14px; + color: #6b7280; +} + +.breadcrumb a { + color: #1d4ed8; + text-decoration: none; +} + +.panel h3 { + margin: 0 0 12px; + font-size: 16px; +} + +@media (max-width: 900px) { + .nav-mobile-toggle { + display: flex; + } + + .left-nav { + position: fixed; + left: 0; + top: 0; + height: 100vh; + height: 100dvh; + transform: translateX(-100%); + transition: transform 0.22s ease; + z-index: 101; + box-shadow: 4px 0 24px rgba(15, 23, 42, 0.12); + } + + .left-nav.nav-drawer-open { + transform: translateX(0); + } + + body.nav-mobile-open { + overflow: hidden; + touch-action: none; + } + + .topbar { + height: auto; + padding: 14px 16px; + } + .topbar h1 { + font-size: 24px; + } + .top-action { + padding: 8px 12px; + } + .top-action-link { + padding: 8px 12px; + } + .container, + .viewer-wrap { + padding: 0 12px; + } + + .container.container-ai-full { + padding: 0 12px; + } +} + +/* Admin token modal */ +.modal-overlay { + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; +} +.modal-overlay[hidden] { + display: none !important; + pointer-events: none !important; +} +.modal-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.4); +} +.modal-content { + position: relative; + background: #fff; + border-radius: 16px; + padding: 24px; + max-width: 400px; + width: 100%; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.2); +} +.modal-content h3 { + margin: 0 0 8px; + font-size: 20px; +} +.modal-desc { + margin: 0 0 16px; + color: #6b7280; + font-size: 14px; +} +.admin-token-form input[type="password"] { + width: 100%; + padding: 10px 12px; + border: 1px solid #e5e7eb; + border-radius: 8px; + font-size: 14px; + margin-bottom: 12px; +} +.admin-token-form input:focus { + outline: none; + border-color: #f97316; +} +.modal-actions { + display: flex; + gap: 8px; + justify-content: flex-end; +} +.modal-actions button { + padding: 8px 16px; + border-radius: 8px; + font-size: 14px; + cursor: pointer; +} +.modal-actions button[type="submit"] { + background: #f97316; + color: #fff; + border: none; +} +.modal-actions button[type="submit"]:hover { + background: #ea580c; +} + +.table-wrap { + overflow-x: auto; +} +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} +.data-table th, +.data-table td { + padding: 10px 12px; + text-align: left; + border-bottom: 1px solid #e5e7eb; +} +.data-table th { + background: #f9fafb; + font-weight: 600; + color: #374151; +} +.data-table tbody tr:hover { + background: #f9fafb; +} +.cell-truncate { + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.form-grid h3 { + grid-column: 1 / -1; + margin: 16px 0 8px; + padding-top: 12px; + border-top: 1px solid #e5e7eb; + font-size: 15px; +} +.form-grid h3:first-of-type { + margin-top: 0; + padding-top: 0; + border-top: none; +} + +.label-text { + display: inline; +} +.required { + color: #dc2626; + font-weight: 600; + vertical-align: super; + font-size: 0.85em; + margin-left: 1px; +} + +/* 회의록 AI — content-area 너비까지 쓰고, 오른쪽 열이 뷰포트에 맞춰 넓어짐 */ +.meeting-minutes-main.container { + max-width: none; + width: 100%; + margin: 20px 0 40px; + padding: 0 24px; + box-sizing: border-box; +} +.mm-layout { + display: grid; + grid-template-columns: minmax(220px, 280px) minmax(0, 1fr); + gap: 16px; + align-items: start; +} +.mm-workspace { + min-width: 0; +} +@media (max-width: 900px) { + .mm-layout { + grid-template-columns: 1fr; + } +} +.mm-sidebar { + position: sticky; + top: 12px; +} +.mm-sidebar-title { + margin: 0 0 8px; + font-size: 15px; +} +.mm-refresh { + width: 100%; + margin-bottom: 10px; +} +.mm-meeting-list { + list-style: none; + margin: 0; + padding: 0; + max-height: 50vh; + overflow-y: auto; +} +.mm-meeting-item { + display: flex; + align-items: center; + gap: 4px; + border-bottom: 1px solid #e5e7eb; +} +.mm-meeting-item-btn { + flex: 1; + text-align: left; + padding: 8px 4px; + border: none; + background: none; + cursor: pointer; + font-size: 13px; + color: #1f2937; +} +.mm-meeting-item-btn:hover { + color: #0f766e; +} +.mm-meeting-del { + flex-shrink: 0; + width: 28px; + height: 28px; + border: none; + background: transparent; + color: #9ca3af; + cursor: pointer; + font-size: 18px; + line-height: 1; +} +.mm-meeting-del:hover { + color: #dc2626; +} +.mm-list-empty { + font-size: 13px; + color: #6b7280; +} +.mm-prompt-panel h2 { + margin-top: 0; +} +.mm-prompt-panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 0; +} +.mm-prompt-panel-head h2 { + margin: 0; + flex: 1; + min-width: 0; + font-size: 1.125rem; +} +.mm-prompt-toggle { + flex-shrink: 0; + width: 36px; + height: 36px; + padding: 0; + border: 1px solid #d1d5db; + border-radius: 8px; + background: #fff; + color: #374151; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + line-height: 1; + transition: background 0.15s ease, border-color 0.15s ease; +} +.mm-prompt-toggle:hover { + background: #f9fafb; + border-color: #9ca3af; +} +.mm-prompt-toggle:focus-visible { + outline: 2px solid #0f766e; + outline-offset: 2px; +} +.mm-prompt-toggle-icon { + display: inline-block; + font-size: 11px; + transition: transform 0.2s ease; +} +.mm-prompt-panel.mm-prompt-collapsed .mm-prompt-toggle-icon { + transform: rotate(-90deg); +} +.mm-prompt-body .subtitle { + margin-top: 0; +} +.mm-prompt-form { + display: flex; + flex-direction: column; + gap: 0; +} +.mm-checkbox-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px 20px; + padding: 14px 16px; + background: #fafafa; + border: 1px solid #e8eaed; + border-radius: 10px; +} +.mm-checkbox-item { + display: inline-flex; + flex-direction: row; + align-items: center; + gap: 8px; + margin: 0; + cursor: pointer; + font-size: 14px; + color: #374151; + line-height: 1.35; + user-select: none; +} +.mm-checkbox-item input[type="checkbox"] { + width: 1.05rem; + height: 1.05rem; + margin: 0; + flex-shrink: 0; + accent-color: #0f766e; +} +.mm-checkbox-item span { + flex: 0 1 auto; +} +.mm-checkbox-item-locked { + cursor: default; +} +.mm-checkbox-item-locked input[type="checkbox"] { + opacity: 0.85; + cursor: not-allowed; +} +.mm-checkbox-badge { + display: inline-block; + margin-left: 4px; + padding: 1px 6px; + font-size: 11px; + font-weight: 600; + color: #0f766e; + background: #ecfdf5; + border: 1px solid #99f6e4; + border-radius: 4px; + vertical-align: middle; +} +.mm-custom-block { + margin-top: 18px; +} +.mm-field-label { + display: block; + font-size: 13px; + font-weight: 600; + color: #374151; + margin-bottom: 8px; + letter-spacing: -0.01em; +} +.mm-textarea, +.mm-input, +.mm-select { + width: 100%; + max-width: 100%; + box-sizing: border-box; + padding: 10px 12px; + font-size: 14px; + line-height: 1.45; + border: 1px solid #d1d5db; + border-radius: 8px; + background: #fff; + color: #111827; +} +.mm-textarea { + min-height: 5.5rem; + resize: vertical; +} +/* 추가 지시: 기본 높이의 약 2/3 (세로 1/3 축소) */ +.mm-custom-instr-textarea { + min-height: calc(5.5rem * 2 / 3); +} +.mm-select { + cursor: pointer; + appearance: auto; +} +.mm-file-input { + padding: 8px 0; + font-size: 14px; +} +.mm-prompt-actions { + margin-top: 16px; + padding-top: 4px; +} +.mm-tabs { + display: flex; + gap: 4px; + margin-bottom: 18px; + border-bottom: 1px solid #e5e7eb; +} +.mm-tab { + padding: 10px 16px; + border: none; + background: none; + cursor: pointer; + font-size: 14px; + color: #6b7280; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + border-radius: 8px 8px 0 0; + transition: color 0.15s ease, background 0.15s ease; +} +.mm-tab:hover { + color: #374151; + background: #f9fafb; +} +.mm-tab.is-active { + color: #0f766e; + font-weight: 600; + border-bottom-color: #0f766e; +} +/* [hidden]은 UA 기본 display:none인데 .mm-field-stack 등이 덮어써 탭이 동시에 보이는 문제 방지 */ +.mm-tab-panel[hidden] { + display: none !important; +} +.mm-field-stack { + display: flex; + flex-direction: column; + gap: 18px; + padding-top: 2px; +} +.mm-section-heading { + margin: 0; + font-size: 13px; + font-weight: 700; + color: #374151; + letter-spacing: -0.02em; +} +.mm-audio-phase { + display: flex; + flex-direction: column; + gap: 16px; +} +.mm-audio-phase + .mm-audio-phase { + margin-top: 4px; + padding-top: 20px; + border-top: 1px solid #e8eaed; +} +.mm-transcribe-model-only { + max-width: 100%; +} +.mm-field { + display: flex; + flex-direction: column; + gap: 0; + margin: 0; +} +.mm-field-narrow { + max-width: 320px; +} +.mm-title-date-field { + margin: 0; +} +.mm-title-date-labels { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + gap: 12px; +} +.mm-title-date-labels .mm-field-label:last-child { + text-align: right; + min-width: 7rem; +} +.mm-title-date-box { + display: flex; + align-items: stretch; + gap: 0; + border: 1px solid #d1d5db; + border-radius: 8px; + background: #fff; + overflow: hidden; +} +.mm-title-date-box .mm-title-input { + flex: 1 1 auto; + min-width: 0; + width: 0; + border: none !important; + border-radius: 0; + border-right: 1px solid #e5e7eb !important; +} +.mm-title-date-box .mm-title-input:focus { + outline: none; + box-shadow: inset 0 0 0 2px rgba(15, 118, 110, 0.25); +} +.mm-date-wrap { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + background: #fafafa; +} +.mm-date-native { + border: none; + background: transparent; + font-size: 14px; + color: #111827; + min-width: 9.5rem; + max-width: 11rem; + padding: 6px 4px; + font-family: inherit; +} +.mm-date-native::-webkit-calendar-picker-indicator { + cursor: pointer; + opacity: 0.85; +} +.mm-date-native:focus { + outline: none; +} +.mm-date-trigger { + flex-shrink: 0; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 4px; + background: #2563eb; + color: #fff; + font-size: 10px; + line-height: 1; + cursor: pointer; + padding: 0; +} +.mm-date-trigger:hover { + background: #1d4ed8; +} +.mm-date-trigger:disabled { + opacity: 0.5; + cursor: not-allowed; +} +@media (max-width: 520px) { + .mm-title-date-box { + flex-wrap: wrap; + } + .mm-title-date-box .mm-title-input { + width: 100%; + flex: 1 1 100%; + border-right: none !important; + border-bottom: 1px solid #e5e7eb !important; + } + .mm-date-wrap { + width: 100%; + justify-content: flex-end; + } +} +.mm-model-row { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px 24px; + align-items: start; +} +@media (max-width: 640px) { + .mm-model-row { + grid-template-columns: 1fr; + } +} +.mm-audio-hint { + margin: -4px 0 0; + padding: 10px 14px; + font-size: 13px; + line-height: 1.5; + color: #1e40af; + background: linear-gradient(180deg, #eff6ff 0%, #f8fafc 100%); + border: 1px solid #bfdbfe; + border-radius: 8px; +} +.mm-audio-summary-hint { + margin: -6px 0 0; + font-size: 12px; + line-height: 1.55; + color: #64748b; +} +.mm-field-help { + display: block; + margin-top: 8px; + font-size: 12px; + line-height: 1.5; + color: #64748b; + font-weight: 400; +} +.mm-form-actions { + margin-top: 4px; +} +.mm-audio-panel .mm-field-help { + max-width: 42rem; +} +.mm-result-panel { + margin-top: 8px; +} +.mm-result-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 8px; +} +.mm-result-title { + margin: 0; + font-size: 1.1rem; +} +.mm-result-hint { + margin: 0 0 10px; + font-size: 13px; + color: #64748b; + line-height: 1.45; +} +.mm-result-split { + display: flex; + flex-direction: column; + gap: 18px; + align-items: stretch; +} +.mm-result-field { + display: flex; + flex-direction: column; + gap: 6px; + min-width: 0; +} +.mm-result-field-label { + font-size: 13px; + font-weight: 600; + color: #334155; +} +.mm-result-textarea { + width: 100%; + box-sizing: border-box; + min-height: 280px; + resize: vertical; + font-family: ui-monospace, SFMono-Regular, "Segoe UI Mono", Menlo, Consolas, monospace; + font-size: 13px; + line-height: 1.5; +} +.mm-result-textarea-half { + min-height: 220px; +} +/** 전사 기록: 회의록 렌더 영역과 동일한 산세리프·여백 (회의록 textarea는 마크다운 편집용 모노스페이스 유지) */ +.mm-result-textarea.mm-transcript-textarea { + font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", "Malgun Gothic", "Apple SD Gothic Neo", "Noto Sans KR", sans-serif; + font-size: 14px; + line-height: 1.6; + color: #1e293b; + background: #ffffff; + border: 1px solid #e2e8f0; + border-radius: 10px; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); + min-height: 260px; + max-height: min(70vh, 640px); +} +.mm-minutes-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 6px; +} +.mm-minutes-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} +.mm-minutes-edit, +.mm-minutes-cancel { + font-size: 13px; + font-weight: 600; +} +.mm-minutes-apply { + font-size: 13px; + padding: 6px 14px; +} +.mm-minutes-editor-wrap { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} +.mm-minutes-rendered { + box-sizing: border-box; + width: 100%; + min-height: 260px; + max-height: min(70vh, 640px); + overflow: auto; + padding: 16px 18px; + background: #ffffff; + border: 1px solid #e2e8f0; + border-radius: 10px; + font-size: 14px; + line-height: 1.6; + word-wrap: break-word; + overflow-wrap: break-word; + color: #1e293b; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); +} +.mm-minutes-rendered.mm-minutes-rendered-empty { + color: #94a3b8; + font-style: italic; + background: #f8fafc; +} +.mm-minutes-rendered > *:first-child { + margin-top: 0; +} +.mm-minutes-rendered > *:last-child { + margin-bottom: 0; +} +.mm-minutes-rendered h1, +.mm-minutes-rendered h2, +.mm-minutes-rendered h3, +.mm-minutes-rendered h4 { + margin: 14px 0 10px; + font-weight: 700; + color: #0f172a; + line-height: 1.35; +} +.mm-minutes-rendered h1 { + font-size: 1.25rem; +} +.mm-minutes-rendered h2 { + font-size: 1.18rem; + margin-top: 18px; +} +.mm-minutes-rendered h3, +.mm-minutes-rendered h4 { + font-size: 1.04rem; +} +.mm-minutes-rendered p { + margin: 8px 0; +} +.mm-minutes-rendered hr { + border: none; + border-top: 1px solid #cbd5e1; + margin: 16px 0; +} +.mm-minutes-rendered ul, +.mm-minutes-rendered ol { + margin: 8px 0; + padding-left: 1.35em; +} +.mm-minutes-rendered li { + margin: 4px 0; +} +.mm-minutes-rendered ul { + list-style: disc; +} +.mm-minutes-rendered ol { + list-style: decimal; +} +.mm-minutes-rendered blockquote { + margin: 8px 0; + padding: 6px 0 6px 12px; + border-left: 3px solid #cbd5e1; + color: #475569; +} +.mm-minutes-rendered strong { + font-weight: 700; + color: #0f172a; +} +.mm-minutes-rendered a { + color: #2563eb; + text-decoration: underline; + text-underline-offset: 2px; + word-break: break-word; +} +.mm-minutes-rendered pre { + margin: 10px 0; + padding: 10px 12px; + border-radius: 8px; + background: #1e293b; + color: #e2e8f0; + font-size: 12px; + line-height: 1.45; + overflow-x: auto; +} +.mm-minutes-rendered :not(pre) > code { + padding: 2px 6px; + border-radius: 4px; + background: #e2e8f0; + font-size: 0.92em; +} +.mm-minutes-source[hidden] { + display: none !important; +} +.mm-result-textarea:disabled { + opacity: 0.75; + cursor: not-allowed; +} +.mm-gen-progress { + margin: 12px 0 8px; + padding: 14px 16px; + background: linear-gradient(180deg, #f0fdfa 0%, #f8fafc 100%); + border: 1px solid #99f6e4; + border-radius: 10px; +} +.mm-gen-progress-track { + position: relative; + height: 6px; + border-radius: 3px; + background: #e5e7eb; + overflow: hidden; + margin-bottom: 10px; +} +.mm-gen-progress-bar { + position: absolute; + left: 0; + top: 0; + height: 100%; + width: 38%; + border-radius: 3px; + background: linear-gradient(90deg, #0f766e, #14b8a6, #0f766e); + animation: mm-gen-progress-slide 1.35s ease-in-out infinite; +} +@keyframes mm-gen-progress-slide { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(280%); + } +} +@media (prefers-reduced-motion: reduce) { + .mm-gen-progress-bar { + animation: none; + width: 100%; + max-width: none; + opacity: 0.85; + } +} +.mm-gen-progress-msg { + margin: 0; + font-size: 13px; + line-height: 1.45; + color: #0f766e; + font-weight: 600; +} +.mm-result-body { + white-space: pre-wrap; + word-break: break-word; + font-size: 14px; + line-height: 1.55; + padding: 12px; + background: #f9fafb; + border-radius: 8px; + border: 1px solid #e5e7eb; + max-height: 60vh; + overflow: auto; +} + +/* 업무 체크리스트 AI */ +.task-checklist-page .tcl-lead { + margin: 0 0 16px; + max-width: 52rem; + line-height: 1.55; +} +.tcl-toolbar-row { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 12px 20px; +} +.tcl-filter { + display: flex; + flex-direction: column; + gap: 6px; +} +.tcl-filter-label { + font-size: 12px; + font-weight: 600; + color: #64748b; +} +.tcl-sort-bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 10px; + margin: 0 0 10px; + padding-bottom: 10px; + border-bottom: 1px solid #e5e7eb; +} +.tcl-sort-label { + font-size: 12px; + font-weight: 600; + color: #64748b; + margin-right: 4px; +} +.task-checklist-page .tcl-sort-btn { + padding: 6px 12px; + font-size: 13px; + font-weight: 500; + color: #334155; + background: #fff; + border: 1px solid #cbd5e1; + border-radius: 8px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} +.task-checklist-page .tcl-sort-btn:hover { + background: #f8fafc; + border-color: #94a3b8; +} +.task-checklist-page .tcl-sort-btn.tcl-sort-btn-active { + color: #0f766e; + border-color: #0f766e; + background: #ecfdf5; + font-weight: 600; +} +.tcl-section-title { + margin: 0 0 12px; + font-size: 1.05rem; +} +.tcl-add-grid { + display: grid; + gap: 12px; + grid-template-columns: 1fr 1fr auto; + align-items: end; +} +@media (max-width: 720px) { + .tcl-add-grid { + grid-template-columns: 1fr; + } +} +.tcl-field-wide { + grid-column: 1 / -1; +} +.tcl-list { + list-style: none; + margin: 0; + padding: 0; +} +.tcl-item { + border-bottom: 1px solid #e5e7eb; + padding: 12px 0; +} +.tcl-item-done .tcl-item-title { + text-decoration: line-through; + color: #64748b; +} +.tcl-item-row { + display: flex; + align-items: flex-start; + gap: 12px; +} +.tcl-checkbox { + width: 1.15rem; + height: 1.15rem; + margin-top: 2px; + flex-shrink: 0; + accent-color: #0f766e; +} +.tcl-item-body { + flex: 1; + min-width: 0; +} +.tcl-item-meeting { + font-size: 12px; + font-weight: 500; + color: #0f766e; + margin-bottom: 4px; + line-height: 1.35; +} +.tcl-item-title { + font-size: 15px; + font-weight: 600; + color: #1f2937; + line-height: 1.4; +} +.tcl-item-meta { + margin-top: 6px; + font-size: 13px; + color: #64748b; + line-height: 1.45; + white-space: pre-wrap; + word-break: break-word; +} +.tcl-tooltip-wrap { + position: relative; + display: block; + outline: none; + border-radius: 4px; +} +.tcl-tooltip-wrap:hover .tcl-popup, +.tcl-tooltip-wrap:focus .tcl-popup { + display: block; +} +.tcl-popup { + display: none; + position: absolute; + left: 0; + top: calc(100% + 6px); + min-width: 240px; + max-width: min(400px, 92vw); + padding: 12px 14px; + background: #fff; + border: 1px solid #cbd5e1; + border-radius: 8px; + box-shadow: 0 10px 28px rgba(15, 23, 42, 0.12); + z-index: 20; + font-size: 13px; + line-height: 1.5; + color: #334155; +} +.tcl-popup-block { + margin-bottom: 10px; +} +.tcl-popup-block:last-child { + margin-bottom: 0; +} +.tcl-popup-label { + font-size: 11px; + font-weight: 700; + color: #0f766e; + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 4px; +} +.tcl-popup-value { + white-space: pre-wrap; + word-break: break-word; +} +.tcl-item-actions { + flex-shrink: 0; + display: flex; + gap: 8px; + align-items: flex-start; +} +.task-checklist-page .tcl-btn-outline { + padding: 6px 12px; + font-size: 13px; + font-weight: 500; + border: 1px solid #2563eb; + color: #2563eb; + background: #fff; + border-radius: 6px; + cursor: pointer; + line-height: 1.3; +} +.task-checklist-page .tcl-btn-outline:hover { + background: #eff6ff; +} +.task-checklist-page .tcl-btn-outline-danger { + border-color: #dc2626; + color: #dc2626; +} +.task-checklist-page .tcl-btn-outline-danger:hover { + background: #fef2f2; +} +.tcl-modal { + position: fixed; + inset: 0; + z-index: 100; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; +} +.tcl-modal[hidden] { + display: none !important; +} +.tcl-modal-backdrop { + position: absolute; + inset: 0; + background: rgba(15, 23, 42, 0.35); +} +.tcl-modal-panel { + position: relative; + width: 100%; + max-width: 420px; + padding: 20px 22px; + background: #fff; + border-radius: 12px; + box-shadow: 0 20px 50px rgba(15, 23, 42, 0.2); +} +.tcl-modal-title { + margin: 0 0 16px; + font-size: 1.05rem; +} +.tcl-complete-preview { + margin: -8px 0 14px; + font-size: 13px; + color: #475569; + line-height: 1.45; + word-break: break-word; +} +.tcl-modal-field { + display: flex; + flex-direction: column; + gap: 6px; + margin-bottom: 14px; +} +.tcl-modal-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 8px; +} +.tcl-empty { + margin: 0; + padding: 16px; + color: #64748b; + font-size: 14px; +} diff --git a/resources/ax-apply/AX_과제_신청서.docx b/resources/ax-apply/AX_과제_신청서.docx new file mode 100644 index 0000000..1b82fb4 Binary files /dev/null and b/resources/ax-apply/AX_과제_신청서.docx differ diff --git a/scripts/apply-schema.js b/scripts/apply-schema.js new file mode 100644 index 0000000..9f76729 --- /dev/null +++ b/scripts/apply-schema.js @@ -0,0 +1,62 @@ +require("dotenv").config({ quiet: true }); + +const fs = require("fs").promises; +const path = require("path"); +const { Pool } = require("pg"); + +const ROOT_DIR = path.join(__dirname, ".."); +const SCHEMA_PATH = path.join(ROOT_DIR, "db", "schema.sql"); + +const getConfig = () => ({ + host: process.env.DB_HOST, + port: Number(process.env.DB_PORT || 5432), + database: process.env.DB_DATABASE, + user: process.env.DB_USERNAME, + password: process.env.DB_PASSWORD, + ssl: process.env.DB_SSL === "1" ? { rejectUnauthorized: false } : false, +}); + +const validateConfig = (config) => { + const missing = ["host", "database", "user", "password"].filter((key) => !config[key]); + if (missing.length) { + throw new Error(`Missing PostgreSQL envs: ${missing.join(", ")}`); + } +}; + +const run = async () => { + const config = getConfig(); + validateConfig(config); + + const sql = await fs.readFile(SCHEMA_PATH, "utf-8"); + const pool = new Pool(config); + try { + await pool.query("SELECT 1"); + await pool.query(sql); + console.log("Schema applied successfully."); + } finally { + await pool.end(); + } +}; + +run().catch((error) => { + console.error("Failed to apply schema:", error.message); + const msg = String(error.message || ""); + if (error.code === "28P01" || /password authentication failed/i.test(msg)) { + console.error( + "\n[DB 인증 실패] .env의 DB_USERNAME·DB_PASSWORD가 PostgreSQL에 등록된 사용자·비밀번호와 일치하는지 확인하세요.", + ); + console.error( + " (원격 DB면 관리자에게 계정을 확인하고, 로컬이면 `sudo -u postgres psql`에서 \\password 사용자명 으로 비밀번호를 맞추세요.)", + ); + } else if (/ECONNREFUSED|ENOTFOUND/i.test(msg) || error.code === "ECONNREFUSED") { + console.error("\n[DB 연결 불가] DB_HOST·DB_PORT가 맞는지, 방화벽·PostgreSQL listen_addresses를 확인하세요."); + } else if (error.code === "28000" || /role .* does not exist/i.test(msg)) { + console.error( + "\n[DB 사용자 없음] .env의 DB_USERNAME에 해당하는 PostgreSQL 역할(role)이 서버에 없습니다.", + ); + console.error( + " 슈퍼유저로 접속해 CREATE ROLE ... LOGIN PASSWORD '...'; 및 GRANT를 실행하거나, db/bootstrap-role.sql.example을 참고하세요.", + ); + } + process.exit(1); +}); diff --git a/scripts/check-ax-schema.js b/scripts/check-ax-schema.js new file mode 100644 index 0000000..16aaba4 --- /dev/null +++ b/scripts/check-ax-schema.js @@ -0,0 +1,32 @@ +#!/usr/bin/env node +/** + * ax_assignments 테이블 컬럼 타입 확인 (PostgreSQL) + * 사용: node scripts/check-ax-schema.js + */ +require("dotenv").config({ path: require("path").join(__dirname, "..", ".env") }); +const { Pool } = require("pg"); + +const pool = new Pool({ + host: process.env.DB_HOST || "localhost", + port: Number(process.env.DB_PORT || 5432), + database: process.env.DB_DATABASE || "ai_web_platform", + user: process.env.DB_USERNAME, + password: process.env.DB_PASSWORD, +}); + +async function main() { + const res = await pool.query(` + SELECT column_name, data_type, udt_name + FROM information_schema.columns + WHERE table_name = 'ax_assignments' + ORDER BY ordinal_position + `); + console.log("ax_assignments columns:"); + res.rows.forEach((r) => console.log(` ${r.column_name}: ${r.data_type} (${r.udt_name})`)); + await pool.end(); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/migrate-db-ncue-to-env.js b/scripts/migrate-db-ncue-to-env.js new file mode 100644 index 0000000..dde7fa2 --- /dev/null +++ b/scripts/migrate-db-ncue-to-env.js @@ -0,0 +1,269 @@ +#!/usr/bin/env node +/** + * .env.ncue(원본 DB) → .env(대상 DB) 데이터 복사 + * + * 학습센터(강의 목록): lectures 테이블 + * 선택: lecture_thumbnail_events (썸네일 작업 로그) + * + * 사용 전: + * 1) 대상 DB에 스키마 적용: node scripts/apply-schema.js (.env 기준) + * 2) 대상 서버에 강의 파일 동기화(별도): DB만으로는 PPT/슬라이드/썸네일 바이너리가 없음 + * - resources/lecture/ (업로드 원본 PPT 등) + * - uploads/thumbnails/ (썸네일) + * - uploads/slides/<강의UUID>/ (슬라이드 이미지) + * + * 실행 (저장소 루트에서): + * node scripts/migrate-db-ncue-to-env.js + * node scripts/migrate-db-ncue-to-env.js --dry-run + * node scripts/migrate-db-ncue-to-env.js --with-thumbnail-events + * + * 대상에 강의만 원본과 동일하게 맞추고(기존 행 삭제 후 복사): + * node scripts/migrate-db-ncue-to-env.js --truncate-target + * + * 경로 변경: + * SOURCE_ENV=.env.ncue TARGET_ENV=.env node scripts/migrate-db-ncue-to-env.js + */ +require("dotenv").config({ quiet: true }); + +const fs = require("fs"); +const path = require("path"); +const dotenv = require("dotenv"); +const { Pool } = require("pg"); + +const ROOT = path.join(__dirname, ".."); + +const LECTURE_COLUMNS = [ + "id", + "type", + "title", + "description", + "tags", + "youtube_url", + "file_name", + "original_name", + "preview_title", + "slide_count", + "thumbnail_url", + "thumbnail_status", + "thumbnail_retry_count", + "thumbnail_error", + "thumbnail_updated_at", + "created_at", + "updated_at", + "list_section", + "news_url", +]; + +const THUMB_EVENT_COLUMNS = [ + "id", + "occurred_at", + "event_type", + "lecture_id", + "lecture_title", + "reason", + "force_flag", + "queue_size_after", + "retry_count", + "duration_ms", + "error_text", +]; + +function parseArgs(argv) { + const out = { dryRun: false, withThumbnailEvents: false, truncateTarget: false }; + for (const a of argv) { + if (a === "--dry-run") out.dryRun = true; + if (a === "--with-thumbnail-events") out.withThumbnailEvents = true; + if (a === "--truncate-target") out.truncateTarget = true; + } + return out; +} + +function loadEnvFile(filePath) { + const abs = path.isAbsolute(filePath) ? filePath : path.join(ROOT, filePath); + if (!fs.existsSync(abs)) { + throw new Error(`환경 파일이 없습니다: ${abs}`); + } + return dotenv.parse(fs.readFileSync(abs, "utf8")); +} + +function poolConfigFromEnv(e) { + const missing = ["DB_HOST", "DB_DATABASE", "DB_USERNAME", "DB_PASSWORD"].filter((k) => !e[k]); + if (missing.length) { + throw new Error(`DB 설정 누락: ${missing.join(", ")}`); + } + return { + host: e.DB_HOST, + port: Number(e.DB_PORT || 5432), + database: e.DB_DATABASE, + user: e.DB_USERNAME, + password: e.DB_PASSWORD, + ssl: e.DB_SSL === "1" ? { rejectUnauthorized: false } : false, + }; +} + +function placeholders(n) { + return Array.from({ length: n }, (_, i) => `$${i + 1}`).join(", "); +} + +function buildLectureUpsert() { + const cols = LECTURE_COLUMNS; + const ph = placeholders(cols.length); + const updates = cols + .filter((c) => c !== "id") + .map((c) => `${c} = EXCLUDED.${c}`) + .join(", "); + return ` + INSERT INTO lectures (${cols.join(", ")}) + VALUES (${ph}) + ON CONFLICT (id) DO UPDATE SET ${updates} + `; +} + +function buildThumbEventUpsert() { + const cols = THUMB_EVENT_COLUMNS; + const ph = placeholders(cols.length); + const updates = cols + .filter((c) => c !== "id") + .map((c) => `${c} = EXCLUDED.${c}`) + .join(", "); + return ` + INSERT INTO lecture_thumbnail_events (${cols.join(", ")}) + VALUES (${ph}) + ON CONFLICT (id) DO UPDATE SET ${updates} + `; +} + +function rowValues(row, columns) { + return columns.map((c) => row[c]); +} + +async function truncateTargetTables(targetPool, dryRun) { + if (dryRun) { + console.log( + "[truncate] dry-run: lecture_thumbnail_events, lectures TRUNCATE 생략" + ); + return; + } + await targetPool.query("TRUNCATE lecture_thumbnail_events"); + await targetPool.query("TRUNCATE lectures"); + console.log("[truncate] 대상 lecture_thumbnail_events, lectures 비움"); +} + +async function migrateLectures(sourcePool, targetPool, dryRun) { + const { rows } = await sourcePool.query( + `SELECT ${LECTURE_COLUMNS.join(", ")} FROM lectures ORDER BY created_at ASC` + ); + console.log(`[lectures] 원본 행 수: ${rows.length}`); + if (dryRun) { + console.log("[lectures] dry-run: INSERT 생략"); + return rows.length; + } + const sql = buildLectureUpsert(); + const client = await targetPool.connect(); + try { + await client.query("BEGIN"); + let n = 0; + for (const row of rows) { + await client.query(sql, rowValues(row, LECTURE_COLUMNS)); + n++; + } + await client.query("COMMIT"); + console.log(`[lectures] 대상 DB 반영 완료: ${n}건`); + return n; + } catch (e) { + await client.query("ROLLBACK"); + throw e; + } finally { + client.release(); + } +} + +async function migrateThumbnailEvents(sourcePool, targetPool, dryRun) { + const { rows } = await sourcePool.query( + `SELECT ${THUMB_EVENT_COLUMNS.join(", ")} FROM lecture_thumbnail_events ORDER BY occurred_at ASC` + ); + console.log(`[lecture_thumbnail_events] 원본 행 수: ${rows.length}`); + if (dryRun) { + console.log("[lecture_thumbnail_events] dry-run: INSERT 생략"); + return rows.length; + } + const sql = buildThumbEventUpsert(); + const client = await targetPool.connect(); + try { + await client.query("BEGIN"); + let n = 0; + for (const row of rows) { + await client.query(sql, rowValues(row, THUMB_EVENT_COLUMNS)); + n++; + } + await client.query("COMMIT"); + console.log(`[lecture_thumbnail_events] 대상 DB 반영 완료: ${n}건`); + return n; + } catch (e) { + await client.query("ROLLBACK"); + throw e; + } finally { + client.release(); + } +} + +function printFileHint() { + console.log(` +[파일 동기화 안내] DB만 복사하면 메타데이터만 옮겨집니다. 강의 PPT/슬라이드/썸네일이 비어 있으면 +원본 서버(또는 .env.ncue가 가리키는 환경의 디스크)에서 대상(ai.xavis.co.kr) 배포 디렉터리로 복사하세요. + + 예시 (원본 호스트에서 대상으로 rsync — 경로는 실제 배포에 맞게 조정): + rsync -avz ./resources/lecture/ user@ai-host:/path/to/webplatform/resources/lecture/ + rsync -avz ./uploads/thumbnails/ user@ai-host:/path/to/webplatform/uploads/thumbnails/ + rsync -avz ./uploads/slides/ user@ai-host:/path/to/webplatform/uploads/slides/ +`); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const sourcePath = process.env.SOURCE_ENV || ".env.ncue"; + const targetPath = process.env.TARGET_ENV || ".env"; + + const sourceEnv = loadEnvFile(sourcePath); + const targetEnv = loadEnvFile(targetPath); + + const srcCfg = poolConfigFromEnv(sourceEnv); + const tgtCfg = poolConfigFromEnv(targetEnv); + + console.log(`원본: ${sourcePath} → ${srcCfg.host}/${srcCfg.database}`); + console.log(`대상: ${targetPath} → ${tgtCfg.host}/${tgtCfg.database}`); + if (args.dryRun) { + console.log("모드: DRY-RUN (쓰기 없음)\n"); + } + + const sourcePool = new Pool(srcCfg); + const targetPool = new Pool(tgtCfg); + + try { + await sourcePool.query("SELECT 1"); + await targetPool.query("SELECT 1"); + } catch (e) { + console.error("DB 연결 실패:", e.message); + process.exit(1); + } + + try { + if (args.truncateTarget) { + await truncateTargetTables(targetPool, args.dryRun); + } + await migrateLectures(sourcePool, targetPool, args.dryRun); + if (args.withThumbnailEvents) { + await migrateThumbnailEvents(sourcePool, targetPool, args.dryRun); + } + printFileHint(); + console.log("완료."); + } catch (e) { + console.error("마이그레이션 실패:", e.message || e); + process.exit(1); + } finally { + await sourcePool.end(); + await targetPool.end(); + } +} + +main(); diff --git a/scripts/normalize-stored-meeting-minutes.js b/scripts/normalize-stored-meeting-minutes.js new file mode 100644 index 0000000..f88b3ed --- /dev/null +++ b/scripts/normalize-stored-meeting-minutes.js @@ -0,0 +1,69 @@ +#!/usr/bin/env node +/** + * DB에 저장된 회의록 generated_minutes에 prepareMeetingMinutesForApi(말미 제거·제목 승격)를 + * 일괄 적용합니다. 서버 재시작 없이 기존 데이터를 정리할 때 사용합니다. + * + * 사용: node scripts/normalize-stored-meeting-minutes.js + * 요구: .env에 DB_HOST, DB_DATABASE, DB_USERNAME, DB_PASSWORD 등 (apply-schema와 동일) + */ +require("dotenv").config({ quiet: true }); + +const { Pool } = require("pg"); +const path = require("path"); + +const { prepareMeetingMinutesForApi } = require(path.join(__dirname, "..", "lib", "meeting-minutes")); +const { extractMeetingSummary } = require(path.join(__dirname, "..", "lib", "meeting-minutes-summary")); + +const getConfig = () => ({ + host: process.env.DB_HOST, + port: Number(process.env.DB_PORT || 5432), + database: process.env.DB_DATABASE, + user: process.env.DB_USERNAME, + password: process.env.DB_PASSWORD, + ssl: process.env.DB_SSL === "1" ? { rejectUnauthorized: false } : false, +}); + +const validateConfig = (config) => { + const missing = ["host", "database", "user", "password"].filter((key) => !config[key]); + if (missing.length) { + throw new Error(`Missing PostgreSQL envs: ${missing.join(", ")}`); + } +}; + +async function main() { + const config = getConfig(); + validateConfig(config); + const pool = new Pool(config); + let updated = 0; + let unchanged = 0; + try { + const r = await pool.query( + `SELECT id, user_email, generated_minutes FROM meeting_ai_meetings ORDER BY created_at ASC` + ); + for (const row of r.rows) { + const raw = row.generated_minutes != null ? String(row.generated_minutes) : ""; + const next = prepareMeetingMinutesForApi(raw); + if (next === raw) { + unchanged++; + continue; + } + const summaryText = extractMeetingSummary(next, 1200); + await pool.query( + `UPDATE meeting_ai_meetings + SET generated_minutes = $1, summary_text = $2, updated_at = NOW() + WHERE id = $3::uuid AND user_email = $4`, + [next, summaryText || null, row.id, row.user_email] + ); + updated++; + console.log("updated:", row.id, row.user_email); + } + console.log(`Done. updated=${updated}, unchanged=${unchanged}, total=${r.rows.length}`); + } finally { + await pool.end(); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/test-ax-apply.js b/scripts/test-ax-apply.js new file mode 100644 index 0000000..3820293 --- /dev/null +++ b/scripts/test-ax-apply.js @@ -0,0 +1,42 @@ +#!/usr/bin/env node +/** + * AX 과제 신청 API 테스트 (필수값만) + * 사용: node scripts/test-ax-apply.js + * ENABLE_POSTGRES=0: JSON 폴백 사용 (DB 불필요) + * ENABLE_POSTGRES=1: PostgreSQL 사용 (기본값) + */ +require("dotenv").config({ path: require("path").join(__dirname, "..", ".env") }); + +const BASE = process.env.TEST_BASE_URL || "http://localhost:8030"; + +const minimalPayload = { + department: "테스트부서", + name: "홍길동", + workProcessDescription: "테스트", + painPoint: "테스트", + currentTimeSpent: "30분", + errorRateBefore: "5%", + reasonToSolve: "테스트", + aiExpectation: "테스트", + outputType: "테스트", + participationPledge: true, +}; + +async function main() { + console.log("POST", BASE + "/api/ax-apply"); + console.log("Payload (필수값만):", JSON.stringify(minimalPayload, null, 2)); + const res = await fetch(BASE + "/api/ax-apply", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(minimalPayload), + }); + const body = await res.json().catch(() => ({})); + console.log("Status:", res.status); + console.log("Response:", JSON.stringify(body, null, 2)); + process.exit(res.ok && body.ok ? 0 : 1); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/test-ax-insert.js b/scripts/test-ax-insert.js new file mode 100644 index 0000000..aa6b99d --- /dev/null +++ b/scripts/test-ax-insert.js @@ -0,0 +1,64 @@ +#!/usr/bin/env node +/** + * ax_assignments INSERT 직접 테스트 (배열 SQL 리터럴 방식) + */ +require("dotenv").config({ path: require("path").join(__dirname, "..", ".env") }); +const { Pool } = require("pg"); +const { v4: uuidv4 } = require("uuid"); + +const pool = new Pool({ + host: process.env.DB_HOST, + port: Number(process.env.DB_PORT || 5432), + database: process.env.DB_DATABASE, + user: process.env.DB_USERNAME, + password: process.env.DB_PASSWORD, +}); + +async function main() { + const id = uuidv4(); + const now = new Date(); + + const toArrayLiteralSafe = (v) => { + if (v === "" || typeof v === "string" || v == null) return "'{}'"; + const arr = Array.isArray(v) ? v.filter((x) => typeof x === "string") : []; + if (arr.length === 0) return "'{}'"; + const escaped = arr.map((s) => `"${String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`); + return `'{${escaped.join(",")}}'`; + }; + + const dataTypesLit = "'{}'"; + const qualitativeLit = "'{}'"; + const techStackLit = "'{}'"; + const risksLit = "'{}'"; + + const sql = `INSERT INTO ax_assignments ( + id, department, name, employee_id, position, phone, email, + work_process_description, pain_point, current_time_spent, error_rate_before, + collaboration_depts, reason_to_solve, ai_expectation, output_type, automation_level, + data_readiness, data_location, personal_info, data_quality, data_count, data_types, + time_reduction, error_reduction, volume_increase, cost_reduction, response_time, + other_metrics, annual_savings, labor_replacement, revenue_increase, other_effects, + qualitative_effects, tech_stack, risks, risk_detail, participation_pledge, status, + created_at, updated_at + ) VALUES ( + $1::uuid, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, + $17, $18, $19, $20, $21, ${dataTypesLit}::text[], $22, $23, $24, $25, $26, $27, $28, $29, $30, + $31, ${qualitativeLit}::text[], ${techStackLit}::text[], ${risksLit}::text[], $32, $33, $34, $35, $36 + )`; + + const params = [ + ... [id, "테스트부서", "홍길동", "", "", "", "", "테스트", "테스트", "30분", "5%", "", "테스트", "테스트", "테스트", "", "", "", "", "", "" ], + ... [ "", "", "", "", "", "", "", "", "", "" ], + ... [ "", false, "신청", now, now ], + ]; + + console.log("SQL preview:", sql.substring(200, 400)); + const res = await pool.query(sql, params); + console.log("OK, inserted:", id); + await pool.end(); +} + +main().catch((e) => { + console.error("Error:", e.message); + process.exit(1); +}); diff --git a/server.js b/server.js new file mode 100644 index 0000000..1427894 --- /dev/null +++ b/server.js @@ -0,0 +1,4291 @@ +require("dotenv").config({ quiet: true }); + +const express = require("express"); +const OpenAI = require("openai").default; +const Anthropic = require("@anthropic-ai/sdk").default; +const { GoogleGenAI } = require("@google/genai"); +const path = require("path"); +const fs = require("fs/promises"); +const fsSync = require("fs"); +const { execFile } = require("child_process"); +const { promisify } = require("util"); +const multer = require("multer"); +const cookieParser = require("cookie-parser"); +const JSZip = require("jszip"); +const { Pool } = require("pg"); +const { v4: uuidv4 } = require("uuid"); +const meetingMinutesLib = require("./lib/meeting-minutes"); +const meetingAiStore = require("./lib/meeting-minutes-store"); +const taskChecklistStore = require("./lib/task-checklist-store"); +const parseChecklistFromMinutes = require("./lib/parse-checklist-from-minutes"); +const { formatMeetingDateOnly } = require("./lib/meeting-date-format"); +const { + normalizeOpsState, + isOpsStateDev, + isOpsStateProd, + isOpsStateSuper, +} = require("./lib/ops-state"); +const { fetchOpenGraphImageUrl } = require("./lib/link-preview"); + +const app = express(); +const PORT = process.env.PORT || 8030; +/** 로컬 전용으로만 열 때: HOST=127.0.0.1 (기본은 모든 인터페이스) */ +const HOST = process.env.HOST || "0.0.0.0"; +/** 강의 목록 페이지당 개수. `.env`에 PAGE_SIZE가 있으면 그 값이 우선(미설정 시 9) */ +const PAGE_SIZE = Number(process.env.PAGE_SIZE || 9); +const ADMIN_TOKEN = (process.env.ADMIN_TOKEN || "xavis-admin").trim(); +const ENABLE_PPT_THUMBNAIL = process.env.ENABLE_PPT_THUMBNAIL !== "0"; +const THUMBNAIL_WIDTH = Number(process.env.THUMBNAIL_WIDTH || 1000); +const THUMBNAIL_MAX_RETRY = Number(process.env.THUMBNAIL_MAX_RETRY || 2); +const THUMBNAIL_RETRY_DELAY_MS = Number(process.env.THUMBNAIL_RETRY_DELAY_MS || 5000); +const THUMBNAIL_EVENT_KEEP = Number(process.env.THUMBNAIL_EVENT_KEEP || 200); +const THUMBNAIL_EVENT_PAGE_SIZE = Number(process.env.THUMBNAIL_EVENT_PAGE_SIZE || 50); +const ENABLE_POSTGRES = process.env.ENABLE_POSTGRES !== "0"; +/** 회의 음성 업로드 최대 크기(MB). OpenAI 전사는 요청당 약 25MB이므로 그보다 큰 파일은 lib/meeting-minutes에서 ffmpeg 분할 후 전사 */ +const MEETING_AUDIO_MAX_MB_RAW = Number(process.env.MEETING_AUDIO_MAX_MB); +const MEETING_AUDIO_MAX_MB = Number.isFinite(MEETING_AUDIO_MAX_MB_RAW) && MEETING_AUDIO_MAX_MB_RAW > 0 + ? Math.min(500, Math.max(25, MEETING_AUDIO_MAX_MB_RAW)) + : 300; +const MEETING_AUDIO_MAX_BYTES = Math.floor(MEETING_AUDIO_MAX_MB * 1024 * 1024); +const OPENAI_API_KEY = (process.env.OPENAI_API_KEY || "").trim(); +/** OpenAI Responses API의 내장 `web_search` 도구 사용. `OPENAI_WEB_SEARCH=0`이면 기존 Chat Completions만 사용 */ +const OPENAI_WEB_SEARCH = process.env.OPENAI_WEB_SEARCH !== "0"; +const CLAUDE_API_KEY = process.env.CLAUDE_API_KEY || ""; +const GENAI_API_KEY = process.env.GENAI_API_KEY || ""; + +const ROOT_DIR = __dirname; +const DATA_DIR = path.join(ROOT_DIR, "data"); +const COMPANY_PROMPTS_PATH = path.join(DATA_DIR, "company-prompts.json"); + +function loadCompanyPrompts() { + try { + return JSON.parse(fsSync.readFileSync(COMPANY_PROMPTS_PATH, "utf8")); + } catch (e) { + console.error("company-prompts.json load failed:", e.message); + return []; + } +} + +function loadAiSuccessStoriesMeta() { + try { + const raw = fsSync.readFileSync(AI_SUCCESS_META_PATH, "utf8"); + const arr = JSON.parse(raw); + return Array.isArray(arr) ? arr : []; + } catch { + return []; + } +} + +function saveAiSuccessStoriesMeta(list) { + fsSync.mkdirSync(path.dirname(AI_SUCCESS_META_PATH), { recursive: true }); + fsSync.mkdirSync(AI_SUCCESS_CONTENT_DIR, { recursive: true }); + fsSync.writeFileSync(AI_SUCCESS_META_PATH, JSON.stringify(list, null, 2), "utf8"); +} + +function loadStoryBodyMarkdown(meta) { + if (!meta) return ""; + if (typeof meta.bodyMarkdown === "string" && meta.bodyMarkdown.length) return meta.bodyMarkdown; + if (meta.contentFile) { + const safeName = path.basename(meta.contentFile); + try { + return fsSync.readFileSync(path.join(AI_SUCCESS_CONTENT_DIR, safeName), "utf8"); + } catch { + return ""; + } + } + return ""; +} + +function enrichAiSuccessStory(meta) { + return { ...meta, bodyMarkdown: loadStoryBodyMarkdown(meta) }; +} + +/** 본문 md 파일명: 슬러그 + 생성 시각(ms)으로 파일명 충돌 가능성 완화 */ +function buildAiSuccessStoryContentFileName(slug) { + const s = String(slug || "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9\-]/g, ""); + const base = s || "story"; + return `${base}-${Date.now()}.md`; +} + +function filterAiSuccessStories(list, q, tag) { + let out = [...list]; + const qLower = (q || "").trim().toLowerCase(); + if (qLower) { + out = out.filter((s) => { + return ( + (s.title || "").toLowerCase().includes(qLower) || + (s.excerpt || "").toLowerCase().includes(qLower) || + (s.department || "").toLowerCase().includes(qLower) || + (s.tags || []).some((t) => String(t).toLowerCase().includes(qLower)) + ); + }); + } + const tagTrim = (tag || "").trim(); + if (tagTrim) { + out = out.filter((s) => (s.tags || []).includes(tagTrim)); + } + out.sort((a, b) => { + const da = new Date(a.publishedAt || a.updatedAt || a.createdAt || 0); + const db = new Date(b.publishedAt || b.updatedAt || b.createdAt || 0); + return db - da; + }); + return out; +} + +function allAiSuccessStoryTags(list) { + const set = new Set(); + list.forEach((s) => (s.tags || []).forEach((t) => set.add(t))); + return Array.from(set).sort(); +} + +function requireAdminApi(req, res, next) { + if (!res.locals.adminMode) { + res.status(403).json({ error: "관리자만 사용할 수 있습니다." }); + return; + } + next(); +} +const TMP_DIR = path.join(DATA_DIR, "tmp"); +const UPLOAD_DIR = path.join(ROOT_DIR, "uploads"); +const THUMBNAIL_DIR = path.join(UPLOAD_DIR, "thumbnails"); +const SLIDES_DIR = path.join(UPLOAD_DIR, "slides"); +const RESOURCES_LECTURE_DIR = path.join(ROOT_DIR, "resources", "lecture"); +const RESOURCES_AX_APPLY_DIR = path.join(ROOT_DIR, "resources", "ax-apply"); +const AX_APPLY_UPLOAD_DIR = path.join(UPLOAD_DIR, "ax-apply"); +const MEETING_MINUTES_UPLOAD_DIR = path.join(UPLOAD_DIR, "meeting-minutes"); +const LECTURE_DB_PATH = path.join(DATA_DIR, "lectures.json"); +const AX_ASSIGNMENTS_DB_PATH = path.join(DATA_DIR, "ax-assignments.json"); +const THUMBNAIL_JOB_DB_PATH = path.join(DATA_DIR, "thumbnail-jobs.json"); +const THUMBNAIL_EVENT_DB_PATH = path.join(DATA_DIR, "thumbnail-events.json"); +const thumbnailEventsStore = require("./lib/thumbnail-events-store"); +const AI_SUCCESS_META_PATH = path.join(DATA_DIR, "ai-success-stories.json"); +const AI_SUCCESS_CONTENT_DIR = path.join(DATA_DIR, "ai-success-stories"); +const DB_SCHEMA_PATH = path.join(ROOT_DIR, "db", "schema.sql"); +const execFileAsync = promisify(execFile); +const thumbnailQueue = []; +const queuedLectureIds = new Set(); +let thumbnailWorkerRunning = false; +let pgPool = null; + +const readThumbnailJobDb = async () => { + try { + const raw = await fs.readFile(THUMBNAIL_JOB_DB_PATH, "utf-8"); + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +}; + +const writeThumbnailJobDb = async () => { + const snapshot = thumbnailQueue.map((job) => ({ + lectureId: job.lectureId, + force: job.force === true, + reason: job.reason || "manual", + enqueuedAt: job.enqueuedAt || Date.now(), + })); + await fs.writeFile(THUMBNAIL_JOB_DB_PATH, JSON.stringify(snapshot, null, 2), "utf-8"); +}; + +const readThumbnailEventDb = async () => + thumbnailEventsStore.readThumbnailEvents(pgPool, THUMBNAIL_EVENT_DB_PATH); + +const writeThumbnailEventDb = async (events) => { + if (Array.isArray(events) && events.length === 0) { + return thumbnailEventsStore.clearThumbnailEvents(pgPool, THUMBNAIL_EVENT_DB_PATH); + } + throw new Error("썸네일 이벤트는 DB/파일 전체 덮어쓰기를 지원하지 않습니다. clear([])만 사용하세요."); +}; + +const appendThumbnailEvent = async (payload) => + thumbnailEventsStore.appendThumbnailEvent(pgPool, THUMBNAIL_EVENT_DB_PATH, THUMBNAIL_EVENT_KEEP, payload); + +const getPgConfig = () => ({ + host: process.env.DB_HOST, + port: Number(process.env.DB_PORT || 5432), + database: process.env.DB_DATABASE, + user: process.env.DB_USERNAME, + password: process.env.DB_PASSWORD, + ssl: process.env.DB_SSL === "1" ? { rejectUnauthorized: false } : false, +}); + +const assertPgConfig = () => { + if (!ENABLE_POSTGRES) return; + const config = getPgConfig(); + const missing = ["host", "database", "user", "password"].filter((key) => !config[key]); + if (missing.length) { + throw new Error(`PostgreSQL env 누락: ${missing.join(", ")}`); + } +}; + +const initPostgres = async () => { + if (!ENABLE_POSTGRES) return; + try { + assertPgConfig(); + pgPool = new Pool(getPgConfig()); + await pgPool.query("SELECT 1"); + const schemaSql = await fs.readFile(DB_SCHEMA_PATH, "utf-8"); + await pgPool.query(schemaSql); + console.log("PostgreSQL 연결 및 스키마 적용 완료."); + } catch (err) { + console.warn("PostgreSQL 연결 실패, 파일 저장소로 폴백:", err.message); + pgPool = null; + } +}; + +function getClientIpForAudit(req) { + const xf = (req.headers["x-forwarded-for"] || "").split(",")[0].trim(); + if (xf) return xf.slice(0, 45); + const ra = req.socket?.remoteAddress || req.connection?.remoteAddress; + return typeof ra === "string" ? ra.slice(0, 45) : null; +} + +async function recordOpsEmailAuthEvent({ email, eventType, req, returnTo = null }) { + if (!pgPool || !email) return; + const e = String(email).trim().toLowerCase().slice(0, 320); + const ua = (req.headers["user-agent"] || "").slice(0, 4000) || null; + const ip = getClientIpForAudit(req); + try { + await pgPool.query( + `INSERT INTO ops_email_auth_events (email, event_type, ip_address, user_agent, return_to) + VALUES ($1, $2, $3, $4, $5)`, + [e, eventType, ip, ua, returnTo ? String(returnTo).slice(0, 2000) : null] + ); + } catch (err) { + console.error("[ops-email-auth] record failed:", err.message); + } +} + +async function upsertOpsEmailUserOnLogin(email) { + if (!pgPool || !email) return; + const e = String(email).trim().toLowerCase().slice(0, 320); + try { + await pgPool.query( + `INSERT INTO ops_email_users (email, first_seen_at, last_login_at, login_count) + VALUES ($1, NOW(), NOW(), 1) + ON CONFLICT (email) DO UPDATE SET + last_login_at = NOW(), + login_count = ops_email_users.login_count + 1`, + [e] + ); + } catch (err) { + console.error("[ops-email-users] upsert failed:", err.message); + } +} + +const opsAuth = require("./ops-auth")(DATA_DIR, { + onMagicLinkRequested: async ({ email, returnTo, req }) => { + await recordOpsEmailAuthEvent({ + email, + eventType: "magic_link_requested", + req, + returnTo, + }); + }, + onLoginSuccess: async ({ email, returnTo, req }) => { + await recordOpsEmailAuthEvent({ + email, + eventType: "login_success", + req, + returnTo, + }); + await upsertOpsEmailUserOnLogin(email); + }, + onLogout: async ({ email, req }) => { + if (email) { + await recordOpsEmailAuthEvent({ + email, + eventType: "logout", + req, + }); + } + }, +}); + +const syncLecturesToPostgres = async (lectures) => { + if (!ENABLE_POSTGRES || !pgPool) return; + const client = await pgPool.connect(); + try { + await client.query("BEGIN"); + for (const lecture of lectures) { + await client.query( + `INSERT INTO lectures ( + id, type, title, description, tags, youtube_url, file_name, original_name, + preview_title, slide_count, thumbnail_url, thumbnail_status, thumbnail_retry_count, + thumbnail_error, thumbnail_updated_at, created_at, list_section, news_url + ) VALUES ( + $1::uuid, $2, $3, $4, $5::text[], $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18 + ) + ON CONFLICT (id) DO UPDATE SET + type = EXCLUDED.type, + title = EXCLUDED.title, + description = EXCLUDED.description, + tags = EXCLUDED.tags, + youtube_url = EXCLUDED.youtube_url, + file_name = EXCLUDED.file_name, + original_name = EXCLUDED.original_name, + preview_title = EXCLUDED.preview_title, + slide_count = EXCLUDED.slide_count, + thumbnail_url = EXCLUDED.thumbnail_url, + thumbnail_status = EXCLUDED.thumbnail_status, + thumbnail_retry_count = EXCLUDED.thumbnail_retry_count, + thumbnail_error = EXCLUDED.thumbnail_error, + thumbnail_updated_at = EXCLUDED.thumbnail_updated_at, + list_section = EXCLUDED.list_section, + news_url = EXCLUDED.news_url`, + [ + lecture.id, + lecture.type, + lecture.title, + lecture.description || "", + Array.isArray(lecture.tags) ? lecture.tags : [], + lecture.youtubeUrl || null, + lecture.fileName || null, + lecture.originalName || null, + lecture.previewTitle || null, + typeof lecture.slideCount === "number" ? lecture.slideCount : 0, + lecture.thumbnailUrl || null, + lecture.thumbnailStatus || "pending", + typeof lecture.thumbnailRetryCount === "number" ? lecture.thumbnailRetryCount : 0, + lecture.thumbnailError || null, + lecture.thumbnailUpdatedAt ? new Date(lecture.thumbnailUpdatedAt) : null, + lecture.createdAt ? new Date(lecture.createdAt) : new Date(), + lecture.listSection || "learning", + lecture.newsUrl || null, + ] + ); + } + + if (lectures.length > 0) { + await client.query("DELETE FROM lectures WHERE id <> ALL($1::uuid[])", [lectures.map((lecture) => lecture.id)]); + } else { + await client.query("DELETE FROM lectures"); + } + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +}; + +const escapeCsv = (value) => { + const str = String(value ?? ""); + if (str.includes('"') || str.includes(",") || str.includes("\n")) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; +}; + +const buildEventFilter = (query) => { + const eventType = (query.eventType || "all").toString(); + const lectureId = (query.lectureId || "").toString().trim(); + const reason = (query.reason || "").toString().trim(); + const from = (query.from || "").toString().trim(); + const to = (query.to || "").toString().trim(); + + const fromMs = from ? new Date(from).getTime() : null; + const toMs = to ? new Date(`${to}T23:59:59.999`).getTime() : null; + + return { + eventType, + lectureId, + reason, + from, + to, + matches: (evt) => { + if (eventType !== "all" && evt.type !== eventType) return false; + if (lectureId && !String(evt.lectureId || "").includes(lectureId)) return false; + if (reason && !String(evt.reason || "").includes(reason)) return false; + const atMs = new Date(evt.at || "").getTime(); + if (Number.isFinite(fromMs) && Number.isFinite(atMs) && atMs < fromMs) return false; + if (Number.isFinite(toMs) && Number.isFinite(atMs) && atMs > toMs) return false; + return true; + }, + }; +}; + +const storage = multer.diskStorage({ + destination: (_, __, cb) => cb(null, UPLOAD_DIR), + filename: (_, file, cb) => { + const ext = (path.extname(file.originalname) || ".pptx").toLowerCase(); + const allowed = [".pptx", ".pdf"]; + const finalExt = allowed.includes(ext) ? ext : ".pptx"; + cb(null, `${Date.now()}-${uuidv4()}${finalExt}`); + }, +}); +const upload = multer({ + storage, + limits: { fileSize: 50 * 1024 * 1024 }, + fileFilter: (_, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase(); + if (ext !== ".pptx" && ext !== ".pdf") { + cb(new Error("PDF(.pdf) 또는 PowerPoint(.pptx) 파일만 업로드할 수 있습니다.")); + return; + } + cb(null, true); + }, +}); + +const LECTURE_VIDEO_ALLOWED_EXT = [".mp4", ".webm", ".mov"]; +const lectureVideoMaxBytes = + Math.max(1, Number(process.env.LECTURE_VIDEO_MAX_MB) || 500) * 1024 * 1024; +const videoStorage = multer.diskStorage({ + destination: (_, __, cb) => cb(null, UPLOAD_DIR), + filename: (_, file, cb) => { + const ext = (path.extname(file.originalname) || ".mp4").toLowerCase(); + const finalExt = LECTURE_VIDEO_ALLOWED_EXT.includes(ext) ? ext : ".mp4"; + cb(null, `${Date.now()}-${uuidv4()}${finalExt}`); + }, +}); +const uploadVideo = multer({ + storage: videoStorage, + limits: { fileSize: lectureVideoMaxBytes }, + fileFilter: (_, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase(); + if (!LECTURE_VIDEO_ALLOWED_EXT.includes(ext)) { + cb(new Error("동영상은 MP4, WebM, MOV 형식만 업로드할 수 있습니다.")); + return; + } + cb(null, true); + }, +}); + +const axApplyStorage = multer.diskStorage({ + destination: (_, __, cb) => { + fsSync.mkdirSync(AX_APPLY_UPLOAD_DIR, { recursive: true }); + cb(null, AX_APPLY_UPLOAD_DIR); + }, + filename: (_, file, cb) => { + const ext = (path.extname(file.originalname) || ".docx").toLowerCase(); + const finalExt = ext === ".docx" || ext === ".doc" ? ".docx" : ".docx"; + cb(null, `${Date.now()}-${uuidv4()}${finalExt}`); + }, +}); +const uploadAxApply = multer({ + storage: axApplyStorage, + limits: { fileSize: 10 * 1024 * 1024 }, + fileFilter: (_, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase(); + if (ext !== ".docx" && ext !== ".doc") { + cb(new Error("Word 문서(.docx, .doc) 파일만 업로드할 수 있습니다.")); + return; + } + cb(null, true); + }, +}); + +const AI_SUCCESS_PUBLIC_PDF_DIR = path.join(ROOT_DIR, "public", "resources", "ai-success"); +const aiSuccessPdfStorage = multer.diskStorage({ + destination: (_, __, cb) => { + fsSync.mkdirSync(AI_SUCCESS_PUBLIC_PDF_DIR, { recursive: true }); + cb(null, AI_SUCCESS_PUBLIC_PDF_DIR); + }, + filename: (_, __, cb) => { + cb(null, `${Date.now()}-${uuidv4()}.pdf`); + }, +}); +const uploadAiSuccessPdf = multer({ + storage: aiSuccessPdfStorage, + limits: { fileSize: 50 * 1024 * 1024 }, + fileFilter: (_, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase(); + if (ext !== ".pdf") { + cb(new Error("PDF(.pdf) 파일만 업로드할 수 있습니다.")); + return; + } + cb(null, true); + }, +}); + +const meetingMinutesAudioStorage = multer.diskStorage({ + destination: (req, _, cb) => { + const email = req.meetingUserEmail || "unknown"; + const safe = String(email).replace(/[^a-zA-Z0-9@._-]/g, "_").slice(0, 200); + const dir = path.join(MEETING_MINUTES_UPLOAD_DIR, safe); + fsSync.mkdirSync(dir, { recursive: true }); + cb(null, dir); + }, + filename: (req, file, cb) => { + const email = req.meetingUserEmail || "user"; + const safe = String(email).replace(/[^a-zA-Z0-9@._-]/g, "_").slice(0, 200); + const now = new Date(); + const p = (n) => String(n).padStart(2, "0"); + const yyyyMMdd = `${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}`; + const HHmmss = `${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`; + const ext = (path.extname(file.originalname) || ".webm").toLowerCase(); + cb(null, `${safe}_${yyyyMMdd}_${HHmmss}${ext}`); + }, +}); +const uploadMeetingAudio = multer({ + storage: meetingMinutesAudioStorage, + limits: { fileSize: MEETING_AUDIO_MAX_BYTES }, + fileFilter: (_, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase(); + const allowed = [".mp3", ".mp4", ".mpeg", ".mpga", ".m4a", ".wav", ".webm", ".ogg", ".flac"]; + if (!allowed.includes(ext)) { + cb(new Error(`지원 음성 형식: ${allowed.join(", ")}`)); + return; + } + cb(null, true); + }, +}); + +const mapRowToAxAssignment = (row) => ({ + id: row.id, + department: row.department || "", + name: row.name || "", + employeeId: row.employee_id || "", + position: row.position || "", + phone: row.phone || "", + email: row.email || "", + workProcessDescription: row.work_process_description || "", + painPoint: row.pain_point || "", + currentTimeSpent: row.current_time_spent || "", + errorRateBefore: row.error_rate_before || "", + collaborationDepts: row.collaboration_depts || "", + reasonToSolve: row.reason_to_solve || "", + aiExpectation: row.ai_expectation || "", + outputType: row.output_type || "", + automationLevel: row.automation_level || "", + dataReadiness: row.data_readiness || "", + dataLocation: row.data_location || "", + personalInfo: row.personal_info || "", + dataQuality: row.data_quality || "", + dataCount: row.data_count || "", + dataTypes: Array.isArray(row.data_types) ? row.data_types : [], + timeReduction: row.time_reduction || "", + errorReduction: row.error_reduction || "", + volumeIncrease: row.volume_increase || "", + costReduction: row.cost_reduction || "", + responseTime: row.response_time || "", + otherMetrics: row.other_metrics || "", + annualSavings: row.annual_savings || "", + laborReplacement: row.labor_replacement || "", + revenueIncrease: row.revenue_increase || "", + otherEffects: row.other_effects || "", + qualitativeEffects: Array.isArray(row.qualitative_effects) ? row.qualitative_effects : [], + techStack: Array.isArray(row.tech_stack) ? row.tech_stack : [], + risks: Array.isArray(row.risks) ? row.risks : [], + riskDetail: row.risk_detail || "", + participationPledge: row.participation_pledge === true, + applicationFile: row.application_file || "", + status: row.status || "신청", + createdAt: row.created_at ? new Date(row.created_at).toISOString() : new Date().toISOString(), + updatedAt: row.updated_at ? new Date(row.updated_at).toISOString() : new Date().toISOString(), +}); + +const readAxAssignmentsDb = async () => { + if (pgPool) { + try { + const res = await pgPool.query( + "SELECT id, department, name, employee_id, position, phone, email, work_process_description, pain_point, current_time_spent, error_rate_before, collaboration_depts, reason_to_solve, ai_expectation, output_type, automation_level, data_readiness, data_location, personal_info, data_quality, data_count, data_types, time_reduction, error_reduction, volume_increase, cost_reduction, response_time, other_metrics, annual_savings, labor_replacement, revenue_increase, other_effects, qualitative_effects, tech_stack, risks, risk_detail, participation_pledge, application_file, status, created_at, updated_at FROM ax_assignments ORDER BY created_at DESC" + ); + return (res.rows || []).map(mapRowToAxAssignment); + } catch (err) { + console.error("readAxAssignmentsDb from PostgreSQL failed:", err.message); + return []; + } + } + try { + const raw = await fs.readFile(AX_ASSIGNMENTS_DB_PATH, "utf-8"); + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +}; + +const readAxAssignmentsByCredentials = async (department, name, email) => { + const d = (department || "").trim().toLowerCase(); + const n = (name || "").trim().toLowerCase(); + const e = (email || "").trim().toLowerCase(); + if (!d || !n || !e) return []; + const all = await readAxAssignmentsDb(); + return all.filter( + (a) => + (a.department || "").trim().toLowerCase() === d && + (a.name || "").trim().toLowerCase() === n && + (a.email || "").trim().toLowerCase() === e + ); +}; + +const isSampleRecord = (a) => + (a?.department || "").trim() === "샘플" && (a?.name || "").trim() === "데이터"; + +const ensureStringArray = (val) => { + if (val === "" || val === null || val === undefined) return []; + if (Array.isArray(val)) return val.filter((x) => typeof x === "string"); + if (typeof val === "string" && val.trim()) return val.split(",").map((s) => s.trim()).filter(Boolean); + return []; +}; + +const toPgArray = (arr) => { + if (arr === "" || arr === null || arr === undefined) return null; + const a = ensureStringArray(arr); + return a.length > 0 ? a : null; +}; + +const readAxAssignmentById = async (id) => { + if (!id) return null; + if (pgPool) { + try { + const res = await pgPool.query( + "SELECT id, department, name, employee_id, position, phone, email, work_process_description, pain_point, current_time_spent, error_rate_before, collaboration_depts, reason_to_solve, ai_expectation, output_type, automation_level, data_readiness, data_location, personal_info, data_quality, data_count, data_types, time_reduction, error_reduction, volume_increase, cost_reduction, response_time, other_metrics, annual_savings, labor_replacement, revenue_increase, other_effects, qualitative_effects, tech_stack, risks, risk_detail, participation_pledge, application_file, status, created_at, updated_at FROM ax_assignments WHERE id = $1", + [id] + ); + return res.rows?.[0] ? mapRowToAxAssignment(res.rows[0]) : null; + } catch { + return null; + } + } + try { + const raw = await fs.readFile(AX_ASSIGNMENTS_DB_PATH, "utf-8"); + const parsed = JSON.parse(raw); + const list = Array.isArray(parsed) ? parsed : []; + const found = list.find((a) => a.id === id); + return found ? { ...found, applicationFile: found.applicationFile || found.application_file || "" } : null; + } catch { + return null; + } +}; + +async function updateAxAssignmentDb(id, data) { + const existing = await readAxAssignmentById(id); + if (!existing) return null; + const existingAppFile = (existing.applicationFile || existing.application_file || "").trim(); + const appFile = (data.applicationFile || data.application_file || "").trim(); + const finalAppFile = appFile || existingAppFile; + + const cleanArr = (v) => (v === "" || typeof v === "string" ? undefined : v); + const dataTypes = cleanArr(data.dataTypes ?? data.data_types) ?? existing.dataTypes; + const qualitativeEffects = cleanArr(data.qualitativeEffects ?? data.qualitative_effects) ?? existing.qualitativeEffects; + const techStack = cleanArr(data.techStack ?? data.techStackStr ?? data.tech_stack) ?? existing.techStack; + const risks = cleanArr(data.risks ?? data.risksStr ?? data.risks) ?? existing.risks; + + const row = { + id, + department: ((data.department ?? existing.department) || "").trim(), + name: ((data.name ?? existing.name) || "").trim(), + employeeId: ((data.employeeId ?? data.employee_id ?? existing.employeeId) || "").trim(), + position: ((data.position ?? existing.position) || "").trim(), + phone: ((data.phone ?? existing.phone) || "").trim(), + email: ((data.email ?? existing.email) || "").trim(), + workProcessDescription: ((data.workProcessDescription ?? data.work_process_description ?? existing.workProcessDescription) || "").trim(), + painPoint: ((data.painPoint ?? data.pain_point ?? existing.painPoint) || "").trim(), + currentTimeSpent: ((data.currentTimeSpent ?? data.current_time_spent ?? existing.currentTimeSpent) || "").trim(), + errorRateBefore: ((data.errorRateBefore ?? data.error_rate_before ?? existing.errorRateBefore) || "").trim(), + collaborationDepts: ((data.collaborationDepts ?? data.collaboration_depts ?? existing.collaborationDepts) || "").trim(), + reasonToSolve: ((data.reasonToSolve ?? data.reason_to_solve ?? existing.reasonToSolve) || "").trim(), + aiExpectation: ((data.aiExpectation ?? data.ai_expectation ?? existing.aiExpectation) || "").trim(), + outputType: ((data.outputType ?? data.output_type ?? existing.outputType) || "").trim(), + automationLevel: ((data.automationLevel ?? data.automation_level ?? existing.automationLevel) || "").trim(), + dataReadiness: ((data.dataReadiness ?? data.data_readiness ?? existing.dataReadiness) || "").trim(), + dataLocation: ((data.dataLocation ?? data.data_location ?? existing.dataLocation) || "").trim(), + personalInfo: ((data.personalInfo ?? data.personal_info ?? existing.personalInfo) || "").trim(), + dataQuality: ((data.dataQuality ?? data.data_quality ?? existing.dataQuality) || "").trim(), + dataCount: ((data.dataCount ?? data.data_count ?? existing.dataCount) || "").trim(), + dataTypes: toPgArray(dataTypes), + timeReduction: ((data.timeReduction ?? data.time_reduction ?? existing.timeReduction) || "").trim(), + errorReduction: ((data.errorReduction ?? data.error_reduction ?? existing.errorReduction) || "").trim(), + volumeIncrease: ((data.volumeIncrease ?? data.volume_increase ?? existing.volumeIncrease) || "").trim(), + costReduction: ((data.costReduction ?? data.cost_reduction ?? existing.costReduction) || "").trim(), + responseTime: ((data.responseTime ?? data.response_time ?? existing.responseTime) || "").trim(), + otherMetrics: ((data.otherMetrics ?? data.other_metrics ?? existing.otherMetrics) || "").trim(), + annualSavings: ((data.annualSavings ?? data.annual_savings ?? existing.annualSavings) || "").trim(), + laborReplacement: ((data.laborReplacement ?? data.labor_replacement ?? existing.laborReplacement) || "").trim(), + revenueIncrease: ((data.revenueIncrease ?? data.revenue_increase ?? existing.revenueIncrease) || "").trim(), + otherEffects: ((data.otherEffects ?? data.other_effects ?? existing.otherEffects) || "").trim(), + qualitativeEffects: toPgArray(qualitativeEffects), + techStack: toPgArray(techStack), + risks: toPgArray(risks), + riskDetail: ((data.riskDetail ?? data.risk_detail ?? existing.riskDetail) || "").trim(), + participationPledge: data.participationPledge === true || data.participation_pledge === true, + applicationFile: finalAppFile, + status: existing.status || "신청", + createdAt: existing.createdAt, + updatedAt: new Date().toISOString(), + }; + + if (pgPool) { + const toArrayLiteralSafe = (v) => { + if (v === "" || (typeof v === "string") || v == null) return "'{}'"; + const arr = Array.isArray(v) ? v.filter((x) => typeof x === "string") : []; + if (arr.length === 0) return "'{}'"; + const escaped = arr.map((s) => `"${String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`); + return `'{${escaped.join(",")}}'`; + }; + const dataTypesLit = toArrayLiteralSafe(row.dataTypes); + const qualitativeLit = toArrayLiteralSafe(row.qualitativeEffects); + const techStackLit = toArrayLiteralSafe(row.techStack); + const risksLit = toArrayLiteralSafe(row.risks); + + try { + await pgPool.query( + `UPDATE ax_assignments SET + department = $2, name = $3, employee_id = $4, position = $5, phone = $6, email = $7, + work_process_description = $8, pain_point = $9, current_time_spent = $10, error_rate_before = $11, + collaboration_depts = $12, reason_to_solve = $13, ai_expectation = $14, output_type = $15, automation_level = $16, + data_readiness = $17, data_location = $18, personal_info = $19, data_quality = $20, data_count = $21, data_types = ${dataTypesLit}::text[], + time_reduction = $22, error_reduction = $23, volume_increase = $24, cost_reduction = $25, response_time = $26, + other_metrics = $27, annual_savings = $28, labor_replacement = $29, revenue_increase = $30, other_effects = $31, + qualitative_effects = ${qualitativeLit}::text[], tech_stack = ${techStackLit}::text[], risks = ${risksLit}::text[], + risk_detail = $32, participation_pledge = $33, application_file = $34, updated_at = $35 + WHERE id = $1::uuid`, + [ + row.id, row.department, row.name, row.employeeId, row.position, row.phone, row.email, + row.workProcessDescription, row.painPoint, row.currentTimeSpent, row.errorRateBefore, + row.collaborationDepts, row.reasonToSolve, row.aiExpectation, row.outputType, row.automationLevel, + row.dataReadiness, row.dataLocation, row.personalInfo, row.dataQuality, row.dataCount, + row.timeReduction, row.errorReduction, row.volumeIncrease, row.costReduction, row.responseTime, + row.otherMetrics, row.annualSavings, row.laborReplacement, row.revenueIncrease, row.otherEffects, + row.riskDetail, row.participationPledge, row.applicationFile || null, + new Date(row.updatedAt), + ] + ); + } catch (err) { + console.error("updateAxAssignmentDb failed:", err.message); + throw err; + } + } else { + const raw = await fs.readFile(AX_ASSIGNMENTS_DB_PATH, "utf-8"); + const list = Array.isArray(JSON.parse(raw)) ? JSON.parse(raw) : []; + const idx = list.findIndex((a) => a.id === id); + if (idx >= 0) { + list[idx] = { ...list[idx], ...row }; + await fs.writeFile(AX_ASSIGNMENTS_DB_PATH, JSON.stringify(list, null, 2), "utf-8"); + } + } + + return row; +}; + +const createAxAssignmentDb = async (data) => { + const id = uuidv4(); + const now = new Date().toISOString(); + + const cleanArr = (v) => (v === "" || typeof v === "string" ? undefined : v); + const dataTypes = cleanArr(data.dataTypes ?? data.data_types); + const qualitativeEffects = cleanArr(data.qualitativeEffects ?? data.qualitative_effects); + const techStack = cleanArr(data.techStack ?? data.techStackStr ?? data.tech_stack); + const risks = cleanArr(data.risks ?? data.risksStr); + + const row = { + id, + department: (data.department || "").trim(), + name: (data.name || "").trim(), + employeeId: (data.employeeId || data.employee_id || "").trim(), + position: (data.position || "").trim(), + phone: (data.phone || "").trim(), + email: (data.email || "").trim(), + workProcessDescription: (data.workProcessDescription || data.work_process_description || "").trim(), + painPoint: (data.painPoint || data.pain_point || "").trim(), + currentTimeSpent: (data.currentTimeSpent || data.current_time_spent || "").trim(), + errorRateBefore: (data.errorRateBefore || data.error_rate_before || "").trim(), + collaborationDepts: (data.collaborationDepts || data.collaboration_depts || "").trim(), + reasonToSolve: (data.reasonToSolve || data.reason_to_solve || "").trim(), + aiExpectation: (data.aiExpectation || data.ai_expectation || "").trim(), + outputType: (data.outputType || data.output_type || "").trim(), + automationLevel: (data.automationLevel || data.automation_level || "").trim(), + dataReadiness: (data.dataReadiness || data.data_readiness || "").trim(), + dataLocation: (data.dataLocation || data.data_location || "").trim(), + personalInfo: (data.personalInfo || data.personal_info || "").trim(), + dataQuality: (data.dataQuality || data.data_quality || "").trim(), + dataCount: (data.dataCount || data.data_count || "").trim(), + dataTypes: toPgArray(dataTypes), + timeReduction: (data.timeReduction || data.time_reduction || "").trim(), + errorReduction: (data.errorReduction || data.error_reduction || "").trim(), + volumeIncrease: (data.volumeIncrease || data.volume_increase || "").trim(), + costReduction: (data.costReduction || data.cost_reduction || "").trim(), + responseTime: (data.responseTime || data.response_time || "").trim(), + otherMetrics: (data.otherMetrics || data.other_metrics || "").trim(), + annualSavings: (data.annualSavings || data.annual_savings || "").trim(), + laborReplacement: (data.laborReplacement || data.labor_replacement || "").trim(), + revenueIncrease: (data.revenueIncrease || data.revenue_increase || "").trim(), + otherEffects: (data.otherEffects || data.other_effects || "").trim(), + qualitativeEffects: toPgArray(qualitativeEffects), + techStack: toPgArray(techStack), + risks: toPgArray(risks), + riskDetail: (data.riskDetail || data.risk_detail || "").trim(), + participationPledge: data.participationPledge === true || data.participation_pledge === true, + applicationFile: (data.applicationFile || data.application_file || "").trim(), + status: "신청", + createdAt: now, + updatedAt: now, + }; + const toSafeArrayParam = (v) => { + if (v === "" || (typeof v === "string") || v === undefined) return null; + if (!Array.isArray(v)) return null; + const filtered = v.filter((x) => typeof x === "string"); + return filtered.length > 0 ? filtered : null; + }; + + if (pgPool) { + const toArrayLiteralSafe = (v) => { + if (v === "" || (typeof v === "string") || v == null) return "'{}'"; + const arr = Array.isArray(v) ? v.filter((x) => typeof x === "string") : []; + if (arr.length === 0) return "'{}'"; + const escaped = arr.map((s) => `"${String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`); + return `'{${escaped.join(",")}}'`; + }; + const dataTypesLit = toArrayLiteralSafe(row.dataTypes); + const qualitativeLit = toArrayLiteralSafe(row.qualitativeEffects); + const techStackLit = toArrayLiteralSafe(row.techStack); + const risksLit = toArrayLiteralSafe(row.risks); + + try { + await pgPool.query( + `INSERT INTO ax_assignments ( + id, department, name, employee_id, position, phone, email, + work_process_description, pain_point, current_time_spent, error_rate_before, + collaboration_depts, reason_to_solve, ai_expectation, output_type, automation_level, + data_readiness, data_location, personal_info, data_quality, data_count, data_types, + time_reduction, error_reduction, volume_increase, cost_reduction, response_time, + other_metrics, annual_savings, labor_replacement, revenue_increase, other_effects, + qualitative_effects, tech_stack, risks, risk_detail, participation_pledge, application_file, status, + created_at, updated_at + ) VALUES ( + $1::uuid, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, + $17, $18, $19, $20, $21, ${dataTypesLit}::text[], $22, $23, $24, $25, $26, $27, $28, $29, $30, + $31, ${qualitativeLit}::text[], ${techStackLit}::text[], ${risksLit}::text[], $32, $33, $34, $35, $36, $37 + )`, + [ + row.id, row.department, row.name, row.employeeId, row.position, row.phone, row.email, + row.workProcessDescription, row.painPoint, row.currentTimeSpent, row.errorRateBefore, + row.collaborationDepts, row.reasonToSolve, row.aiExpectation, row.outputType, row.automationLevel, + row.dataReadiness, row.dataLocation, row.personalInfo, row.dataQuality, row.dataCount, + row.timeReduction, row.errorReduction, row.volumeIncrease, row.costReduction, row.responseTime, + row.otherMetrics, row.annualSavings, row.laborReplacement, row.revenueIncrease, row.otherEffects, + row.riskDetail, row.participationPledge, row.applicationFile || null, row.status, + new Date(row.createdAt), new Date(row.updatedAt), + ] + ); + } catch (err) { + console.error("createAxAssignmentDb failed:", err.message); + throw err; + } + } else { + const list = await readAxAssignmentsDb(); + list.unshift(row); + await fs.writeFile(AX_ASSIGNMENTS_DB_PATH, JSON.stringify(list, null, 2), "utf-8"); + } + return row; +}; + +const deleteAxAssignmentDb = async (id) => { + if (!id) return false; + if (pgPool) { + try { + const res = await pgPool.query("DELETE FROM ax_assignments WHERE id = $1::uuid", [id]); + return (res.rowCount || 0) > 0; + } catch (err) { + console.error("deleteAxAssignmentDb failed:", err.message); + throw err; + } + } + try { + const raw = await fs.readFile(AX_ASSIGNMENTS_DB_PATH, "utf-8"); + const list = Array.isArray(JSON.parse(raw)) ? JSON.parse(raw) : []; + const idx = list.findIndex((a) => a.id === id); + if (idx < 0) return false; + list.splice(idx, 1); + await fs.writeFile(AX_ASSIGNMENTS_DB_PATH, JSON.stringify(list, null, 2), "utf-8"); + return true; + } catch { + return false; + } +}; + +app.set("view engine", "ejs"); +app.set("views", path.join(ROOT_DIR, "views")); + +app.use(express.urlencoded({ extended: true })); +app.use(express.json()); +app.use(cookieParser()); + +const ADMIN_COOKIE_NAME = "admin_session"; +const ADMIN_COOKIE_MAX_AGE = 24 * 60 * 60 * 1000; // 24시간 + +app.use((req, res, next) => { + const cookieToken = req.cookies?.[ADMIN_COOKIE_NAME] || ""; + res.locals.adminMode = + ADMIN_TOKEN.length > 0 && cookieToken.length > 0 && cookieToken === ADMIN_TOKEN; + next(); +}); + +/** GPT 채팅: SUPER 전면 허용. DEV는 관리자 토큰 세션. PROD는 이메일(OPS) 인증 세션만 */ +function isChatGptAllowed(req, res) { + if (isOpsStateSuper()) return true; + if (isOpsStateDev() && res.locals.adminMode) return true; + if (isOpsStateProd() && res.locals.opsUserEmail) return true; + return false; +} + +/** AI 성공 사례 상세: DEV일 때만 관리자 모드로 제한. PROD·SUPER는 상세 열람 가능 */ +function isAiSuccessStoryDetailAllowed(req, res) { + if (!isOpsStateDev()) return true; + return !!res.locals.adminMode; +} + +/** 과제신청: DEV에서만 관리자 제한. PROD·SUPER는 제한 없음 */ +function isAxApplyDownloadSubmitAllowed(req, res) { + if (!isOpsStateDev()) return true; + return !!res.locals.adminMode; +} + +/** AI 탐색(/ai-explore): DEV·비관리자일 때 프롬프트 카드만 사용 가능 */ +function isAiExploreDevGuestRestricted(req, res) { + return isOpsStateDev() && !res.locals.adminMode; +} + +/** OPS 이메일 세션, DEV+관리자(MEETING_DEV_EMAIL), SUPER(데모 이메일) — 회의록 AI */ +function getMeetingMinutesUserEmail(req, res) { + if (res.locals.opsUserEmail) return String(res.locals.opsUserEmail).trim().toLowerCase(); + if (isOpsStateDev() && res.locals.adminMode) { + return (process.env.MEETING_DEV_EMAIL || "dev@xavis.co.kr").trim().toLowerCase(); + } + if (isOpsStateSuper()) { + return (process.env.MEETING_SUPER_EMAIL || process.env.MEETING_DEV_EMAIL || "demo@xavis.local") + .trim() + .toLowerCase(); + } + return null; +} + +const MEETING_MINUTES_ALLOWED_MODELS = new Set(["gpt-5-mini", "gpt-5.4"]); + +/** YYYY-MM-DD 검증, 실패 시 { error } */ +function parseMeetingDateIso(raw) { + if (raw == null || String(raw).trim() === "") return { value: null }; + const s = String(raw).trim().slice(0, 10); + if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) return { error: "날짜는 YYYY-MM-DD 형식이어야 합니다." }; + const [y, mo, d] = s.split("-").map(Number); + const dt = new Date(y, mo - 1, d); + if (dt.getFullYear() !== y || dt.getMonth() !== mo - 1 || dt.getDate() !== d) return { error: "유효하지 않은 날짜입니다." }; + return { value: s }; +} + +function requireMeetingMinutesEmail(req, res, next) { + const email = getMeetingMinutesUserEmail(req, res); + if (!email) { + res.status(401).json({ error: "이메일 로그인이 필요합니다." }); + return; + } + req.meetingUserEmail = email; + next(); +} + +async function ensureMeetingUserAndDefaultPrompt(email) { + await meetingAiStore.ensureUserAndDefaultPrompt(pgPool, email); +} + +function mapRowToMeetingPrompt(row) { + if (!row) return null; + return { + id: row.id, + userEmail: row.user_email, + includeTitleLine: row.include_title_line !== false, + includeAttendees: row.include_attendees !== false, + includeSummary: row.include_summary !== false, + includeActionItems: row.include_action_items !== false, + includeChecklist: true, + customInstructions: row.custom_instructions || "", + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +function mapRowToMeeting(row) { + if (!row) return null; + return { + id: row.id, + userEmail: row.user_email, + title: row.title || "", + sourceText: row.source_text || "", + transcriptText: row.transcript_text || "", + generatedMinutes: meetingMinutesLib.prepareMeetingMinutesForApi(row.generated_minutes || ""), + audioFilePath: row.audio_file_path || "", + audioOriginalName: row.audio_original_name || "", + chatModel: row.chat_model || "gpt-5-mini", + transcriptionModel: row.transcription_model || null, + meetingDate: row.meeting_date != null ? formatMeetingDateOnly(row.meeting_date) : null, + createdAt: row.created_at ? new Date(row.created_at).toISOString() : null, + updatedAt: row.updated_at ? new Date(row.updated_at).toISOString() : null, + checklistSnapshot: row.checklist_snapshot != null ? row.checklist_snapshot : null, + summaryText: row.summary_text != null && String(row.summary_text).trim() ? String(row.summary_text).trim() : "", + }; +} + +/** + * 규칙 기반 액션(번호)·체크리스트를 우선하고 LLM 추출로 보완 (제목 기준 중복 제거) + * @param {string} markdown + * @param {Array<{ title: string, detail?: string, assignee?: string|null, due_note?: string|null, completed?: boolean }>} llmItems + */ +function mergeLlmAndRuleWorkItemsForSync(markdown, llmItems) { + const rule = parseChecklistFromMinutes.parseAllRuleBasedWorkItems(markdown); + const seen = new Set(); + /** @type {Array<{ title: string, detail: string, assignee: string|null, due_note: string|null, completed: boolean }>} */ + const out = []; + function push(arr) { + for (const it of arr) { + const t = (it.title || "").trim().toLowerCase(); + if (!t) continue; + if (seen.has(t)) continue; + seen.add(t); + out.push({ + title: String(it.title).trim(), + detail: String(it.detail != null ? it.detail : "").trim(), + assignee: it.assignee != null && String(it.assignee).trim() ? String(it.assignee).trim() : null, + due_note: it.due_note != null && String(it.due_note).trim() ? String(it.due_note).trim() : null, + completed: !!it.completed, + }); + } + } + push(rule); + push(llmItems || []); + return out; +} + +/** + * 회의록 저장 직후: 규칙(액션·체크리스트) + LLM JSON 추출 병합 → 스냅샷 저장 → 업무 체크리스트 행 삽입 + */ +async function syncAutoChecklistFromMeetingMinutes(openai, { pgPool, email, meetingId, generatedMinutes, uiModel }) { + if ((process.env.MEETING_AUTO_CHECKLIST || "1").trim() === "0") { + return { imported: 0, disabled: true }; + } + const maxChars = Number(process.env.MEETING_CHECKLIST_EXTRACT_MAX_CHARS || 24000); + let slice = String(generatedMinutes || "").trim(); + if (!slice) return { imported: 0 }; + if (slice.length > maxChars) slice = slice.slice(slice.length - maxChars); + + /** @type {Array<{ title: string, detail?: string, assignee?: string|null, due_note?: string|null, completed?: boolean }>} */ + let llmItems = []; + let extractError = null; + try { + llmItems = await meetingMinutesLib.extractChecklistStructured(openai, { + minutesMarkdown: slice, + uiModel, + resolveApiModel: resolveOpenAiApiModel, + }); + } catch (e) { + extractError = e?.response?.data?.error?.message || e?.message || "extract failed"; + console.warn("[meeting] checklist extract:", extractError); + } + + const merged = mergeLlmAndRuleWorkItemsForSync(slice, llmItems || []); + + const snapshot = { + items: merged, + extractedAt: new Date().toISOString(), + extractor: "rule-actions-checklist+openai-json", + llmItemCount: (llmItems || []).length, + mergedItemCount: merged.length, + ...(extractError ? { extractError } : {}), + }; + try { + await meetingAiStore.updateMeetingChecklistSnapshot(pgPool, meetingId, email, snapshot); + } catch (e) { + console.warn("[meeting] checklist_snapshot update:", e?.message); + } + + if (!merged.length) { + return { imported: 0, snapshot, ...(extractError ? { extractError } : {}) }; + } + + try { + const inserted = await taskChecklistStore.insertImportedBatch(pgPool, email, meetingId, merged); + return { imported: inserted.length, snapshot, ...(extractError ? { extractError } : {}) }; + } catch (e) { + console.warn("[meeting] checklist insert batch:", e?.message); + return { imported: 0, snapshot, importError: e?.message, ...(extractError ? { extractError } : {}) }; + } +} + +app.use("/public", express.static(path.join(ROOT_DIR, "public"))); +/** 채팅 마크다운 뷰어(marked + DOMPurify) — node_modules에서만 제공 */ +app.use("/vendor/marked", express.static(path.join(ROOT_DIR, "node_modules/marked/lib"))); +app.use("/vendor/dompurify", express.static(path.join(ROOT_DIR, "node_modules/dompurify/dist"))); +app.use("/uploads", express.static(UPLOAD_DIR)); +app.get("/resources/ax-apply/AX_과제_신청서.docx", (req, res) => { + if (!isAxApplyDownloadSubmitAllowed(req, res)) { + return res + .status(403) + .type("text/plain; charset=utf-8") + .send("로그인 후 이용 가능합니다."); + } + const fp = path.join(RESOURCES_AX_APPLY_DIR, "AX_과제_신청서.docx"); + if (!fsSync.existsSync(fp)) return res.status(404).send("Not found"); + res.download(fp, "AX_과제_신청서.docx"); +}); +app.use("/resources/ax-apply", express.static(RESOURCES_AX_APPLY_DIR)); + +app.use(opsAuth.middleware); +opsAuth.registerRoutes(app); + +const pageRouter = express.Router(); +pageRouter.get("/chat", (req, res) => + res.render("chat", { + activeMenu: "chat", + chatGptAllowed: isChatGptAllowed(req, res), + opsState: normalizeOpsState(), + adminMode: res.locals.adminMode, + opsUserEmail: !!res.locals.opsUserEmail, + }) +); +pageRouter.get("/ai-explore", (req, res) => + res.render("ai-explore", { + activeMenu: "ai-explore", + adminMode: res.locals.adminMode, + opsUserEmail: !!res.locals.opsUserEmail, + aiExploreDevGuestRestricted: isAiExploreDevGuestRestricted(req, res), + }) +); +pageRouter.get("/ai-explore/prompts", (req, res) => + res.render("ai-prompts", { activeMenu: "ai-explore", prompts: loadCompanyPrompts() }) +); +pageRouter.get("/ai-explore/meeting-minutes", (req, res) => + res.render("meeting-minutes", { + activeMenu: "ai-explore", + adminMode: res.locals.adminMode, + meetingUserEmail: getMeetingMinutesUserEmail(req, res) || "", + opsState: normalizeOpsState(), + }) +); +pageRouter.get("/ai-explore/task-checklist", (req, res) => + res.render("task-checklist", { + activeMenu: "ai-explore", + adminMode: res.locals.adminMode, + meetingUserEmail: getMeetingMinutesUserEmail(req, res) || "", + opsState: normalizeOpsState(), + }) +); +pageRouter.get("/ai-cases/write", (req, res) => { + if (!res.locals.adminMode) { + return res.status(403).send( + "권한 없음

관리자 모드가 필요합니다. 좌측 하단 관리자에서 토큰을 입력한 뒤 다시 시도하세요.

AI 성공 사례 목록으로

" + ); + } + const editSlug = (req.query.edit || "").trim(); + const meta = loadAiSuccessStoriesMeta(); + let story = null; + if (editSlug) { + const m = meta.find((x) => x.slug === editSlug); + if (m) story = enrichAiSuccessStory(m); + } + res.render("ai-cases-write", { + activeMenu: "ai-cases", + adminMode: true, + story, + allStories: meta, + editSlug: editSlug || null, + }); +}); + +pageRouter.get("/ai-cases", (req, res) => { + const q = (req.query.q || "").trim(); + const tag = (req.query.tag || "").trim(); + const meta = loadAiSuccessStoriesMeta(); + const filtered = filterAiSuccessStories(meta, q, tag); + const tags = allAiSuccessStoryTags(meta); + res.render("ai-cases", { + activeMenu: "ai-cases", + adminMode: res.locals.adminMode, + opsUserEmail: !!res.locals.opsUserEmail, + successStoryDetailAllowed: isAiSuccessStoryDetailAllowed(req, res), + stories: filtered, + filters: { q, tag }, + availableTags: tags, + }); +}); + +pageRouter.get("/ai-cases/:slug", async (req, res, next) => { + try { + if (!isAiSuccessStoryDetailAllowed(req, res)) { + return res.status(403).send( + "상세 열람 불가

상세 열람 불가

로그인 후 이용 가능합니다.

AI 성공 사례 목록으로

" + ); + } + const slug = (req.params.slug || "").trim(); + const meta = loadAiSuccessStoriesMeta(); + const m = meta.find((x) => x.slug === slug); + if (!m) return res.status(404).send("사례를 찾을 수 없습니다."); + const story = enrichAiSuccessStory(m); + const pdfUrl = (story.pdfUrl || "").trim(); + let slideImageUrls = []; + let slides = []; + if (pdfUrl) { + const out = await ensureAiSuccessStorySlides(slug, pdfUrl); + slideImageUrls = out.urls || []; + slides = slideImageUrls.map(() => ({ title: "", lines: [] })); + } + res.render("ai-case-detail", { + activeMenu: "ai-cases", + adminMode: res.locals.adminMode, + story, + slideImageUrls, + slides, + }); + } catch (err) { + next(err); + } +}); +pageRouter.get("/ax-apply", async (req, res) => { + let assignments = []; + if (res.locals.adminMode) { + assignments = await readAxAssignmentsDb(); + } else { + const all = await readAxAssignmentsDb(); + const sample = all.find(isSampleRecord); + if (sample) assignments = [sample]; + } + res.render("ax-apply", { + activeMenu: "ax-apply", + assignments, + adminMode: res.locals.adminMode, + axApplyDownloadSubmitAllowed: isAxApplyDownloadSubmitAllowed(req, res), + }); +}); + +app.get("/api/ax-apply-list", async (req, res) => { + const department = (req.query.department || "").trim(); + const name = (req.query.name || "").trim(); + const email = (req.query.email || "").trim(); + if (res.locals.adminMode) { + const list = department && name && email + ? await readAxAssignmentsByCredentials(department, name, email) + : await readAxAssignmentsDb(); + return res.json(list); + } + if (!department || !name || !email) { + const allEmpty = await readAxAssignmentsDb(); + const sample = allEmpty.find(isSampleRecord); + return res.json(sample ? [sample] : []); + } + const list = await readAxAssignmentsByCredentials(department, name, email); + const all = await readAxAssignmentsDb(); + const sample = all.find(isSampleRecord); + if (sample && !list.some((m) => m.id === sample.id)) { + list.push(sample); + } + res.json(list); +}); + +app.get("/api/ax-apply/:id", async (req, res) => { + const id = (req.params.id || "").trim(); + if (!id) return res.status(404).json({ error: "잘못된 요청입니다." }); + const department = (req.query.department || "").trim(); + const name = (req.query.name || "").trim(); + const email = (req.query.email || "").trim(); + const one = await readAxAssignmentById(id); + if (!one) return res.status(404).json({ error: "해당 신청을 찾을 수 없습니다." }); + if (!res.locals.adminMode) { + if (isSampleRecord(one)) { + return res.json(one); + } + if (!department || !name || !email) return res.status(403).json({ error: "소속 부서, 이름, 이메일을 입력해 조회한 후 수정할 수 있습니다." }); + const matches = await readAxAssignmentsByCredentials(department, name, email); + if (!matches.some((m) => m.id === id)) return res.status(403).json({ error: "본인의 신청만 수정할 수 있습니다." }); + } + res.json(one); +}); + +app.put("/api/ax-apply/:id", (req, res, next) => { + uploadAxApply.single("applicationFile")(req, res, (err) => { + if (err) return res.status(400).json({ error: err.message || "파일 업로드 실패" }); + next(); + }); +}, async (req, res) => { + try { + if (!isAxApplyDownloadSubmitAllowed(req, res)) { + return res.status(403).json({ error: "로그인 후 이용 가능합니다." }); + } + const id = (req.params.id || "").trim(); + if (!id) return res.status(404).json({ error: "잘못된 요청입니다." }); + const body = { ...req.body }; + const department = (body.department || "").trim(); + const name = (body.name || "").trim(); + const email = (body.email || "").trim(); + if (!res.locals.adminMode) { + const existing = await readAxAssignmentById(id); + if (existing && isSampleRecord(existing)) return res.status(403).json({ error: "샘플 데이터는 참고용으로 수정할 수 없습니다." }); + if (!department || !name || !email) return res.status(403).json({ error: "소속 부서, 이름, 이메일을 모두 입력해 주세요." }); + const matches = await readAxAssignmentsByCredentials(department, name, email); + if (!matches.some((m) => m.id === id)) return res.status(403).json({ error: "본인의 신청만 수정할 수 있습니다." }); + } + if (body.techStackStr && typeof body.techStackStr === "string") { + body.techStack = body.techStackStr.split(",").map((s) => s.trim()).filter(Boolean); + } + if (body.risksStr && typeof body.risksStr === "string") { + body.risks = body.risksStr.split(",").map((s) => s.trim()).filter(Boolean); + } + for (const k of ["dataTypes", "data_types", "techStackStr", "tech_stack", "risksStr", "qualitativeEffects", "qualitative_effects"]) { + if (body[k] === "" || (typeof body[k] === "string" && body[k] != null)) delete body[k]; + } + if (body.techStack && !Array.isArray(body.techStack)) delete body.techStack; + if (body.risks && !Array.isArray(body.risks)) delete body.risks; + if (!email) return res.status(400).json({ error: "이메일을 입력해 주세요." }); + if (req.file) body.applicationFile = `/uploads/ax-apply/${req.file.filename}`; + body.participationPledge = body.participationPledge === "1" || body.participationPledge === true; + const row = await updateAxAssignmentDb(id, body); + if (!row) return res.status(404).json({ error: "해당 신청을 찾을 수 없습니다." }); + res.json({ ok: true, id: row.id }); + } catch (err) { + const msg = err?.message || "저장 실패"; + if (err instanceof ReferenceError && /updateAxAssignmentDb/.test(String(msg))) { + return res.status(500).json({ error: "서버를 재시작한 후 다시 시도해 주세요. (코드 반영이 필요할 수 있습니다.)" }); + } + res.status(500).json({ error: msg }); + } +}); + +app.delete("/api/ax-apply/:id", async (req, res) => { + if (!res.locals.adminMode) return res.status(403).json({ error: "관리자만 삭제할 수 있습니다." }); + try { + const id = (req.params.id || "").trim(); + if (!id) return res.status(404).json({ error: "잘못된 요청입니다." }); + const ok = await deleteAxAssignmentDb(id); + if (!ok) return res.status(404).json({ error: "해당 신청을 찾을 수 없습니다." }); + res.json({ ok: true }); + } catch (err) { + res.status(500).json({ error: err?.message || "삭제 실패" }); + } +}); + +app.post("/api/ax-apply", (req, res, next) => { + uploadAxApply.single("applicationFile")(req, res, (err) => { + if (err) return res.status(400).json({ error: err.message || "파일 업로드 실패" }); + next(); + }); +}, async (req, res) => { + try { + if (!isAxApplyDownloadSubmitAllowed(req, res)) { + return res.status(403).json({ error: "로그인 후 이용 가능합니다." }); + } + const body = { ...req.body }; + if (body.techStackStr && typeof body.techStackStr === "string") { + body.techStack = body.techStackStr.split(",").map((s) => s.trim()).filter(Boolean); + } + if (body.risksStr && typeof body.risksStr === "string") { + body.risks = body.risksStr.split(",").map((s) => s.trim()).filter(Boolean); + } + for (const k of ["dataTypes", "data_types", "techStackStr", "tech_stack", "risksStr", "qualitativeEffects", "qualitative_effects"]) { + if (body[k] === "" || (typeof body[k] === "string" && body[k] != null)) delete body[k]; + } + if (body.techStack && !Array.isArray(body.techStack)) delete body.techStack; + if (body.risks && !Array.isArray(body.risks)) delete body.risks; + if (!req.file) { + return res.status(400).json({ error: "작성 완료 신청서(.docx) 파일을 업로드해 주세요." }); + } + const email = (body.email || "").trim(); + if (!email) { + return res.status(400).json({ error: "이메일을 입력해 주세요." }); + } + body.applicationFile = `/uploads/ax-apply/${req.file.filename}`; + body.participationPledge = body.participationPledge === "1" || body.participationPledge === true; + const row = await createAxAssignmentDb(body); + res.json({ ok: true, id: row.id }); + } catch (err) { + const msg = err?.message || "저장 실패"; + if (err instanceof ReferenceError && /updateAxAssignmentDb/.test(String(msg))) { + return res.status(500).json({ error: "서버를 재시작한 후 다시 시도해 주세요. (코드 반영이 필요할 수 있습니다.)" }); + } + res.status(500).json({ error: msg }); + } +}); + +const SYSTEM_PROMPT = [ + "당신은 자비스(XAVIS) 플랫폼의 도우미입니다. 친절하고 정확하게 답변해주세요.", + "", + "웹 검색이나 제공된 검색 결과를 쓸 수 있으면 여러 출처를 종합해 답하세요.", + "회사·조직의 주소·위치·소재지·오시는 길 등을 묻는 질문에는 본사(또는 등기·공시상 대표 주소)뿐 아니라, 검색·자료에 나오면 공장·연구소·지사·지역 거점 등 주요 부속 시설을 구분해 짧게 정리해 포함하세요. 정보가 본사만 확인되는 경우에도 그 한계를 짧게 밝히고, 추가 거점이 공개되어 있으면 빠짐없이 반영하세요.", +].join("\n"); + +const getProvider = (model) => { + const m = (model || "").toString().toLowerCase(); + if (m.startsWith("claude-")) return "anthropic"; + if (m.startsWith("gemini-")) return "google"; + return "openai"; +}; + +const GEMINI_MODEL_MAP = { + "gemini-3-pro": "gemini-2.5-pro", + "gemini-3-flash": "gemini-2.5-flash", +}; + +/** 채팅 UI와 동일하게 허용하는 모델만 처리 */ +const CHAT_ALLOWED_MODELS = new Set(["gpt-5.4", "gpt-5-mini"]); + +/** UI 선택값 → OpenAI Chat Completions API에 넘길 실제 모델 ID (gpt-5.4 등은 API에 없을 수 있어 기본은 gpt-4o 계열) */ +function resolveOpenAiApiModel(uiModel) { + const map = { + "gpt-5.4": (process.env.OPENAI_MODEL_DEFAULT || "gpt-4o").trim(), + "gpt-5-mini": (process.env.OPENAI_MODEL_MINI || "gpt-4o-mini").trim(), + }; + return map[uiModel] || uiModel; +} + +function buildResponsesInputFromChatMessages(normalizedMessages) { + const input = []; + for (const m of normalizedMessages) { + const r = String(m.role || "user").toLowerCase(); + if (r !== "user" && r !== "assistant") continue; + input.push({ + role: r === "assistant" ? "assistant" : "user", + content: m.content, + }); + } + return input; +} + +function buildOpenAiWebSearchTool() { + const tool = { type: "web_search" }; + const country = (process.env.OPENAI_WEB_SEARCH_COUNTRY || "KR").trim(); + if (country) { + tool.user_location = { + type: "approximate", + country, + }; + const city = (process.env.OPENAI_WEB_SEARCH_CITY || "").trim(); + const region = (process.env.OPENAI_WEB_SEARCH_REGION || "").trim(); + const timezone = (process.env.OPENAI_WEB_SEARCH_TIMEZONE || "Asia/Seoul").trim(); + if (city) tool.user_location.city = city; + if (region) tool.user_location.region = region; + if (timezone) tool.user_location.timezone = timezone; + } + return tool; +} + +function extractSourcesFromResponse(response) { + const out = []; + const seen = new Set(); + if (!response?.output) return out; + for (const item of response.output) { + if (item.type !== "message") continue; + for (const part of item.content || []) { + if (part.type !== "output_text" || !part.annotations) continue; + for (const ann of part.annotations) { + if (ann.type === "url_citation" && ann.url && !seen.has(ann.url)) { + seen.add(ann.url); + out.push({ url: ann.url, title: ann.title || ann.url }); + } + } + } + } + return out; +} + +/** 회의록 본문 수동 저장 — PATCH/PUT/POST(프록시·구버전 호환) */ +async function saveMeetingGeneratedMinutesApi(req, res) { + try { + await ensureMeetingUserAndDefaultPrompt(req.meetingUserEmail); + const id = (req.params.id || "").trim(); + const b = req.body || {}; + const generatedMinutes = meetingMinutesLib.prepareMeetingMinutesForApi((b.generatedMinutes ?? "").toString()); + if (!id) return res.status(400).json({ error: "회의 ID가 필요합니다." }); + const prev = await meetingAiStore.getMeeting(pgPool, id, req.meetingUserEmail); + if (!prev) return res.status(404).json({ error: "회의록을 찾을 수 없습니다." }); + const model = (prev.chat_model || "gpt-5-mini").toString().trim(); + const fields = { generatedMinutes }; + if (Object.prototype.hasOwnProperty.call(b, "transcriptText")) { + fields.transcriptText = (b.transcriptText ?? "").toString(); + } + if (Object.prototype.hasOwnProperty.call(b, "sourceText")) { + fields.sourceText = (b.sourceText ?? "").toString(); + } + const updated = await meetingAiStore.updateMeetingContent(pgPool, id, req.meetingUserEmail, fields); + if (!updated) return res.status(404).json({ error: "저장할 수 없습니다." }); + let checklistSync = { imported: 0 }; + if (OPENAI_API_KEY) { + try { + const openai = new OpenAI({ apiKey: OPENAI_API_KEY }); + checklistSync = await syncAutoChecklistFromMeetingMinutes(openai, { + pgPool, + email: req.meetingUserEmail, + meetingId: id, + generatedMinutes, + uiModel: model, + }); + } catch (e) { + checklistSync = { imported: 0, extractError: e?.message }; + } + } else { + checklistSync = { imported: 0, disabled: true }; + } + const fresh = await meetingAiStore.getMeeting(pgPool, id, req.meetingUserEmail); + const meetingOut = mapRowToMeeting(fresh); + if (checklistSync.snapshot) meetingOut.checklistSnapshot = checklistSync.snapshot; + res.json({ meeting: meetingOut, checklistSync }); + } catch (err) { + res.status(500).json({ error: err?.message || "저장 실패" }); + } +} + +const meetingMinutesSaveJson = express.json({ limit: "2mb" }); + +app.get("/api/meeting-minutes/prompt", requireMeetingMinutesEmail, async (req, res) => { + try { + await ensureMeetingUserAndDefaultPrompt(req.meetingUserEmail); + const row = await meetingAiStore.getPromptRow(pgPool, req.meetingUserEmail); + res.json({ prompt: mapRowToMeetingPrompt(row) }); + } catch (err) { + res.status(500).json({ error: err?.message || "조회 실패" }); + } +}); + +app.put("/api/meeting-minutes/prompt", requireMeetingMinutesEmail, express.json(), async (req, res) => { + try { + await ensureMeetingUserAndDefaultPrompt(req.meetingUserEmail); + const b = req.body || {}; + const includeTitleLine = b.includeTitleLine !== false; + const includeAttendees = b.includeAttendees !== false; + const includeSummary = b.includeSummary !== false; + const includeActionItems = b.includeActionItems !== false; + const includeChecklist = true; + const customInstructions = (b.customInstructions || "").toString().slice(0, 8000); + const row = await meetingAiStore.upsertPrompt(pgPool, req.meetingUserEmail, { + includeTitleLine, + includeAttendees, + includeSummary, + includeActionItems, + includeChecklist, + customInstructions, + }); + res.json({ prompt: mapRowToMeetingPrompt(row) }); + } catch (err) { + res.status(500).json({ error: err?.message || "저장 실패" }); + } +}); + +app.get("/api/meeting-minutes/meetings", requireMeetingMinutesEmail, async (req, res) => { + try { + await ensureMeetingUserAndDefaultPrompt(req.meetingUserEmail); + const rows = await meetingAiStore.listMeetings(pgPool, req.meetingUserEmail); + res.json({ meetings: (rows || []).map(mapRowToMeeting) }); + } catch (err) { + res.status(500).json({ error: err?.message || "목록 조회 실패" }); + } +}); + +app.get("/api/meeting-minutes/meetings/:id", requireMeetingMinutesEmail, async (req, res) => { + try { + const id = (req.params.id || "").trim(); + const row = await meetingAiStore.getMeeting(pgPool, id, req.meetingUserEmail); + if (!row) return res.status(404).json({ error: "회의록을 찾을 수 없습니다." }); + res.json({ meeting: mapRowToMeeting(row) }); + } catch (err) { + res.status(500).json({ error: err?.message || "조회 실패" }); + } +}); + +app.patch("/api/meeting-minutes/meetings/:id", requireMeetingMinutesEmail, meetingMinutesSaveJson, saveMeetingGeneratedMinutesApi); +app.put("/api/meeting-minutes/meetings/:id", requireMeetingMinutesEmail, meetingMinutesSaveJson, saveMeetingGeneratedMinutesApi); +app.post("/api/meeting-minutes/meetings/:id/save", requireMeetingMinutesEmail, meetingMinutesSaveJson, saveMeetingGeneratedMinutesApi); + +app.delete("/api/meeting-minutes/meetings/:id", requireMeetingMinutesEmail, async (req, res) => { + try { + const id = (req.params.id || "").trim(); + const result = await meetingAiStore.deleteMeeting(pgPool, id, req.meetingUserEmail); + if (!result.deleted) return res.status(404).json({ error: "삭제할 항목이 없습니다." }); + if (result.audio_file_path) { + const abs = path.join(ROOT_DIR, result.audio_file_path.replace(/^\//, "")); + const norm = path.normalize(abs); + if (norm.startsWith(MEETING_MINUTES_UPLOAD_DIR) && fsSync.existsSync(norm)) { + fsSync.unlinkSync(norm); + } + } + res.json({ ok: true }); + } catch (err) { + res.status(500).json({ error: err?.message || "삭제 실패" }); + } +}); + +app.post("/api/meeting-minutes/generate-text", requireMeetingMinutesEmail, express.json({ limit: "2mb" }), async (req, res) => { + if (!OPENAI_API_KEY) return res.status(503).json({ error: "OPENAI_API_KEY가 설정되지 않았습니다." }); + try { + await ensureMeetingUserAndDefaultPrompt(req.meetingUserEmail); + const title = (req.body?.title || "").toString().trim().slice(0, 500); + const sourceText = (req.body?.sourceText || "").toString().trim(); + const model = (req.body?.model || "gpt-5-mini").toString().trim(); + const mdParsed = parseMeetingDateIso(req.body?.meetingDate); + if (mdParsed.error) return res.status(400).json({ error: mdParsed.error }); + if (!sourceText) return res.status(400).json({ error: "회의 내용을 입력해 주세요." }); + if (!MEETING_MINUTES_ALLOWED_MODELS.has(model)) { + return res.status(400).json({ error: "지원 모델: gpt-5-mini, gpt-5.4" }); + } + const pr = await meetingAiStore.getPromptRow(pgPool, req.meetingUserEmail); + const systemPrompt = meetingMinutesLib.buildMeetingMinutesSystemPrompt(mapRowToMeetingPrompt(pr)); + const openai = new OpenAI({ apiKey: OPENAI_API_KEY }); + const generated = await meetingMinutesLib.generateMeetingMinutes(openai, { + systemPrompt, + userContent: sourceText, + uiModel: model, + resolveApiModel: resolveOpenAiApiModel, + }); + const ins = await meetingAiStore.insertMeetingText(pgPool, { + email: req.meetingUserEmail, + title, + sourceText, + generated, + model, + meetingDate: mdParsed.value, + }); + let checklistSync = { imported: 0 }; + try { + checklistSync = await syncAutoChecklistFromMeetingMinutes(openai, { + pgPool, + email: req.meetingUserEmail, + meetingId: ins.id, + generatedMinutes: generated, + uiModel: model, + }); + } catch (e) { + checklistSync = { imported: 0, extractError: e?.message }; + } + const meetingOut = mapRowToMeeting(ins); + if (checklistSync.snapshot) meetingOut.checklistSnapshot = checklistSync.snapshot; + res.json({ meeting: meetingOut, checklistSync }); + } catch (err) { + const msg = err?.response?.data?.error?.message || err?.message || "생성 실패"; + res.status(500).json({ error: msg }); + } +}); + +app.post( + "/api/meeting-minutes/generate-audio", + requireMeetingMinutesEmail, + (req, res, next) => { + uploadMeetingAudio.single("audio")(req, res, (err) => { + if (err) return res.status(400).json({ error: err.message || "파일 업로드 실패" }); + next(); + }); + }, + async (req, res) => { + if (!OPENAI_API_KEY) return res.status(503).json({ error: "OPENAI_API_KEY가 설정되지 않았습니다." }); + try { + await ensureMeetingUserAndDefaultPrompt(req.meetingUserEmail); + const title = (req.body?.title || "").toString().trim().slice(0, 500); + const model = (req.body?.model || "gpt-5-mini").toString().trim(); + const whisperModel = (req.body?.whisperModel || meetingMinutesLib.DEFAULT_TRANSCRIPTION_MODEL).toString().trim(); + const mdParsed = parseMeetingDateIso(req.body?.meetingDate); + if (mdParsed.error) return res.status(400).json({ error: mdParsed.error }); + if (!req.file) return res.status(400).json({ error: "음성 파일을 선택해 주세요." }); + if (!MEETING_MINUTES_ALLOWED_MODELS.has(model)) { + return res.status(400).json({ error: "지원 모델: gpt-5-mini, gpt-5.4" }); + } + if (!meetingMinutesLib.TRANSCRIPTION_UI_MODELS.has(whisperModel)) { + return res.status(400).json({ error: "지원 전사 모델: gpt-4o-mini-transcribe, gpt-4o-transcribe" }); + } + const relPath = `/uploads/${path.relative(UPLOAD_DIR, req.file.path).split(path.sep).join("/")}`; + const pr = await meetingAiStore.getPromptRow(pgPool, req.meetingUserEmail); + const systemPrompt = meetingMinutesLib.buildMeetingMinutesSystemPrompt(mapRowToMeetingPrompt(pr)); + const openai = new OpenAI({ apiKey: OPENAI_API_KEY }); + let transcript = ""; + try { + transcript = await meetingMinutesLib.transcribeMeetingAudio(openai, req.file.path, whisperModel); + } catch (te) { + const tmsg = te?.response?.data?.error?.message || te?.message || "전사 실패"; + return res.status(500).json({ error: `Whisper 전사 실패: ${tmsg}` }); + } + if (!transcript.trim()) return res.status(400).json({ error: "전사 결과가 비어 있습니다." }); + const generated = await meetingMinutesLib.generateMeetingMinutes(openai, { + systemPrompt, + userContent: transcript, + uiModel: model, + resolveApiModel: resolveOpenAiApiModel, + }); + const ins = await meetingAiStore.insertMeetingAudio(pgPool, { + email: req.meetingUserEmail, + title, + transcript, + generated, + relPath, + originalName: req.file.originalname, + model, + whisperModel, + meetingDate: mdParsed.value, + }); + let checklistSync = { imported: 0 }; + try { + checklistSync = await syncAutoChecklistFromMeetingMinutes(openai, { + pgPool, + email: req.meetingUserEmail, + meetingId: ins.id, + generatedMinutes: generated, + uiModel: model, + }); + } catch (e) { + checklistSync = { imported: 0, extractError: e?.message }; + } + const meetingOut = mapRowToMeeting(ins); + if (checklistSync.snapshot) meetingOut.checklistSnapshot = checklistSync.snapshot; + res.json({ meeting: meetingOut, checklistSync }); + } catch (err) { + const msg = err?.response?.data?.error?.message || err?.message || "생성 실패"; + res.status(500).json({ error: msg }); + } + } +); + +app.get("/api/task-checklist/items", requireMeetingMinutesEmail, async (req, res) => { + try { + await ensureMeetingUserAndDefaultPrompt(req.meetingUserEmail); + const completed = req.query.completed; + let cf = null; + if (completed === "true") cf = true; + else if (completed === "false") cf = false; + const midRaw = (req.query.meetingId || "").trim(); + const meetingId = midRaw && midRaw !== "__all__" ? midRaw : null; + const items = await taskChecklistStore.listItems(pgPool, req.meetingUserEmail, { + completed: cf, + meetingId: meetingId || undefined, + }); + const mids = items.map((it) => (it.meetingId != null ? String(it.meetingId) : "")).filter(Boolean); + const metaById = await meetingAiStore.getMeetingMetaForIds(pgPool, req.meetingUserEmail, mids); + const enriched = items.map((it) => { + const mid = it.meetingId != null ? String(it.meetingId) : ""; + const meta = mid && metaById.has(mid) ? metaById.get(mid) : null; + return { + ...it, + meetingTitle: meta ? meta.meetingTitle : null, + meetingDate: meta ? meta.meetingDate : null, + meetingSummary: meta ? meta.meetingSummary : null, + }; + }); + res.json({ items: enriched }); + } catch (err) { + res.status(500).json({ error: err?.message || "목록 조회 실패" }); + } +}); + +app.post("/api/task-checklist/items", requireMeetingMinutesEmail, express.json(), async (req, res) => { + try { + await ensureMeetingUserAndDefaultPrompt(req.meetingUserEmail); + const item = await taskChecklistStore.insertItem(pgPool, req.meetingUserEmail, { + title: req.body?.title, + detail: req.body?.detail, + assignee: req.body?.assignee, + dueNote: req.body?.dueNote, + meetingId: req.body?.meetingId || null, + source: "manual", + completed: !!req.body?.completed, + }); + res.json({ item }); + } catch (err) { + const msg = err?.message || "저장 실패"; + res.status(400).json({ error: msg }); + } +}); + +app.patch("/api/task-checklist/items/:id", requireMeetingMinutesEmail, express.json(), async (req, res) => { + try { + const id = (req.params.id || "").trim(); + const item = await taskChecklistStore.updateItem(pgPool, id, req.meetingUserEmail, req.body || {}); + if (!item) return res.status(404).json({ error: "항목을 찾을 수 없습니다." }); + res.json({ item }); + } catch (err) { + res.status(500).json({ error: err?.message || "수정 실패" }); + } +}); + +app.delete("/api/task-checklist/items/:id", requireMeetingMinutesEmail, async (req, res) => { + try { + const id = (req.params.id || "").trim(); + const result = await taskChecklistStore.deleteItem(pgPool, id, req.meetingUserEmail); + if (!result.deleted) return res.status(404).json({ error: "삭제할 항목이 없습니다." }); + res.json({ ok: true }); + } catch (err) { + res.status(500).json({ error: err?.message || "삭제 실패" }); + } +}); + +app.post("/api/task-checklist/import/:meetingId", requireMeetingMinutesEmail, async (req, res) => { + try { + await ensureMeetingUserAndDefaultPrompt(req.meetingUserEmail); + const meetingId = (req.params.meetingId || "").trim(); + const mode = (req.query.mode || "checklist").toString().trim() === "actions" ? "actions" : "checklist"; + const row = await meetingAiStore.getMeeting(pgPool, meetingId, req.meetingUserEmail); + if (!row) return res.status(404).json({ error: "회의록을 찾을 수 없습니다." }); + const text = row.generated_minutes || ""; + if (!text.trim()) return res.status(400).json({ error: "회의록 본문이 비어 있습니다." }); + const parsed = parseChecklistFromMinutes.parseItemsFromMinutes(text, mode); + if (!parsed.length) { + return res.status(400).json({ + error: + mode === "actions" + ? "‘액션 아이템’ 섹션에서 항목을 찾지 못했습니다. 제목 형식(번호 목록)을 확인해 주세요." + : "‘후속 확인 체크리스트’ 등 체크리스트 섹션에서 항목을 찾지 못했습니다.", + parsedCount: 0, + }); + } + const inserted = await taskChecklistStore.insertImportedBatch(pgPool, req.meetingUserEmail, meetingId, parsed); + res.json({ imported: inserted.length, items: inserted }); + } catch (err) { + res.status(500).json({ error: err?.message || "가져오기 실패" }); + } +}); + +/** 모든 회의록에 대해 규칙 기반 가져오기(회의별 중복은 기존과 동일하게 스킵) */ +app.post("/api/task-checklist/import-all", requireMeetingMinutesEmail, async (req, res) => { + try { + await ensureMeetingUserAndDefaultPrompt(req.meetingUserEmail); + const mode = (req.query.mode || "checklist").toString().trim() === "actions" ? "actions" : "checklist"; + const rows = await meetingAiStore.listMeetings(pgPool, req.meetingUserEmail); + if (!rows.length) { + return res.status(400).json({ error: "가져올 회의록이 없습니다." }); + } + let imported = 0; + /** @type {Array<{ meetingId: string, title: string, imported: number, skip?: string }>} */ + const details = []; + for (const row of rows) { + const meetingId = row.id != null ? String(row.id) : ""; + const title = (row.title && String(row.title).trim()) || "제목 없음"; + if (!meetingId) continue; + const text = row.generated_minutes || ""; + if (!String(text).trim()) { + details.push({ meetingId, title, imported: 0, skip: "empty_body" }); + continue; + } + const parsed = parseChecklistFromMinutes.parseItemsFromMinutes(text, mode); + if (!parsed.length) { + details.push({ meetingId, title, imported: 0, skip: "no_parsed_items" }); + continue; + } + const inserted = await taskChecklistStore.insertImportedBatch(pgPool, req.meetingUserEmail, meetingId, parsed); + const n = inserted.length; + imported += n; + details.push({ meetingId, title, imported: n }); + } + const meetingsWithImports = details.filter((d) => d.imported > 0).length; + res.json({ + imported, + meetingCount: rows.length, + meetingsWithImports, + details, + }); + } catch (err) { + res.status(500).json({ error: err?.message || "가져오기 실패" }); + } +}); + +app.get("/api/chat/config", (req, res) => { + res.json({ + configured: OPENAI_API_KEY.length > 0, + webSearch: OPENAI_WEB_SEARCH, + chatGptAllowed: isChatGptAllowed(req, res), + }); +}); + +app.post("/api/chat", async (req, res) => { + if (!isChatGptAllowed(req, res)) { + res.status(403).json({ error: "허용된 사용자가 아닙니다." }); + return; + } + const { messages, model } = req.body; + if (!Array.isArray(messages) || messages.length === 0) { + res.status(400).json({ error: "messages 배열이 필요합니다." }); + return; + } + const chatModel = (model || "gpt-5-mini").toString().trim() || "gpt-5-mini"; + if (!CHAT_ALLOWED_MODELS.has(chatModel)) { + res.status(400).json({ error: "지원하지 않는 모델입니다. gpt-5.4 또는 gpt-5-mini만 사용할 수 있습니다." }); + return; + } + const provider = getProvider(chatModel); + + const normalizedMessages = messages.map((m) => ({ + role: m.role || "user", + content: String(m.content || ""), + })); + + try { + let reply = ""; + let sources; + + if (provider === "openai") { + if (!OPENAI_API_KEY) { + res.status(503).json({ error: "OPENAI_API_KEY가 설정되지 않았습니다. .env 파일을 확인한 뒤 서버를 재시작해주세요." }); + return; + } + const apiModel = resolveOpenAiApiModel(chatModel); + const openai = new OpenAI({ apiKey: OPENAI_API_KEY }); + if (OPENAI_WEB_SEARCH) { + const resp = await openai.responses.create({ + model: apiModel, + instructions: SYSTEM_PROMPT, + input: buildResponsesInputFromChatMessages(normalizedMessages), + tools: [buildOpenAiWebSearchTool()], + include: ["web_search_call.action.sources"], + }); + reply = resp.output_text || ""; + sources = extractSourcesFromResponse(resp); + } else { + const completion = await openai.chat.completions.create({ + model: apiModel, + messages: [ + { role: "system", content: SYSTEM_PROMPT }, + ...normalizedMessages, + ], + }); + reply = completion.choices?.[0]?.message?.content || ""; + } + } else if (provider === "anthropic") { + if (!CLAUDE_API_KEY) { + res.status(503).json({ error: "CLAUDE_API_KEY가 설정되지 않았습니다. .env 파일을 확인해주세요." }); + return; + } + const anthropic = new Anthropic({ apiKey: CLAUDE_API_KEY }); + const claudeMessages = normalizedMessages + .filter((m) => m.role === "user" || m.role === "assistant") + .map((m) => ({ role: m.role, content: m.content })); + const msg = await anthropic.messages.create({ + model: chatModel, + max_tokens: 4096, + system: SYSTEM_PROMPT, + messages: claudeMessages, + }); + const textBlock = msg.content?.find((b) => b.type === "text"); + reply = textBlock?.text || ""; + } else if (provider === "google") { + if (!GENAI_API_KEY) { + res.status(503).json({ error: "GENAI_API_KEY가 설정되지 않았습니다. .env 파일을 확인해주세요." }); + return; + } + const geminiModel = GEMINI_MODEL_MAP[chatModel] || chatModel; + const ai = new GoogleGenAI({ apiKey: GENAI_API_KEY }); + const geminiContents = normalizedMessages.map((m) => ({ + role: m.role === "assistant" ? "model" : "user", + parts: [{ text: m.content }], + })); + const response = await ai.models.generateContent({ + model: geminiModel, + contents: geminiContents, + config: { systemInstruction: SYSTEM_PROMPT }, + }); + reply = response?.text || ""; + } + + const payload = { message: reply }; + if (sources && sources.length) payload.sources = sources; + res.json(payload); + } catch (err) { + const apiMsg = + err?.error?.message || + err?.response?.data?.error?.message || + err?.message || + `${provider} API 오류`; + const status = + typeof err?.status === "number" + ? err.status + : err?.response?.status && err.response.status >= 400 && err.response.status < 600 + ? err.response.status + : 500; + res.status(status >= 400 && status < 600 ? status : 500).json({ error: apiMsg }); + } +}); + +function writeChatSse(res, obj) { + res.write(`data: ${JSON.stringify(obj)}\n\n`); +} + +/** OpenAI Chat Completions 스트리밍 (SSE). 클라이언트는 fetch + ReadableStream으로 수신 */ +app.post("/api/chat/stream", async (req, res) => { + const { messages, model } = req.body; + if (!Array.isArray(messages) || messages.length === 0) { + res.status(400).json({ error: "messages 배열이 필요합니다." }); + return; + } + if (!isChatGptAllowed(req, res)) { + res.status(403).json({ error: "허용된 사용자가 아닙니다." }); + return; + } + const chatModel = (model || "gpt-5-mini").toString().trim() || "gpt-5-mini"; + if (!CHAT_ALLOWED_MODELS.has(chatModel)) { + res.status(400).json({ error: "지원하지 않는 모델입니다. gpt-5.4 또는 gpt-5-mini만 사용할 수 있습니다." }); + return; + } + const provider = getProvider(chatModel); + if (provider !== "openai") { + res.status(400).json({ error: "스트리밍은 OpenAI 모델만 지원합니다." }); + return; + } + if (!OPENAI_API_KEY) { + res.status(503).json({ error: "OPENAI_API_KEY가 설정되지 않았습니다. .env 파일을 확인한 뒤 서버를 재시작해주세요." }); + return; + } + + const normalizedMessages = messages.map((m) => ({ + role: m.role || "user", + content: String(m.content || ""), + })); + + res.setHeader("Content-Type", "text/event-stream; charset=utf-8"); + res.setHeader("Cache-Control", "no-cache, no-transform"); + res.setHeader("Connection", "keep-alive"); + res.setHeader("X-Accel-Buffering", "no"); + + const apiModel = resolveOpenAiApiModel(chatModel); + const openai = new OpenAI({ apiKey: OPENAI_API_KEY }); + + try { + if (OPENAI_WEB_SEARCH) { + const stream = await openai.responses.create({ + model: apiModel, + instructions: SYSTEM_PROMPT, + input: buildResponsesInputFromChatMessages(normalizedMessages), + tools: [buildOpenAiWebSearchTool()], + stream: true, + include: ["web_search_call.action.sources"], + }); + + for await (const event of stream) { + if (event.type === "response.output_text.delta") { + const d = event.delta; + if (d) writeChatSse(res, { type: "delta", text: d }); + } else if ( + event.type === "response.web_search_call.searching" || + event.type === "response.web_search_call.in_progress" + ) { + writeChatSse(res, { type: "status", phase: "web_search" }); + } else if (event.type === "response.completed") { + const items = extractSourcesFromResponse(event.response); + if (items.length) writeChatSse(res, { type: "sources", items }); + } else if (event.type === "error") { + throw new Error(event.message || "Responses API 오류"); + } else if (event.type === "response.failed") { + const msg = event.response?.error?.message || "응답 생성 실패"; + throw new Error(msg); + } + } + writeChatSse(res, { type: "done" }); + res.end(); + return; + } + + const stream = await openai.chat.completions.create({ + model: apiModel, + messages: [{ role: "system", content: SYSTEM_PROMPT }, ...normalizedMessages], + stream: true, + }); + + for await (const chunk of stream) { + const delta = chunk.choices?.[0]?.delta?.content ?? ""; + if (delta) writeChatSse(res, { type: "delta", text: delta }); + } + writeChatSse(res, { type: "done" }); + res.end(); + } catch (err) { + const apiMsg = + err?.error?.message || + err?.response?.data?.error?.message || + err?.message || + "OpenAI API 오류"; + try { + writeChatSse(res, { type: "error", error: apiMsg }); + res.end(); + } catch { + if (!res.headersSent) { + res.status(500).json({ error: apiMsg }); + } + } + } +}); + +app.post( + "/api/ai-success-stories/upload-pdf", + requireAdminApi, + (req, res, next) => { + uploadAiSuccessPdf.single("pdfFile")(req, res, (err) => { + if (err) { + res.status(400).json({ error: err.message || "업로드 실패" }); + return; + } + next(); + }); + }, + (req, res) => { + try { + if (!req.file) { + res.status(400).json({ error: "PDF 파일을 선택해 주세요." }); + return; + } + const rel = path.relative(path.join(ROOT_DIR, "public"), req.file.path).replace(/\\/g, "/"); + const pdfUrl = `/public/${rel}`; + res.json({ ok: true, pdfUrl, filename: req.file.filename }); + } catch (err) { + res.status(500).json({ error: err?.message || "저장 실패" }); + } + } +); + +app.post("/api/ai-success-stories", requireAdminApi, (req, res) => { + try { + const body = req.body || {}; + const title = String(body.title || "").trim(); + let slug = String(body.slug || "") + .trim() + .toLowerCase() + .replace(/[^a-z0-9\-]/g, ""); + const bodyMd = String(body.bodyMarkdown || "").trim(); + const pdfUrlTrim = String(body.pdfUrl || "").trim(); + if (!title || !slug) { + res.status(400).json({ error: "제목과 슬러그(영문·숫자·하이픈)는 필수입니다." }); + return; + } + if (!bodyMd && !pdfUrlTrim) { + res.status(400).json({ error: "본문(Markdown) 또는 원문 PDF 중 하나는 필수입니다." }); + return; + } + const meta = loadAiSuccessStoriesMeta(); + if (meta.some((m) => m.slug === slug)) { + res.status(400).json({ error: "같은 슬러그가 이미 있습니다." }); + return; + } + const id = `story-${Date.now()}`; + const tags = String(body.tags || "") + .split(",") + .map((t) => t.trim()) + .filter(Boolean); + const fileName = buildAiSuccessStoryContentFileName(slug); + fsSync.mkdirSync(AI_SUCCESS_CONTENT_DIR, { recursive: true }); + fsSync.writeFileSync(path.join(AI_SUCCESS_CONTENT_DIR, fileName), bodyMd, "utf8"); + const row = { + id, + slug, + title, + excerpt: String(body.excerpt || "").trim() || title.slice(0, 140), + author: String(body.author || "").trim(), + department: String(body.department || "").trim(), + publishedAt: String(body.publishedAt || "").trim() || new Date().toISOString().slice(0, 10), + tags, + contentFile: fileName, + pdfUrl: String(body.pdfUrl || "").trim(), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + meta.push(row); + saveAiSuccessStoriesMeta(meta); + res.json({ ok: true, story: row }); + } catch (err) { + res.status(500).json({ error: err?.message || "저장 실패" }); + } +}); + +app.put("/api/ai-success-stories/:id", requireAdminApi, (req, res) => { + try { + const id = req.params.id; + const meta = loadAiSuccessStoriesMeta(); + const idx = meta.findIndex((m) => m.id === id); + if (idx === -1) { + res.status(404).json({ error: "해당 사례가 없습니다." }); + return; + } + const body = req.body || {}; + const cur = meta[idx]; + const title = body.title !== undefined ? String(body.title).trim() : cur.title; + let slug = body.slug !== undefined + ? String(body.slug) + .trim() + .toLowerCase() + .replace(/[^a-z0-9\-]/g, "") + : cur.slug; + const bodyMd = + body.bodyMarkdown !== undefined ? String(body.bodyMarkdown) : loadStoryBodyMarkdown(cur); + const nextPdf = body.pdfUrl !== undefined ? String(body.pdfUrl).trim() : (cur.pdfUrl || "").trim(); + if (!title || !slug) { + res.status(400).json({ error: "제목과 슬러그는 필수입니다." }); + return; + } + if (!bodyMd.trim() && !nextPdf) { + res.status(400).json({ error: "본문(Markdown) 또는 원문 PDF 중 하나는 필수입니다." }); + return; + } + if (meta.some((m, i) => m.slug === slug && i !== idx)) { + res.status(400).json({ error: "같은 슬러그가 이미 있습니다." }); + return; + } + const oldSlug = cur.slug; + const oldFileBase = cur.contentFile ? path.basename(cur.contentFile) : `${oldSlug}.md`; + let nextFile; + if (slug !== oldSlug) { + nextFile = buildAiSuccessStoryContentFileName(slug); + } else { + nextFile = cur.contentFile ? path.basename(cur.contentFile) : buildAiSuccessStoryContentFileName(slug); + } + const safeFile = path.basename(nextFile); + fsSync.mkdirSync(AI_SUCCESS_CONTENT_DIR, { recursive: true }); + fsSync.writeFileSync(path.join(AI_SUCCESS_CONTENT_DIR, safeFile), bodyMd, "utf8"); + if (oldFileBase !== safeFile) { + try { + fsSync.unlinkSync(path.join(AI_SUCCESS_CONTENT_DIR, oldFileBase)); + } catch { + /* ignore */ + } + } + meta[idx] = { + ...cur, + title, + slug, + excerpt: body.excerpt !== undefined ? String(body.excerpt).trim() : cur.excerpt, + author: body.author !== undefined ? String(body.author).trim() : cur.author, + department: body.department !== undefined ? String(body.department).trim() : cur.department, + publishedAt: body.publishedAt !== undefined ? String(body.publishedAt).trim() : cur.publishedAt, + tags: + body.tags !== undefined + ? String(body.tags) + .split(",") + .map((t) => t.trim()) + .filter(Boolean) + : cur.tags, + contentFile: safeFile, + pdfUrl: body.pdfUrl !== undefined ? String(body.pdfUrl).trim() : cur.pdfUrl || "", + updatedAt: new Date().toISOString(), + }; + saveAiSuccessStoriesMeta(meta); + res.json({ ok: true, story: meta[idx] }); + } catch (err) { + res.status(500).json({ error: err?.message || "저장 실패" }); + } +}); + +app.delete("/api/ai-success-stories/:id", requireAdminApi, (req, res) => { + try { + const id = req.params.id; + const meta = loadAiSuccessStoriesMeta(); + const idx = meta.findIndex((m) => m.id === id); + if (idx === -1) { + res.status(404).json({ error: "해당 사례가 없습니다." }); + return; + } + const cur = meta[idx]; + const cf = cur.contentFile; + if (cf) { + try { + fsSync.unlinkSync(path.join(AI_SUCCESS_CONTENT_DIR, path.basename(cf))); + } catch { + /* ignore */ + } + } + meta.splice(idx, 1); + saveAiSuccessStoriesMeta(meta); + res.json({ ok: true }); + } catch (err) { + res.status(500).json({ error: err?.message || "삭제 실패" }); + } +}); + +app.use("/", pageRouter); + +const decodeXmlText = (value) => + value + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'"); + +const escapeHtml = (value) => + (value || "") + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + +const isYoutubeUrl = (url) => + /^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.be)\//i.test((url || "").trim()); + +/** http(s) 외부 링크 (뉴스 URL 등) */ +const isHttpUrl = (url) => { + const s = (url || "").trim(); + if (!s) return false; + try { + const u = new URL(s); + return u.protocol === "http:" || u.protocol === "https:"; + } catch { + return false; + } +}; + +const normalizeLecture = (lecture) => { + if (!lecture) return lecture; + if (!lecture.listSection) lecture.listSection = "learning"; + return lecture; +}; + +const toYoutubeEmbedUrl = (url) => { + const value = (url || "").trim(); + try { + const parsed = new URL(value); + if (parsed.hostname.includes("youtu.be")) { + const videoId = parsed.pathname.replace("/", ""); + return `https://www.youtube.com/embed/${videoId}`; + } + if (parsed.hostname.includes("youtube.com")) { + const videoId = parsed.searchParams.get("v"); + if (videoId) return `https://www.youtube.com/embed/${videoId}`; + if (parsed.pathname.startsWith("/embed/")) return value; + } + return value; + } catch { + return value; + } +}; + +const getYoutubeThumbnailUrl = (url) => { + if (!url || !isYoutubeUrl(url)) return null; + try { + const parsed = new URL((url || "").trim()); + let videoId = null; + if (parsed.hostname.includes("youtu.be")) { + videoId = parsed.pathname.replace(/^\/+/, "").split("/")[0]; + } else if (parsed.hostname.includes("youtube.com")) { + videoId = parsed.searchParams.get("v"); + if (!videoId && parsed.pathname.startsWith("/embed/")) { + videoId = (parsed.pathname.match(/\/embed\/([^/?]+)/) || [])[1]; + } + } + if (videoId) return `https://img.youtube.com/vi/${videoId}/hqdefault.jpg`; + } catch {} + return null; +}; + +app.locals.getYoutubeThumbnailUrl = getYoutubeThumbnailUrl; + +const mapRowToLecture = (row) => + normalizeLecture({ + id: row.id, + type: row.type, + title: row.title, + description: row.description || "", + tags: Array.isArray(row.tags) ? row.tags : [], + youtubeUrl: row.youtube_url || null, + fileName: row.file_name || null, + originalName: row.original_name || null, + previewTitle: row.preview_title || null, + slideCount: typeof row.slide_count === "number" ? row.slide_count : 0, + thumbnailUrl: row.thumbnail_url || null, + thumbnailStatus: row.thumbnail_status || "pending", + thumbnailRetryCount: typeof row.thumbnail_retry_count === "number" ? row.thumbnail_retry_count : 0, + thumbnailError: row.thumbnail_error || null, + thumbnailUpdatedAt: row.thumbnail_updated_at ? new Date(row.thumbnail_updated_at).toISOString() : null, + createdAt: row.created_at ? new Date(row.created_at).toISOString() : new Date().toISOString(), + listSection: row.list_section || "learning", + newsUrl: row.news_url || null, + }); + +const readLectureDb = async () => { + if (pgPool) { + try { + const res = await pgPool.query( + "SELECT id, type, title, description, tags, youtube_url, file_name, original_name, preview_title, slide_count, thumbnail_url, thumbnail_status, thumbnail_retry_count, thumbnail_error, thumbnail_updated_at, created_at, list_section, news_url FROM lectures ORDER BY created_at DESC" + ); + return (res.rows || []).map(mapRowToLecture); + } catch (err) { + console.error("readLectureDb from PostgreSQL failed:", err.message); + throw err; + } + } + try { + const raw = await fs.readFile(LECTURE_DB_PATH, "utf-8"); + const parsed = JSON.parse(raw); + const list = Array.isArray(parsed) ? parsed : []; + return list.map((item) => normalizeLecture({ ...item })); + } catch { + return []; + } +}; + +const writeLectureDb = async (lectures) => { + if (pgPool) { + await syncLecturesToPostgres(lectures); + return; + } + await fs.writeFile(LECTURE_DB_PATH, JSON.stringify(lectures, null, 2), "utf-8"); +}; + +/** link·news: og:image URL을 thumbnailUrl(절대 URL)에 저장 */ +const applyLinkOgPreviewToLecture = async (lectureId, pageUrl) => { + const img = await fetchOpenGraphImageUrl(pageUrl); + const lectures = await readLectureDb(); + const lec = lectures.find((l) => l.id === lectureId); + if (!lec || (lec.type !== "link" && lec.type !== "news")) return; + lec.thumbnailUrl = img || null; + lec.thumbnailStatus = img ? "ready" : "failed"; + lec.thumbnailError = img ? null : "og:image 없음"; + lec.thumbnailUpdatedAt = new Date().toISOString(); + if (typeof lec.thumbnailRetryCount !== "number") lec.thumbnailRetryCount = 0; + await writeLectureDb(lectures); +}; + +const scheduleLinkOgPreviewFetch = (lectureId, pageUrl) => { + setImmediate(() => { + applyLinkOgPreviewToLecture(lectureId, pageUrl).catch((e) => + console.error("[link-preview]", lectureId, e?.message || e) + ); + }); +}; + +/** 목록에 표시 중인 pending 링크에 대해 서버당 1회 OG fetch 예약(기존 등록 건 보정) */ +const linkOgPreviewScheduledIds = new Set(); + +const parseTags = (value) => + (value || "") + .split(",") + .map((tag) => tag.trim()) + .filter(Boolean) + .filter((tag, idx, arr) => arr.indexOf(tag) === idx); + +const LECTURE_CATEGORIES = ["AX 사고 전환", "AI 툴 활용", "AI Agent", "바이브 코딩"]; + +const mergeTagsWithCategory = (tagsStr, category) => { + const tags = parseTags(tagsStr); + const cat = (category || "").toString().trim(); + if (cat && LECTURE_CATEGORIES.includes(cat) && !tags.includes(cat)) { + tags.push(cat); + } + return tags; +}; + +const parsePptxSlides = async (filePath) => { + const buffer = await fs.readFile(filePath); + const zip = await JSZip.loadAsync(buffer); + const slideFiles = Object.keys(zip.files) + .filter((name) => /^ppt\/slides\/slide\d+\.xml$/.test(name)) + .sort((a, b) => { + const aNum = Number((a.match(/slide(\d+)\.xml/) || [])[1] || 0); + const bNum = Number((b.match(/slide(\d+)\.xml/) || [])[1] || 0); + return aNum - bNum; + }); + + const slides = []; + for (const slideFile of slideFiles) { + const xml = await zip.files[slideFile].async("string"); + const texts = []; + const matcher = xml.matchAll(/]*>([\s\S]*?)<\/a:t>/g); + for (const matched of matcher) { + const plain = decodeXmlText(matched[1] || "").trim(); + if (plain) texts.push(plain); + } + slides.push({ + title: texts[0] || "", + lines: texts, + }); + } + return slides; +}; + +const getPptxMeta = async (filePath) => { + const slides = await parsePptxSlides(filePath); + const first = slides[0] || { title: "" }; + return { + slideCount: slides.length, + previewTitle: first.title || "", + }; +}; + +const getPdfMeta = async (filePath) => { + try { + const { stdout } = await execFileAsync("pdfinfo", [filePath], { timeout: 10000 }); + const match = (stdout || "").match(/Pages:\s*(\d+)/); + const slideCount = match ? Math.max(1, parseInt(match[1], 10)) : 0; + return { slideCount, previewTitle: "" }; + } catch { + return { slideCount: 0, previewTitle: "" }; + } +}; + +const parsePdfSlides = async (filePath) => { + const meta = await getPdfMeta(filePath); + return Array.from({ length: meta.slideCount }, (_, i) => ({ + title: "", + lines: [], + })); +}; + +const tryExec = async (command, args, timeout = 20000) => { + try { + await execFileAsync(command, args, { + timeout, + windowsHide: true, + }); + return true; + } catch (err) { + return false; + } +}; + +const tryExecWithLog = async (command, args, timeout, logContext) => { + try { + await execFileAsync(command, args, { + timeout, + windowsHide: true, + }); + return { ok: true }; + } catch (err) { + console.error(`[slide-images] ${logContext}: ${command} failed:`, err.message || err); + return { ok: false, error: err.message }; + } +}; + +const safeRemove = async (targetPath) => { + try { + await fs.rm(targetPath, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors. + } +}; + +const generateWithQuickLook = async (filePath, targetKey) => { + if (process.platform !== "darwin") return null; + const qlmanagePath = "/usr/bin/qlmanage"; + if (!fsSync.existsSync(qlmanagePath)) return null; + + const before = new Set(await fs.readdir(THUMBNAIL_DIR)); + const ok = await tryExec(qlmanagePath, ["-t", "-s", String(THUMBNAIL_WIDTH), "-o", THUMBNAIL_DIR, filePath]); + if (!ok) return null; + + const after = await fs.readdir(THUMBNAIL_DIR); + const createdCandidates = after.filter((name) => !before.has(name) && /\.(png|jpg|jpeg)$/i.test(name)); + const base = path.basename(filePath); + const matched = createdCandidates.find((name) => name.includes(base)) || createdCandidates[0]; + if (!matched) return null; + + const sourcePath = path.join(THUMBNAIL_DIR, matched); + const ext = path.extname(matched) || ".png"; + const finalName = `${targetKey}${ext.toLowerCase()}`; + const finalPath = path.join(THUMBNAIL_DIR, finalName); + + try { + await fs.rename(sourcePath, finalPath); + } catch { + return null; + } + return `/uploads/thumbnails/${finalName}`; +}; + +const generateWithLibreOffice = async (filePath, targetKey) => { + const tmpWorkDir = path.join(TMP_DIR, `thumb-${targetKey}-${Date.now()}`); + await fs.mkdir(tmpWorkDir, { recursive: true }); + + const sofficeCandidates = ["soffice", "libreoffice"]; + let converted = false; + for (const cmd of sofficeCandidates) { + // soffice --headless --convert-to pdf --outdir + const ok = await tryExec(cmd, ["--headless", "--convert-to", "pdf", "--outdir", tmpWorkDir, filePath], 60000); + if (ok) { + converted = true; + break; + } + } + if (!converted) { + await safeRemove(tmpWorkDir); + return null; + } + + const pdfPath = path.join(tmpWorkDir, `${path.basename(filePath, path.extname(filePath))}.pdf`); + if (!fsSync.existsSync(pdfPath)) { + await safeRemove(tmpWorkDir); + return null; + } + + const outPrefix = path.join(tmpWorkDir, "preview"); + const okPpm = await tryExec("pdftoppm", ["-f", "1", "-singlefile", "-png", pdfPath, outPrefix], 30000); + if (!okPpm) { + await safeRemove(tmpWorkDir); + return null; + } + + const generatedPng = `${outPrefix}.png`; + if (!fsSync.existsSync(generatedPng)) { + await safeRemove(tmpWorkDir); + return null; + } + + const finalName = `${targetKey}.png`; + const finalPath = path.join(THUMBNAIL_DIR, finalName); + try { + await fs.copyFile(generatedPng, finalPath); + } catch { + await safeRemove(tmpWorkDir); + return null; + } + await safeRemove(tmpWorkDir); + return `/uploads/thumbnails/${finalName}`; +}; + +const generateWithPdfDirect = async (filePath, targetKey) => { + const ext = path.extname(filePath).toLowerCase(); + if (ext !== ".pdf") return null; + + const tmpWorkDir = path.join(TMP_DIR, `thumb-pdf-${targetKey}-${Date.now()}`); + await fs.mkdir(tmpWorkDir, { recursive: true }); + + const outPrefix = path.join(tmpWorkDir, "preview"); + const okPpm = await tryExec("pdftoppm", ["-f", "1", "-singlefile", "-png", "-r", "150", filePath, outPrefix], 30000); + if (!okPpm) { + await safeRemove(tmpWorkDir); + return null; + } + + const generatedPng = `${outPrefix}.png`; + if (!fsSync.existsSync(generatedPng)) { + await safeRemove(tmpWorkDir); + return null; + } + + const finalName = `${targetKey}.png`; + const finalPath = path.join(THUMBNAIL_DIR, finalName); + try { + await fs.copyFile(generatedPng, finalPath); + } catch { + await safeRemove(tmpWorkDir); + return null; + } + await safeRemove(tmpWorkDir); + return `/uploads/thumbnails/${finalName}`; +}; + +const generatePptThumbnail = async (filePath, targetKey) => { + if (!ENABLE_PPT_THUMBNAIL) return null; + + await fs.mkdir(THUMBNAIL_DIR, { recursive: true }); + const pdfDirect = await generateWithPdfDirect(filePath, targetKey); + if (pdfDirect) return pdfDirect; + const quickLook = await generateWithQuickLook(filePath, targetKey); + if (quickLook) return quickLook; + const libre = await generateWithLibreOffice(filePath, targetKey); + if (libre) return libre; + return null; +}; + +const generateSlideImages = async (filePath, lectureId) => { + const outDir = path.join(SLIDES_DIR, lectureId); + await fs.mkdir(outDir, { recursive: true }); + + const ext = path.extname(filePath).toLowerCase(); + const tmpWorkDir = path.join(TMP_DIR, `slides-${lectureId}-${Date.now()}`); + await fs.mkdir(tmpWorkDir, { recursive: true }); + + let pdfPath = filePath; + if (ext === ".pptx") { + const libreOfficeCandidates = [ + "soffice", + "libreoffice", + ...(process.platform === "darwin" + ? ["/Applications/LibreOffice.app/Contents/MacOS/soffice"] + : []), + ]; + let converted = false; + for (const cmd of libreOfficeCandidates) { + const { ok } = await tryExecWithLog( + cmd, + ["--headless", "--convert-to", "pdf", "--outdir", tmpWorkDir, filePath], + 90000, + `lecture ${lectureId} PPTX→PDF` + ); + if (ok) { + converted = true; + break; + } + } + if (!converted) { + console.error(`[slide-images] lecture ${lectureId}: PPTX 변환 실패. LibreOffice(soffice/libreoffice) 설치 필요. brew install --cask libreoffice`); + await safeRemove(tmpWorkDir); + return null; + } + + pdfPath = path.join(tmpWorkDir, `${path.basename(filePath, path.extname(filePath))}.pdf`); + if (!fsSync.existsSync(pdfPath)) { + console.error(`[slide-images] lecture ${lectureId}: 변환된 PDF 파일을 찾을 수 없음: ${pdfPath}`); + await safeRemove(tmpWorkDir); + return null; + } + } else if (ext !== ".pdf") { + await safeRemove(tmpWorkDir); + return null; + } + + const outPrefix = path.join(tmpWorkDir, "slide"); + const { ok: okPpm } = await tryExecWithLog( + "pdftoppm", + ["-png", "-r", "150", pdfPath, outPrefix], + 60000, + `lecture ${lectureId} pdftoppm` + ); + if (!okPpm) { + await safeRemove(tmpWorkDir); + return null; + } + + const slidePngs = fsSync.readdirSync(tmpWorkDir) + .filter((n) => /^slide-\d+\.png$/.test(n)) + .sort((a, b) => { + const na = Number((a.match(/slide-(\d+)\.png/) || [])[1] || 0); + const nb = Number((b.match(/slide-(\d+)\.png/) || [])[1] || 0); + return na - nb; + }); + + if (slidePngs.length === 0) { + console.error(`[slide-images] lecture ${lectureId}: pdftoppm 출력 파일 없음. tmpWorkDir=${tmpWorkDir}`); + } + + const urls = []; + for (let i = 0; i < slidePngs.length; i++) { + const srcPath = path.join(tmpWorkDir, slidePngs[i]); + const destName = `slide-${i + 1}.png`; + const destPath = path.join(outDir, destName); + try { + await fs.copyFile(srcPath, destPath); + urls.push(`/uploads/slides/${lectureId}/${destName}`); + } catch (err) { + console.error(`[slide-images] lecture ${lectureId}: 파일 복사 실패 ${slidePngs[i]}:`, err.message); + break; + } + } + + await safeRemove(tmpWorkDir); + if (urls.length > 0) { + console.log(`[slide-images] lecture ${lectureId}: ${urls.length}장 슬라이드 이미지 생성 완료`); + } + return urls.length > 0 ? urls : null; +}; + +const getSlideImageUrls = (lectureId) => { + const dir = path.join(SLIDES_DIR, lectureId); + if (!fsSync.existsSync(dir)) return []; + const files = fsSync.readdirSync(dir) + .filter((n) => /^slide-\d+\.png$/.test(n)) + .sort((a, b) => { + const na = Number((a.match(/slide-(\d+)\.png/) || [])[1] || 0); + const nb = Number((b.match(/slide-(\d+)\.png/) || [])[1] || 0); + return na - nb; + }); + return files.map((f) => `/uploads/slides/${lectureId}/${f}`); +}; + +const AI_SUCCESS_SLIDES_SUBDIR = "ai-success-slides"; + +function safeAiSuccessSlug(slug) { + return ( + String(slug || "") + .replace(/[^a-zA-Z0-9\-_]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") || "story" + ); +} + +/** `/public/.../file.pdf` 또는 절대 URL의 `/public/...` 경로 → 절대 경로 (public 하위 PDF만) */ +function resolvePublicPdfPath(pdfUrl) { + const raw = (pdfUrl || "").trim(); + if (!raw) return null; + let pathname = raw; + if (/^https?:\/\//i.test(raw)) { + try { + pathname = new URL(raw).pathname; + } catch { + return null; + } + } else if (!raw.startsWith("/")) { + if (raw.startsWith("public/")) pathname = `/${raw}`; + else return null; + } + if (!pathname.startsWith("/public/")) return null; + let rel; + try { + rel = decodeURIComponent(pathname).replace(/^\/+/, ""); + } catch { + rel = pathname.replace(/^\/+/, ""); + } + const abs = path.join(ROOT_DIR, rel); + const normalized = path.normalize(abs); + const publicRoot = path.join(ROOT_DIR, "public"); + if (!normalized.startsWith(publicRoot) || !fsSync.existsSync(normalized)) return null; + if (path.extname(normalized).toLowerCase() !== ".pdf") return null; + return normalized; +} + +function getAiSuccessSlideImageUrls(slug) { + const safeSlug = safeAiSuccessSlug(slug); + const dir = path.join(UPLOAD_DIR, AI_SUCCESS_SLIDES_SUBDIR, safeSlug); + if (!fsSync.existsSync(dir)) return []; + const files = fsSync + .readdirSync(dir) + .filter((n) => /^slide-\d+\.png$/.test(n)) + .sort((a, b) => { + const na = Number((a.match(/slide-(\d+)\.png/) || [])[1] || 0); + const nb = Number((b.match(/slide-(\d+)\.png/) || [])[1] || 0); + return na - nb; + }); + return files.map((f) => `/uploads/${AI_SUCCESS_SLIDES_SUBDIR}/${safeSlug}/${f}`); +} + +async function generateAiSuccessPdfSlides(pdfPath, slug) { + const safeSlug = safeAiSuccessSlug(slug); + const outDir = path.join(UPLOAD_DIR, AI_SUCCESS_SLIDES_SUBDIR, safeSlug); + await fs.mkdir(outDir, { recursive: true }); + const tmpWorkDir = path.join(TMP_DIR, `ai-success-${safeSlug}-${Date.now()}`); + await fs.mkdir(tmpWorkDir, { recursive: true }); + const outPrefix = path.join(tmpWorkDir, "slide"); + const { ok: okPpm } = await tryExecWithLog( + "pdftoppm", + ["-png", "-r", "150", pdfPath, outPrefix], + 60000, + `ai-success ${safeSlug} pdftoppm` + ); + if (!okPpm) { + await safeRemove(tmpWorkDir); + return null; + } + const slidePngs = fsSync + .readdirSync(tmpWorkDir) + .filter((n) => /^slide-\d+\.png$/.test(n)) + .sort((a, b) => { + const na = Number((a.match(/slide-(\d+)\.png/) || [])[1] || 0); + const nb = Number((b.match(/slide-(\d+)\.png/) || [])[1] || 0); + return na - nb; + }); + const urls = []; + for (let i = 0; i < slidePngs.length; i++) { + const srcPath = path.join(tmpWorkDir, slidePngs[i]); + const destName = `slide-${i + 1}.png`; + const destPath = path.join(outDir, destName); + try { + await fs.copyFile(srcPath, destPath); + urls.push(`/uploads/${AI_SUCCESS_SLIDES_SUBDIR}/${safeSlug}/${destName}`); + } catch (err) { + console.error(`[ai-success-slides] 복사 실패 ${slidePngs[i]}:`, err?.message || err); + break; + } + } + await safeRemove(tmpWorkDir); + if (urls.length > 0) { + console.log(`[ai-success-slides] ${safeSlug}: ${urls.length}장 생성`); + } + return urls.length > 0 ? urls : null; +} + +async function ensureAiSuccessStorySlides(slug, pdfUrl) { + const pdfPath = resolvePublicPdfPath(pdfUrl); + if (!pdfPath) { + return { urls: [], slidesError: true }; + } + const existing = getAiSuccessSlideImageUrls(slug); + if (existing.length > 0) { + return { urls: existing, slidesError: false }; + } + const generated = await generateAiSuccessPdfSlides(pdfPath, slug); + if (!generated || generated.length === 0) { + return { urls: [], slidesError: true }; + } + return { urls: generated, slidesError: false }; +} + +const ensurePptThumbnailFields = (lecture) => { + if (!lecture || lecture.type !== "ppt") return false; + let changed = false; + if (!lecture.thumbnailStatus) { + lecture.thumbnailStatus = lecture.thumbnailUrl ? "ready" : "pending"; + changed = true; + } + if (typeof lecture.thumbnailRetryCount !== "number") { + lecture.thumbnailRetryCount = 0; + changed = true; + } + if (!lecture.thumbnailError) { + lecture.thumbnailError = null; + changed = true; + } + if (!lecture.thumbnailUpdatedAt) { + lecture.thumbnailUpdatedAt = lecture.createdAt || new Date().toISOString(); + changed = true; + } + return changed; +}; + +const enqueueThumbnailJob = (lectureId, options = {}) => { + const force = options.force === true; + const reason = options.reason || "manual"; + const persist = options.persist !== false; + if (!lectureId) return false; + if (queuedLectureIds.has(lectureId)) return false; + thumbnailQueue.push({ lectureId, force, reason, enqueuedAt: Date.now() }); + queuedLectureIds.add(lectureId); + appendThumbnailEvent({ + type: "enqueue", + lectureId, + reason, + force, + queueSizeAfter: thumbnailQueue.length, + }).catch(() => { + // Ignore event logging failure. + }); + if (persist) { + writeThumbnailJobDb().catch(() => { + // Ignore queue persistence failure. + }); + } + processThumbnailQueue(); + return true; +}; + +const processThumbnailQueue = async () => { + if (thumbnailWorkerRunning) return; + thumbnailWorkerRunning = true; + while (thumbnailQueue.length > 0) { + const job = thumbnailQueue.shift(); + queuedLectureIds.delete(job.lectureId); + await writeThumbnailJobDb().catch(() => { + // Ignore queue persistence failure. + }); + try { + const startedAt = Date.now(); + const lectures = await readLectureDb(); + const lecture = lectures.find((item) => item.id === job.lectureId); + if (!lecture || lecture.type !== "ppt" || !lecture.fileName) continue; + + ensurePptThumbnailFields(lecture); + if (!job.force && lecture.thumbnailStatus === "ready" && lecture.thumbnailUrl) { + continue; + } + + lecture.thumbnailStatus = "processing"; + lecture.thumbnailError = null; + lecture.thumbnailUpdatedAt = new Date().toISOString(); + await writeLectureDb(lectures); + await appendThumbnailEvent({ + type: "start", + lectureId: lecture.id, + lectureTitle: lecture.title, + reason: job.reason, + force: job.force, + queueSizeAfter: thumbnailQueue.length, + }).catch(() => { + // Ignore event logging failure. + }); + + const filePath = path.join(UPLOAD_DIR, lecture.fileName); + const newThumb = await generatePptThumbnail(filePath, lecture.id); + + if (newThumb) { + lecture.thumbnailUrl = newThumb; + lecture.thumbnailStatus = "ready"; + lecture.thumbnailError = null; + } else { + lecture.thumbnailStatus = "failed"; + lecture.thumbnailError = "썸네일 생성 도구 실행 실패 또는 미설치"; + lecture.thumbnailRetryCount = (lecture.thumbnailRetryCount || 0) + 1; + } + lecture.thumbnailUpdatedAt = new Date().toISOString(); + await writeLectureDb(lectures); + await appendThumbnailEvent({ + type: lecture.thumbnailStatus === "ready" ? "success" : "failed", + lectureId: lecture.id, + lectureTitle: lecture.title, + reason: job.reason, + force: job.force, + retryCount: lecture.thumbnailRetryCount || 0, + error: lecture.thumbnailError || null, + durationMs: Date.now() - startedAt, + }).catch(() => { + // Ignore event logging failure. + }); + + if ( + lecture.thumbnailStatus === "failed" && + lecture.thumbnailRetryCount <= THUMBNAIL_MAX_RETRY && + ENABLE_PPT_THUMBNAIL + ) { + setTimeout(() => { + enqueueThumbnailJob(lecture.id, { force: true, reason: "auto-retry" }); + }, THUMBNAIL_RETRY_DELAY_MS); + } + } catch (error) { + await appendThumbnailEvent({ + type: "worker-error", + lectureId: job.lectureId, + reason: job.reason, + force: job.force, + error: error?.message || "unknown worker error", + }).catch(() => { + // Ignore event logging failure. + }); + // Keep queue worker alive for next jobs. + } + } + thumbnailWorkerRunning = false; +}; + +const removeQueuedThumbnailJobs = async (lectureId) => { + if (!lectureId) return; + let changed = false; + for (let i = thumbnailQueue.length - 1; i >= 0; i -= 1) { + if (thumbnailQueue[i].lectureId === lectureId) { + thumbnailQueue.splice(i, 1); + changed = true; + } + } + if (changed) { + queuedLectureIds.delete(lectureId); + await writeThumbnailJobDb().catch(() => { + // Ignore queue persistence failure. + }); + } +}; + +const seedResourceLectures = async () => { + let lectures = await readLectureDb(); + if (lectures.length > 0) return; + + if (!fsSync.existsSync(RESOURCES_LECTURE_DIR)) return; + const files = await fs.readdir(RESOURCES_LECTURE_DIR); + const pptxFiles = files.filter((name) => path.extname(name).toLowerCase() === ".pptx"); + + for (const fileName of pptxFiles) { + const source = path.join(RESOURCES_LECTURE_DIR, fileName); + const targetName = `${Date.now()}-${uuidv4()}${path.extname(fileName)}`; + const target = path.join(UPLOAD_DIR, targetName); + await fs.copyFile(source, target); + lectures.push({ + id: uuidv4(), + type: "ppt", + listSection: "learning", + title: path.basename(fileName, path.extname(fileName)), + description: "초기 샘플 PPT 강의", + tags: ["샘플", "PPT"], + fileName: targetName, + originalName: fileName, + createdAt: new Date().toISOString(), + }); + const inserted = lectures[lectures.length - 1]; + const meta = await getPptxMeta(target); + inserted.previewTitle = meta.previewTitle; + inserted.slideCount = meta.slideCount; + inserted.thumbnailUrl = null; + inserted.thumbnailStatus = "pending"; + inserted.thumbnailRetryCount = 0; + inserted.thumbnailError = null; + inserted.thumbnailUpdatedAt = new Date().toISOString(); + enqueueThumbnailJob(inserted.id, { force: false, reason: "seed" }); + } + await writeLectureDb(lectures); +}; + +const ensureBootstrap = async () => { + await initPostgres(); + await thumbnailEventsStore + .migrateThumbnailEventsFromJson(pgPool, THUMBNAIL_EVENT_DB_PATH, THUMBNAIL_EVENT_KEEP) + .catch((e) => console.warn("thumbnail-events migration:", e?.message || e)); + await fs.mkdir(DATA_DIR, { recursive: true }); + await fs.mkdir(TMP_DIR, { recursive: true }); + await fs.mkdir(UPLOAD_DIR, { recursive: true }); + await fs.mkdir(THUMBNAIL_DIR, { recursive: true }); + if (!pgPool && !fsSync.existsSync(LECTURE_DB_PATH)) { + await writeLectureDb([]); + } + if (!pgPool && !fsSync.existsSync(AX_ASSIGNMENTS_DB_PATH)) { + await fs.writeFile(AX_ASSIGNMENTS_DB_PATH, "[]", "utf-8"); + } + if (!fsSync.existsSync(THUMBNAIL_JOB_DB_PATH)) { + await fs.writeFile(THUMBNAIL_JOB_DB_PATH, "[]", "utf-8"); + } + if (!pgPool && !fsSync.existsSync(THUMBNAIL_EVENT_DB_PATH)) { + await fs.writeFile(THUMBNAIL_EVENT_DB_PATH, "[]", "utf-8"); + } + await seedResourceLectures(); + + const lectures = await readLectureDb(); + let changed = false; + for (const lecture of lectures) { + if (lecture.type !== "ppt") continue; + if (ensurePptThumbnailFields(lecture)) changed = true; + if (lecture.thumbnailStatus === "processing") { + lecture.thumbnailStatus = "pending"; + lecture.thumbnailUpdatedAt = new Date().toISOString(); + changed = true; + } + if (lecture.thumbnailStatus === "pending") { + enqueueThumbnailJob(lecture.id, { force: false, reason: "bootstrap" }); + } + if ( + lecture.thumbnailStatus === "failed" && + lecture.thumbnailRetryCount < THUMBNAIL_MAX_RETRY && + ENABLE_PPT_THUMBNAIL + ) { + lecture.thumbnailStatus = "pending"; + lecture.thumbnailError = null; + lecture.thumbnailUpdatedAt = new Date().toISOString(); + changed = true; + enqueueThumbnailJob(lecture.id, { force: true, reason: "bootstrap-retry" }); + } + } + + const persistedJobs = await readThumbnailJobDb(); + for (const job of persistedJobs) { + if (!job?.lectureId) continue; + enqueueThumbnailJob(job.lectureId, { + force: job.force === true, + reason: job.reason || "restored", + persist: false, + }); + } + await writeThumbnailJobDb().catch(() => { + // Ignore queue persistence failure. + }); + + if (changed) await writeLectureDb(lectures); +}; + +function normalizeLectureSearchText(s) { + try { + return String(s || "") + .normalize("NFC") + .toLowerCase(); + } catch { + return String(s || "").toLowerCase(); + } +} + +/** "클로드" 검색 시 본문의 "Claude", "claude" 등과도 매칭되도록 보강 */ +function lectureSearchQueryVariants(qNorm) { + if (!qNorm) return [""]; + const v = new Set([qNorm]); + if (qNorm.includes("클로드")) v.add(qNorm.replace(/클로드/g, "claude")); + if (qNorm.includes("claude")) v.add(qNorm.replace(/claude/g, "클로드")); + return [...v]; +} + +/** + * 강의 검색어(q) 매칭: title, description, previewTitle, PPT/PDF는 originalName(확장자 제외). + * 한글 NFC 정규화 + 클로드/claude 상호 보강. + */ +function lectureMatchesSearchQuery(lecture, qNorm) { + if (!qNorm) return true; + const parts = [lecture.title || "", lecture.description || ""]; + const pt = (lecture.previewTitle || "").trim(); + if (pt && pt !== "제목 없음" && pt !== "미리보기 생성 실패") { + parts.push(pt); + } + if (lecture.type === "ppt" || lecture.type === "video") { + const orig = (lecture.originalName || "").replace(/\.[^.]+$/, ""); + if (orig) parts.push(orig); + } + if (lecture.type === "link" || lecture.type === "news") { + const u = (lecture.newsUrl || "").trim(); + if (u) parts.push(u); + } + const haystack = normalizeLectureSearchText(parts.join(" ")); + return lectureSearchQueryVariants(qNorm).some((variant) => haystack.includes(variant)); +} + +const buildLectureListContext = async (req, options = {}) => { + const { basePath = "/learning", forAdmin = false } = options; + const page = Math.max(Number(req.query.page) || 1, 1); + const q = (req.query.q || "").toString().trim(); + const type = (req.query.type || "all").toString(); + const tag = (req.query.tag || "").toString().trim(); + const category = (req.query.category || "").toString().trim(); + let tokenVal = req.query.token; + if (Array.isArray(tokenVal)) tokenVal = tokenVal[0]; + const token = (tokenVal != null ? String(tokenVal) : "").trim(); + const retryQueued = Math.max(Number(req.query.retryQueued) || 0, 0); + const eventsCleared = req.query.eventsCleared === "1" || req.query.eventsCleared === "true"; + + let lectures = await readLectureDb(); + + let hasMutated = false; + for (const lecture of lectures) { + if (!Array.isArray(lecture.tags)) { + lecture.tags = []; + hasMutated = true; + } + const needsMeta = + lecture.type === "ppt" && + (lecture.previewTitle === "미리보기 생성 실패" || + !lecture.previewTitle || + typeof lecture.slideCount !== "number"); + if (needsMeta) { + try { + const filePath = path.join(UPLOAD_DIR, lecture.fileName); + const ext = path.extname(lecture.fileName || "").toLowerCase(); + const meta = ext === ".pdf" ? await getPdfMeta(filePath) : await getPptxMeta(filePath); + lecture.previewTitle = meta.previewTitle || (ext === ".pdf" ? "PDF 문서" : ""); + lecture.slideCount = meta.slideCount; + hasMutated = true; + } catch { + lecture.previewTitle = lecture.previewTitle || "미리보기 생성 실패"; + lecture.slideCount = typeof lecture.slideCount === "number" ? lecture.slideCount : 0; + hasMutated = true; + } + } + if (lecture.type === "ppt") { + if (ensurePptThumbnailFields(lecture)) hasMutated = true; + if (lecture.thumbnailStatus === "pending" && lecture.fileName) { + enqueueThumbnailJob(lecture.id, { force: false, reason: "list-view" }); + } + } + } + if (hasMutated) await writeLectureDb(lectures); + + /** 학습센터 단일 목록: learning + (구)뉴스 섹션으로 등록된 항목 포함 */ + const inSection = (lec) => lec.listSection === "learning" || lec.listSection === "news"; + + const sectionLectures = lectures.filter(inSection); + const availableTags = [...new Set(sectionLectures.flatMap((lecture) => lecture.tags || []))].sort((a, b) => + a.localeCompare(b, "ko-KR") + ); + + const ordered = [...sectionLectures].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); + const qNorm = q ? normalizeLectureSearchText(q) : ""; + const filtered = ordered.filter((lecture) => { + if (type !== "all" && lecture.type !== type) return false; + if (tag && !(lecture.tags || []).includes(tag)) return false; + if (category && !(lecture.tags || []).includes(category)) return false; + if (!q) return true; + return lectureMatchesSearchQuery(lecture, qNorm); + }); + + const totalCount = filtered.length; + const totalPages = Math.max(Math.ceil(totalCount / PAGE_SIZE), 1); + const currentPage = Math.min(page, totalPages); + const start = (currentPage - 1) * PAGE_SIZE; + const pageItems = filtered.slice(start, start + PAGE_SIZE); + + for (const lec of pageItems) { + const st = lec.thumbnailStatus || "pending"; + if ( + (lec.type === "link" || lec.type === "news") && + lec.newsUrl && + !lec.thumbnailUrl && + st !== "failed" && + st !== "ready" && + !linkOgPreviewScheduledIds.has(lec.id) + ) { + linkOgPreviewScheduledIds.add(lec.id); + scheduleLinkOgPreviewFetch(lec.id, lec.newsUrl); + } + } + + const isValidToken = + forAdmin && ADMIN_TOKEN.length > 0 && token.length > 0 && token === ADMIN_TOKEN; + const tokenForQuery = isValidToken ? token : ""; + + const makeQueryForPath = (nextPage) => { + const params = new URLSearchParams(); + if (q) params.set("q", q); + if (type && type !== "all") params.set("type", type); + if (tag) params.set("tag", tag); + if (category) params.set("category", category); + if (forAdmin && tokenForQuery) params.set("token", tokenForQuery); + if (nextPage && nextPage > 1) params.set("page", String(nextPage)); + return params.toString(); + }; + + const returnQuery = makeQueryForPath(currentPage); + const prevQuery = makeQueryForPath(currentPage - 1); + const nextQuery = makeQueryForPath(currentPage + 1); + const paginationData = { + page: currentPage, + totalPages, + totalCount, + hasPrev: currentPage > 1, + hasNext: currentPage < totalPages, + prevQuery, + nextQuery, + pages: Array.from({ length: totalPages }, (_, idx) => { + const p = idx + 1; + return { page: p, query: makeQueryForPath(p), active: p === currentPage }; + }), + }; + + const ctx = { + lectures: pageItems, + filters: { q: q || "", type: type || "all", tag: tag || "", category: category || "" }, + availableTags, + pagination: paginationData, + returnQuery, + viewerBasePath: options.viewerBasePath || "/learning", + learningApiPath: options.learningApiPath || "/api/learning/lectures", + adminRegisterHref: options.adminRegisterHref || "/admin", + adminBasePath: options.adminBasePath || "/admin", + navActiveMenu: options.navActiveMenu || "learning", + pageTitle: options.pageTitle || "학습센터", + heroTitle: options.heroTitle || "최신 컨텐츠로 학습하고, 바로 업무에 적용하세요.", + heroDesc: + options.heroDesc || + "유튜브·PPT·웹 링크를 등록한 뒤, 목록에서 클릭하여 강의를 시청하거나 외부 자료를 열 수 있습니다.", + sectionListHeading: options.sectionListHeading || "등록된 강의", + filterPanelTitle: options.filterPanelTitle, + adminPageTitle: options.adminPageTitle || "학습센터 관리", + }; + + if (forAdmin) { + ctx.adminMode = isValidToken; + ctx.adminRequested = !!token; + ctx.tokenRaw = isValidToken ? token : ""; + ctx.retryQueued = retryQueued; + ctx.eventsCleared = eventsCleared; + ctx.recentEvents = [...(await readThumbnailEventDb())].reverse().slice(0, 12); + const events = await readThumbnailEventDb(); + ctx.failureReasons = Object.values( + events.reduce((acc, evt) => { + if (evt.type !== "failed" || !evt.error) return acc; + const key = evt.error; + if (!acc[key]) acc[key] = { reason: key, count: 0, latestAt: evt.at }; + acc[key].count += 1; + if (new Date(evt.at).getTime() > new Date(acc[key].latestAt).getTime()) acc[key].latestAt = evt.at; + return acc; + }, {}) + ).sort((a, b) => b.count - a.count); + ctx.thumbnailStatusSummary = sectionLectures.reduce( + (acc, l) => { + if (l.type !== "ppt") return acc; + const s = l.thumbnailStatus || "pending"; + if (s === "ready") acc.ready += 1; + else if (s === "processing") acc.processing += 1; + else if (s === "failed") acc.failed += 1; + else acc.pending += 1; + return acc; + }, + { ready: 0, processing: 0, failed: 0, pending: 0 } + ); + ctx.thumbnailQueueInfo = { pending: thumbnailQueue.length, working: thumbnailWorkerRunning, maxRetry: THUMBNAIL_MAX_RETRY }; + } + + ctx.opsUserEmail = !!(req.res && req.res.locals && req.res.locals.opsUserEmail); + + return ctx; +}; + +app.post("/api/admin/validate-token", (req, res) => { + const token = (req.body?.token ?? "").toString().trim(); + const valid = + ADMIN_TOKEN.length > 0 && + token.length > 0 && + token === ADMIN_TOKEN; + res.json({ valid: !!valid }); +}); + +app.get("/", (req, res) => res.redirect("/learning")); + +app.get("/learning", async (req, res) => { + const ctx = await buildLectureListContext(req, { basePath: "/learning", forAdmin: false }); + res.render("learning-viewer", ctx); +}); + +/** 구 URL 호환 */ +app.get("/newsletter", (req, res) => { + res.redirect(302, "/learning"); +}); +app.get("/newsletter/admin", (req, res) => { + const q = req.url.includes("?") ? req.url.slice(req.url.indexOf("?")) : ""; + res.redirect(302, "/admin" + q); +}); + +app.get("/api/learning/lectures", async (req, res) => { + try { + const ctx = await buildLectureListContext(req, { basePath: "/learning", forAdmin: false }); + res.render("learning-lectures-partial", ctx, (err, html) => { + if (err) return res.status(500).json({ error: err?.message || "렌더링 실패" }); + res.json({ + html: html || "", + hasNext: ctx.pagination?.hasNext || false, + nextPage: (ctx.pagination?.page || 1) + 1, + totalCount: ctx.pagination?.totalCount ?? 0, + }); + }); + } catch (err) { + res.status(500).json({ error: err?.message || "요청 처리 실패" }); + } +}); + +app.get("/admin/logout", (req, res) => { + res.clearCookie(ADMIN_COOKIE_NAME, { path: "/" }); + res.redirect("/learning"); +}); + +/** 관리자: OPS 이메일 인증 로그인 사용자 목록(이메일·최근 접속일) */ +app.get("/admin/users", async (req, res) => { + const token = (req.query.token != null ? String(req.query.token) : "").trim(); + const cookieToken = (req.cookies?.[ADMIN_COOKIE_NAME] || "").trim(); + const isValidUrlToken = + ADMIN_TOKEN.length > 0 && token.length > 0 && token === ADMIN_TOKEN; + const isValidCookie = + ADMIN_TOKEN.length > 0 && cookieToken.length > 0 && cookieToken === ADMIN_TOKEN; + + if (isValidUrlToken) { + res.cookie(ADMIN_COOKIE_NAME, token, { + httpOnly: true, + maxAge: ADMIN_COOKIE_MAX_AGE, + path: "/", + }); + } else if (req.cookies?.[ADMIN_COOKIE_NAME] && !isValidCookie) { + res.clearCookie(ADMIN_COOKIE_NAME, { path: "/" }); + } + + const allowed = isValidUrlToken || isValidCookie; + if (!allowed) { + return res.redirect("/learning"); + } + + let users = []; + let dbError = null; + const pgConnected = ENABLE_POSTGRES && !!pgPool; + try { + if (pgPool) { + const r = await pgPool.query( + `SELECT email, last_login_at, first_seen_at, login_count + FROM ops_email_users + ORDER BY last_login_at DESC NULLS LAST` + ); + users = (r.rows || []).map((row) => ({ + email: row.email, + lastLoginAt: row.last_login_at, + firstSeenAt: row.first_seen_at, + loginCount: row.login_count, + })); + } + } catch (err) { + console.error("[admin/users]", err); + dbError = err?.message || "조회 실패"; + } + + res.render("admin-users", { + adminMode: true, + activeMenu: "admin-users", + users, + dbError, + pgConnected, + }); +}); + +app.get("/admin", async (req, res) => { + const ctx = await buildLectureListContext(req, { + basePath: "/admin", + forAdmin: true, + adminBasePath: "/admin", + viewerBasePath: "/learning", + navActiveMenu: "learning", + }); + const token = (req.query.token != null ? String(req.query.token) : "").trim(); + const cookieToken = (req.cookies?.[ADMIN_COOKIE_NAME] || "").trim(); + const isValidUrlToken = + ADMIN_TOKEN.length > 0 && token.length > 0 && token === ADMIN_TOKEN; + const isValidCookie = + ADMIN_TOKEN.length > 0 && cookieToken.length > 0 && cookieToken === ADMIN_TOKEN; + + if (isValidUrlToken) { + res.cookie(ADMIN_COOKIE_NAME, token, { + httpOnly: true, + maxAge: ADMIN_COOKIE_MAX_AGE, + path: "/", + }); + } else if (isValidCookie) { + ctx.adminMode = true; + ctx.tokenRaw = cookieToken; + } else if (req.cookies?.[ADMIN_COOKIE_NAME]) { + res.clearCookie(ADMIN_COOKIE_NAME, { path: "/" }); + res.locals.adminMode = false; + } + res.render("learning-admin", ctx); +}); + +app.post("/lectures/youtube", async (req, res) => { + const token = (req.body.token || "").toString().trim(); + if (token !== ADMIN_TOKEN) { + res.status(403).send("관리자 토큰이 필요합니다."); + return; + } + const { title, description, youtubeUrl, tags, category } = req.body; + if (!title?.trim() || !youtubeUrl?.trim()) { + res.status(400).send("제목과 유튜브 링크는 필수입니다."); + return; + } + if (!isYoutubeUrl(youtubeUrl)) { + res.status(400).send("유효한 유튜브 링크를 입력해주세요."); + return; + } + + const lectures = await readLectureDb(); + lectures.push({ + id: uuidv4(), + type: "youtube", + listSection: "learning", + title: title.trim(), + description: (description || "").trim(), + tags: mergeTagsWithCategory(tags, category), + youtubeUrl: youtubeUrl.trim(), + createdAt: new Date().toISOString(), + }); + await writeLectureDb(lectures); + const returnTo = (req.body.returnTo || "").toString().trim(); + res.redirect(returnTo.startsWith("/") ? returnTo : "/admin"); +}); + +app.post("/lectures/ppt", upload.single("pptFile"), async (req, res) => { + const token = (req.body.token || "").toString().trim(); + if (token !== ADMIN_TOKEN) { + res.status(403).send("관리자 토큰이 필요합니다."); + return; + } + const { title, description, tags, category } = req.body; + if (!req.file) { + res.status(400).send("PDF 또는 PPT 파일이 필요합니다."); + return; + } + if (!title?.trim()) { + res.status(400).send("제목은 필수입니다."); + return; + } + + const filePath = path.join(UPLOAD_DIR, req.file.filename); + const ext = path.extname(req.file.filename).toLowerCase(); + let pptMeta = { previewTitle: "", slideCount: 0 }; + try { + if (ext === ".pdf") { + pptMeta = await getPdfMeta(filePath); + } else { + pptMeta = await getPptxMeta(filePath); + } + } catch { + // Keep defaults; upload still succeeds even if metadata parsing fails. + } + + const lectures = await readLectureDb(); + const lectureId = uuidv4(); + lectures.push({ + id: lectureId, + type: "ppt", + listSection: "learning", + title: title.trim(), + description: (description || "").trim(), + tags: mergeTagsWithCategory(tags, category), + fileName: req.file.filename, + originalName: req.file.originalname, + previewTitle: pptMeta.previewTitle, + slideCount: pptMeta.slideCount, + thumbnailUrl: null, + thumbnailStatus: "pending", + thumbnailRetryCount: 0, + thumbnailError: null, + thumbnailUpdatedAt: new Date().toISOString(), + createdAt: new Date().toISOString(), + }); + await writeLectureDb(lectures); + enqueueThumbnailJob(lectureId, { force: false, reason: "upload" }); + + if (ENABLE_PPT_THUMBNAIL) { + generateSlideImages(filePath, lectureId).catch((err) => { + console.error("[slide-images] 업로드 후 생성 실패:", err?.message || err); + }); + } + + const returnTo = (req.body.returnTo || "").toString().trim(); + res.redirect(returnTo.startsWith("/") ? returnTo : "/admin"); +}); + +app.post( + "/lectures/video", + (req, res, next) => { + uploadVideo.single("videoFile")(req, res, (err) => { + if (err) { + res.status(400).send(err.message || "동영상 업로드에 실패했습니다."); + return; + } + next(); + }); + }, + async (req, res) => { + const token = (req.body.token || "").toString().trim(); + if (token !== ADMIN_TOKEN) { + res.status(403).send("관리자 토큰이 필요합니다."); + return; + } + const { title, description, tags, category } = req.body; + if (!req.file) { + res.status(400).send("동영상 파일이 필요합니다."); + return; + } + if (!title?.trim()) { + res.status(400).send("제목은 필수입니다."); + return; + } + + const lectures = await readLectureDb(); + const lectureId = uuidv4(); + const createdAt = new Date().toISOString(); + lectures.push({ + id: lectureId, + type: "video", + listSection: "learning", + title: title.trim(), + description: (description || "").trim(), + tags: mergeTagsWithCategory(tags, category), + fileName: req.file.filename, + originalName: req.file.originalname, + previewTitle: null, + slideCount: 0, + youtubeUrl: null, + thumbnailUrl: null, + thumbnailStatus: "ready", + thumbnailRetryCount: 0, + thumbnailError: null, + thumbnailUpdatedAt: createdAt, + createdAt, + }); + await writeLectureDb(lectures); + + const returnTo = (req.body.returnTo || "").toString().trim(); + res.redirect(returnTo.startsWith("/") ? returnTo : "/admin"); + } +); + +/** 학습센터: 유튜브가 아닌 일반 http(s) 링크를 카드로 등록 (news_url 컬럼 사용) */ +app.post("/lectures/link", async (req, res) => { + const token = (req.body.token || "").toString().trim(); + if (token !== ADMIN_TOKEN) { + res.status(403).send("관리자 토큰이 필요합니다."); + return; + } + const { title, description, tags, category } = req.body; + const linkUrl = (req.body.linkUrl || "").toString().trim(); + if (!title?.trim() || !linkUrl) { + res.status(400).send("제목과 URL은 필수입니다."); + return; + } + if (!isHttpUrl(linkUrl)) { + res.status(400).send("유효한 http(s) URL을 입력해주세요."); + return; + } + if (isYoutubeUrl(linkUrl)) { + res.status(400).send("유튜브 링크는 «유튜브 강의 등록»을 이용해주세요."); + return; + } + + const lectures = await readLectureDb(); + const lectureId = uuidv4(); + const createdAt = new Date().toISOString(); + lectures.push({ + id: lectureId, + type: "link", + listSection: "learning", + title: title.trim(), + description: (description || "").trim(), + tags: mergeTagsWithCategory(tags, category), + newsUrl: linkUrl, + thumbnailUrl: null, + thumbnailStatus: "pending", + thumbnailRetryCount: 0, + thumbnailError: null, + thumbnailUpdatedAt: createdAt, + createdAt, + }); + await writeLectureDb(lectures); + scheduleLinkOgPreviewFetch(lectureId, linkUrl); + const returnTo = (req.body.returnTo || "").toString().trim(); + res.redirect(returnTo.startsWith("/") ? returnTo : "/admin"); +}); + +app.post("/lectures/:id/delete", async (req, res) => { + const { token, returnTo } = req.body; + if ((token || "").trim() !== ADMIN_TOKEN) { + res.status(403).send("관리자 토큰이 유효하지 않습니다."); + return; + } + + const lectures = await readLectureDb(); + const index = lectures.findIndex((item) => item.id === req.params.id); + if (index < 0) { + res.status(404).send("삭제할 강의를 찾을 수 없습니다."); + return; + } + + const target = lectures[index]; + await removeQueuedThumbnailJobs(target.id); + const storedFileTypes = ["ppt", "video"]; + if (storedFileTypes.includes(target.type) && target.fileName) { + const filePath = path.join(UPLOAD_DIR, target.fileName); + try { + await fs.unlink(filePath); + } catch { + // Ignore file delete failures to keep metadata consistent. + } + if (target.type === "ppt") { + if (target.thumbnailUrl) { + const thumbPath = path.join(ROOT_DIR, target.thumbnailUrl.replace(/^\//, "")); + try { + await fs.unlink(thumbPath); + } catch { + // Ignore thumbnail delete failures. + } + } + const slidesDir = path.join(SLIDES_DIR, target.id); + try { + await fs.rm(slidesDir, { recursive: true, force: true }); + } catch { + // Ignore slide images delete failures. + } + } + } + + lectures.splice(index, 1); + await writeLectureDb(lectures); + + const safeReturn = (returnTo || "").toString().trim(); + if (safeReturn.startsWith("/")) { + res.redirect(safeReturn); + return; + } + if (safeReturn.startsWith("?")) { + res.redirect(`/admin${safeReturn}`); + return; + } + res.redirect("/admin"); +}); + +app.post("/lectures/:id/thumbnail/regenerate", async (req, res) => { + const { token, returnTo } = req.body; + if ((token || "").trim() !== ADMIN_TOKEN) { + res.status(403).send("관리자 토큰이 유효하지 않습니다."); + return; + } + + const lectures = await readLectureDb(); + const lecture = lectures.find((item) => item.id === req.params.id); + if (!lecture) { + res.status(404).send("강의를 찾을 수 없습니다."); + return; + } + if (lecture.type !== "ppt" || !lecture.fileName) { + res.status(400).send("PPT 강의만 썸네일 재생성이 가능합니다."); + return; + } + + ensurePptThumbnailFields(lecture); + lecture.thumbnailStatus = "pending"; + lecture.thumbnailError = null; + lecture.thumbnailRetryCount = 0; + lecture.thumbnailUpdatedAt = new Date().toISOString(); + await writeLectureDb(lectures); + enqueueThumbnailJob(lecture.id, { force: true, reason: "manual-regenerate" }); + + const safeReturn = (returnTo || "").toString().trim(); + if (safeReturn.startsWith("/")) { + res.redirect(safeReturn); + return; + } + if (safeReturn.startsWith("?")) { + res.redirect(`/admin${safeReturn}`); + return; + } + res.redirect("/admin"); +}); + +app.post("/lectures/:id/slides/regenerate", async (req, res) => { + const { token, returnTo } = req.body; + if ((token || "").trim() !== ADMIN_TOKEN) { + res.status(403).send("관리자 토큰이 유효하지 않습니다."); + return; + } + + const lectures = await readLectureDb(); + const lecture = lectures.find((item) => item.id === req.params.id); + if (!lecture) { + res.status(404).send("강의를 찾을 수 없습니다."); + return; + } + if (lecture.type !== "ppt" || !lecture.fileName) { + res.status(400).send("PDF/PowerPoint 강의만 슬라이드 이미지 재생성이 가능합니다."); + return; + } + + const filePath = path.join(UPLOAD_DIR, lecture.fileName); + if (!fsSync.existsSync(filePath)) { + res.status(400).send("원본 파일을 찾을 수 없습니다."); + return; + } + + const outDir = path.join(SLIDES_DIR, lecture.id); + try { + await fs.rm(outDir, { recursive: true, force: true }); + } catch { + // Ignore + } + + const urls = await generateSlideImages(filePath, lecture.id); + if (urls && urls.length > 0) { + res.redirect(`/lectures/${lecture.id}`); + } else { + res.redirect(`/lectures/${lecture.id}?slidesError=1`); + } +}); + +app.post("/thumbnails/retry-failed", async (req, res) => { + const { token, returnTo } = req.body; + if ((token || "").trim() !== ADMIN_TOKEN) { + res.status(403).send("관리자 토큰이 유효하지 않습니다."); + return; + } + + const lectures = await readLectureDb(); + let queued = 0; + for (const lecture of lectures) { + if (lecture.type !== "ppt") continue; + ensurePptThumbnailFields(lecture); + if (lecture.thumbnailStatus === "failed") { + lecture.thumbnailStatus = "pending"; + lecture.thumbnailError = null; + lecture.thumbnailRetryCount = 0; + lecture.thumbnailUpdatedAt = new Date().toISOString(); + if (enqueueThumbnailJob(lecture.id, { force: true, reason: "bulk-retry" })) { + queued += 1; + } + } + } + await writeLectureDb(lectures); + + const safeReturn = (returnTo || "").toString().trim(); + if (safeReturn.startsWith("/")) { + const sep = safeReturn.includes("?") ? "&" : "?"; + res.redirect(`${safeReturn}${sep}retryQueued=${queued}`); + return; + } + if (safeReturn.startsWith("?")) { + const joiner = safeReturn.length > 1 ? "&" : ""; + res.redirect(`/admin${safeReturn}${joiner}retryQueued=${queued}`); + return; + } + res.redirect(`/admin?retryQueued=${queued}`); +}); + +app.post("/thumbnails/events/clear", async (req, res) => { + const { token, returnTo } = req.body; + if ((token || "").trim() !== ADMIN_TOKEN) { + res.status(403).send("관리자 토큰이 유효하지 않습니다."); + return; + } + + await writeThumbnailEventDb([]); + + const safeReturn = (returnTo || "").toString().trim(); + if (safeReturn.startsWith("/")) { + const sep = safeReturn.includes("?") ? "&" : "?"; + res.redirect(`${safeReturn}${sep}eventsCleared=1`); + return; + } + if (safeReturn.startsWith("?")) { + const joiner = safeReturn.length > 1 ? "&" : ""; + res.redirect(`/admin${safeReturn}${joiner}eventsCleared=1`); + return; + } + res.redirect("/admin?eventsCleared=1"); +}); + +app.get("/admin/thumbnail-events", async (req, res) => { + const token = (req.query.token || "").toString(); + if (token !== ADMIN_TOKEN) { + res.status(403).send("관리자 토큰이 유효하지 않습니다."); + return; + } + + const page = Math.max(Number(req.query.page) || 1, 1); + const limit = Math.min(Math.max(Number(req.query.limit) || THUMBNAIL_EVENT_PAGE_SIZE, 10), 200); + const filter = buildEventFilter(req.query); + + const events = await readThumbnailEventDb(); + const filtered = [...events].reverse().filter(filter.matches); + const totalCount = filtered.length; + const totalPages = Math.max(Math.ceil(totalCount / limit), 1); + const currentPage = Math.min(page, totalPages); + const start = (currentPage - 1) * limit; + const pageItems = filtered.slice(start, start + limit); + + const makeQuery = (nextPage) => { + const params = new URLSearchParams(); + params.set("token", token); + if (filter.eventType !== "all") params.set("eventType", filter.eventType); + if (filter.lectureId) params.set("lectureId", filter.lectureId); + if (filter.reason) params.set("reason", filter.reason); + if (filter.from) params.set("from", filter.from); + if (filter.to) params.set("to", filter.to); + if (limit !== THUMBNAIL_EVENT_PAGE_SIZE) params.set("limit", String(limit)); + if (nextPage > 1) params.set("page", String(nextPage)); + return params.toString(); + }; + + const pagination = { + page: currentPage, + totalPages, + totalCount, + hasPrev: currentPage > 1, + hasNext: currentPage < totalPages, + prevQuery: makeQuery(currentPage - 1), + nextQuery: makeQuery(currentPage + 1), + pages: Array.from({ length: totalPages }, (_, idx) => { + const p = idx + 1; + return { page: p, query: makeQuery(p), active: p === currentPage }; + }), + }; + + const csvQuery = (() => { + const params = new URLSearchParams(makeQuery(1)); + params.delete("page"); + return params.toString(); + })(); + + const eventsCleared = req.query.eventsCleared === "1" || req.query.eventsCleared === "true"; + + res.render("admin-thumbnail-events", { + token, + filters: { + eventType: filter.eventType, + lectureId: filter.lectureId, + reason: filter.reason, + from: filter.from, + to: filter.to, + limit, + }, + events: pageItems, + pagination, + csvQuery, + eventsCleared, + }); +}); + +app.get("/admin/thumbnail-events.csv", async (req, res) => { + const token = (req.query.token || "").toString(); + if (token !== ADMIN_TOKEN) { + res.status(403).send("관리자 토큰이 유효하지 않습니다."); + return; + } + + const filter = buildEventFilter(req.query); + const events = await readThumbnailEventDb(); + const filtered = [...events].reverse().filter(filter.matches); + + const header = ["id", "at", "type", "lectureId", "lectureTitle", "reason", "force", "retryCount", "durationMs", "error"]; + const rows = filtered.map((evt) => + [ + evt.id, + evt.at, + evt.type, + evt.lectureId || "", + evt.lectureTitle || "", + evt.reason || "", + evt.force === true ? "true" : "false", + evt.retryCount ?? "", + evt.durationMs ?? "", + evt.error || "", + ] + .map(escapeCsv) + .join(",") + ); + + const csv = [header.join(","), ...rows].join("\n"); + res.setHeader("Content-Type", "text/csv; charset=utf-8"); + res.setHeader("Content-Disposition", `attachment; filename="thumbnail-events-${Date.now()}.csv"`); + res.send(csv); +}); + +app.get("/api/queue/metrics", async (req, res) => { + const token = (req.query.token || "").toString(); + if (token !== ADMIN_TOKEN) { + res.status(403).json({ message: "관리자 토큰이 유효하지 않습니다." }); + return; + } + + const lectures = await readLectureDb(); + const events = await readThumbnailEventDb(); + const pptLectures = lectures.filter((lecture) => lecture.type === "ppt"); + const statusCounts = pptLectures.reduce( + (acc, lecture) => { + const status = lecture.thumbnailStatus || "pending"; + if (status === "ready") acc.ready += 1; + else if (status === "processing") acc.processing += 1; + else if (status === "failed") acc.failed += 1; + else acc.pending += 1; + return acc; + }, + { ready: 0, processing: 0, pending: 0, failed: 0 } + ); + + const recentFailures = [...events] + .filter((evt) => evt.type === "failed") + .reverse() + .slice(0, 10) + .map((evt) => ({ + at: evt.at, + lectureId: evt.lectureId, + lectureTitle: evt.lectureTitle || null, + reason: evt.reason || null, + error: evt.error || null, + retryCount: evt.retryCount || 0, + })); + + res.json({ + queue: { + pending: thumbnailQueue.length, + working: thumbnailWorkerRunning, + queuedLectureIds: [...queuedLectureIds], + }, + statusCounts, + config: { + maxRetry: THUMBNAIL_MAX_RETRY, + retryDelayMs: THUMBNAIL_RETRY_DELAY_MS, + eventKeep: THUMBNAIL_EVENT_KEEP, + thumbnailEnabled: ENABLE_PPT_THUMBNAIL, + }, + recentFailures, + generatedAt: new Date().toISOString(), + }); +}); + +app.get("/api/queue/events-summary", async (req, res) => { + const token = (req.query.token || "").toString(); + if (token !== ADMIN_TOKEN) { + res.status(403).json({ message: "관리자 토큰이 유효하지 않습니다." }); + return; + } + + const hours = Math.min(Math.max(Number(req.query.hours) || 24, 1), 168); + const now = Date.now(); + const startMs = now - hours * 60 * 60 * 1000; + + const events = await readThumbnailEventDb(); + const scoped = events.filter((evt) => { + const atMs = new Date(evt.at || "").getTime(); + return Number.isFinite(atMs) && atMs >= startMs; + }); + + const processed = scoped.filter((evt) => evt.type === "success" || evt.type === "failed"); + const successCount = processed.filter((evt) => evt.type === "success").length; + const failedCount = processed.filter((evt) => evt.type === "failed").length; + const processedCount = processed.length; + const failureRate = processedCount > 0 ? failedCount / processedCount : 0; + + const durations = processed + .map((evt) => Number(evt.durationMs)) + .filter((ms) => Number.isFinite(ms) && ms >= 0); + const avgDurationMs = durations.length + ? Math.round(durations.reduce((sum, ms) => sum + ms, 0) / durations.length) + : 0; + + const bucketMap = new Map(); + for (let i = 0; i < hours; i += 1) { + const bucketStart = startMs + i * 60 * 60 * 1000; + const key = new Date(bucketStart).toISOString().slice(0, 13); + bucketMap.set(key, { + key, + label: `${new Date(bucketStart).getHours()}시`, + processed: 0, + success: 0, + failed: 0, + }); + } + for (const evt of processed) { + const atMs = new Date(evt.at || "").getTime(); + if (!Number.isFinite(atMs) || atMs < startMs) continue; + const key = new Date(atMs).toISOString().slice(0, 13); + const bucket = bucketMap.get(key); + if (!bucket) continue; + bucket.processed += 1; + if (evt.type === "success") bucket.success += 1; + if (evt.type === "failed") bucket.failed += 1; + } + + const buckets = [...bucketMap.values()]; + const queue = { + pending: thumbnailQueue.length, + working: thumbnailWorkerRunning, + }; + + res.json({ + windowHours: hours, + generatedAt: new Date().toISOString(), + kpi: { + processedCount, + successCount, + failedCount, + failureRate, + avgDurationMs, + }, + queue, + buckets, + }); +}); + +app.get("/lectures/:id/edit", async (req, res) => { + const queryToken = (req.query.token || "").toString().trim(); + const cookieToken = (req.cookies?.[ADMIN_COOKIE_NAME] || "").toString().trim(); + const isValidQuery = ADMIN_TOKEN.length > 0 && queryToken === ADMIN_TOKEN; + const isValidCookie = ADMIN_TOKEN.length > 0 && cookieToken.length > 0 && cookieToken === ADMIN_TOKEN; + if (!isValidQuery && !isValidCookie) { + res.status(403).send("관리자 토큰이 필요합니다."); + return; + } + const tokenRaw = isValidQuery ? queryToken : cookieToken; + const lectures = await readLectureDb(); + const lecture = lectures.find((item) => item.id === req.params.id); + if (!lecture) { + res.status(404).send("강의를 찾을 수 없습니다."); + return; + } + const returnQuery = new URLSearchParams(req.query).toString(); + res.render("learning-edit", { + lecture, + tokenRaw, + returnQuery: returnQuery || (tokenRaw ? `token=${encodeURIComponent(tokenRaw)}` : ""), + adminBasePath: "/admin", + viewerBasePath: "/learning", + navActiveMenu: "learning", + }); +}); + +app.post("/lectures/:id/update", async (req, res) => { + const { token, returnTo, title, description, tags, category } = req.body; + const youtubeUrl = req.body.youtubeUrl; + if ((token || "").trim() !== ADMIN_TOKEN) { + res.status(403).send("관리자 토큰이 유효하지 않습니다."); + return; + } + const lectures = await readLectureDb(); + const index = lectures.findIndex((item) => item.id === req.params.id); + if (index < 0) { + res.status(404).send("수정할 강의를 찾을 수 없습니다."); + return; + } + const lecture = lectures[index]; + if (!title?.trim()) { + res.status(400).send("제목은 필수입니다."); + return; + } + if (lecture.type === "youtube") { + if (!youtubeUrl?.trim()) { + res.status(400).send("유튜브 링크는 필수입니다."); + return; + } + if (!isYoutubeUrl(youtubeUrl)) { + res.status(400).send("유효한 유튜브 링크를 입력해주세요."); + return; + } + lecture.youtubeUrl = youtubeUrl.trim(); + } + let scheduleOgAfterWrite = false; + if (lecture.type === "news" || lecture.type === "link") { + const nu = (req.body.newsUrl || "").toString().trim(); + if (!nu) { + res.status(400).send(lecture.type === "link" ? "URL은 필수입니다." : "뉴스 URL은 필수입니다."); + return; + } + if (!isHttpUrl(nu)) { + res.status(400).send("유효한 http(s) URL을 입력해주세요."); + return; + } + if (lecture.type === "link" && isYoutubeUrl(nu)) { + res.status(400).send("유튜브 링크는 «유튜브 강의 등록»을 이용해주세요."); + return; + } + const prevUrl = (lecture.newsUrl || "").trim(); + lecture.newsUrl = nu; + if (prevUrl !== nu) { + scheduleOgAfterWrite = true; + lecture.thumbnailUrl = null; + lecture.thumbnailStatus = "pending"; + lecture.thumbnailError = null; + lecture.thumbnailUpdatedAt = new Date().toISOString(); + } + } + lecture.title = title.trim(); + lecture.description = (description || "").trim(); + lecture.tags = mergeTagsWithCategory(tags, category); + await writeLectureDb(lectures); + if (scheduleOgAfterWrite && (lecture.type === "news" || lecture.type === "link")) { + const nu = (lecture.newsUrl || "").trim(); + if (nu && isHttpUrl(nu) && !(lecture.type === "link" && isYoutubeUrl(nu))) { + scheduleLinkOgPreviewFetch(lecture.id, nu); + } + } + const safeReturn = (returnTo || "").toString().trim(); + res.redirect(safeReturn.startsWith("/") ? safeReturn : `/admin?${safeReturn || ""}`); +}); + +app.get("/lectures/:id", async (req, res) => { + const lectures = await readLectureDb(); + const lecture = lectures.find((item) => item.id === req.params.id); + + if (!lecture) { + res.status(404).send("강의를 찾을 수 없습니다."); + return; + } + + if (lecture.type === "youtube") { + res.render("lecture-youtube", { + lecture, + embedUrl: toYoutubeEmbedUrl(lecture.youtubeUrl), + }); + return; + } + + if (lecture.type === "news" || lecture.type === "link") { + const nu = (lecture.newsUrl || "").trim(); + if (nu && isHttpUrl(nu)) { + res.redirect(302, nu); + return; + } + res.render(lecture.type === "news" ? "lecture-news" : "lecture-link", { lecture }); + return; + } + + if (lecture.type === "video" && lecture.fileName) { + const filePath = path.join(UPLOAD_DIR, lecture.fileName); + if (!fsSync.existsSync(filePath)) { + res.status(404).send("동영상 파일을 찾을 수 없습니다."); + return; + } + const videoSrc = `/uploads/${path.basename(lecture.fileName)}`; + res.render("lecture-video", { lecture, videoSrc }); + return; + } + + const filePath = path.join(UPLOAD_DIR, lecture.fileName); + const ext = path.extname(lecture.fileName || "").toLowerCase(); + let slides = + ext === ".pdf" + ? await parsePdfSlides(filePath) + : await parsePptxSlides(filePath); + const slideImageUrls = getSlideImageUrls(lecture.id); + + if (slides.length === 0 && slideImageUrls.length > 0) { + slides = slideImageUrls.map(() => ({ title: "", lines: [] })); + } + + res.render("lecture-ppt", { + lecture, + slides, + slideImageUrls: slideImageUrls || [], + slidesError: req.query.slidesError === "1", + }); +}); + +app.use((req, res) => { + res.status(404).send(`페이지를 찾을 수 없습니다: ${req.method} ${req.path}`); +}); + +app.use((error, _, res, __) => { + res.status(400).send(error.message || "요청 처리 중 오류가 발생했습니다."); +}); + +ensureBootstrap() + .then(() => { + app.listen(PORT, HOST, () => { + const hint = HOST === "0.0.0.0" || HOST === "::" ? "localhost" : HOST; + console.log(`Server started: http://${hint}:${PORT} (listening on ${HOST}:${PORT})`); + }); + }) + .catch((error) => { + console.error("Failed to bootstrap:", error); + process.exit(1); + }); diff --git a/views/admin-thumbnail-events.ejs b/views/admin-thumbnail-events.ejs new file mode 100644 index 0000000..fd21021 --- /dev/null +++ b/views/admin-thumbnail-events.ejs @@ -0,0 +1,238 @@ + + + + + + 썸네일 이벤트 로그 + + + +
+ <%- include('partials/nav', { activeMenu: 'learning' }) %> + +
+
+

썸네일 이벤트 로그

+ 대시보드로 +
+ +
+
+
+

실시간 요약

+
+ + +
+
+

요약 API: /api/queue/events-summary?token=***&hours=24

+
+
+ 처리 건수(24h) + - +
+
+ 실패율(24h) + - +
+
+ 평균 소요(ms) + - +
+
+ 현재 큐 + - +
+
+
+
+

시간대별 처리량(24h)

+
+
+
+

시간대별 실패량(24h)

+
+
+
+
+ +
+

필터

+ <% if (typeof eventsCleared !== 'undefined' && eventsCleared) { %> +

썸네일 이벤트 로그가 초기화되었습니다.

+ <% } %> +
+ + + + + + + + +
+
+ + + +
+
+ +
+
+

이벤트 목록

+ 총 <%= pagination.totalCount %>건 +
+ + <% if (!events.length) { %> +

조회 결과가 없습니다.

+ <% } else { %> +
+ + + + + + + + + + + + + + <% events.forEach((evt) => { %> + + + + + + + + + + <% }) %> + +
시간타입강의사유재시도소요(ms)에러
<%= new Date(evt.at).toLocaleString("ko-KR") %><%= evt.type %> +
<%= evt.lectureTitle || "-" %>
+ <%= evt.lectureId || "-" %> +
<%= evt.reason || "-" %><%= evt.retryCount ?? "-" %><%= evt.durationMs ?? "-" %><%= evt.error || "-" %>
+
+ <% } %> + + <% if (pagination.totalPages > 1) { %> + + <% } %> +
+
+
+
+ + + diff --git a/views/admin-users.ejs b/views/admin-users.ejs new file mode 100644 index 0000000..afbabec --- /dev/null +++ b/views/admin-users.ejs @@ -0,0 +1,60 @@ + + + + + + 사용자 현황관리 - XAVIS + + + +
+ <%- include('partials/nav', { activeMenu: 'admin-users', adminMode: true }) %> +
+
+

사용자 현황관리

+ 학습센터 +
+
+
+

+ OPS 이메일(@xavis.co.kr) 매직 링크로 로그인에 성공한 사용자 목록입니다. 이메일과 최근 접속일(마지막 로그인 시각)을 표시합니다. +

+ <% if (typeof dbError !== 'undefined' && dbError) { %> +

목록을 불러오지 못했습니다: <%= dbError %>

+ <% } else if (!pgConnected) { %> +

PostgreSQL이 비활성화되어 있어 사용자 목록을 조회할 수 없습니다.

+ <% } else if (!users || users.length === 0) { %> +

아직 로그인 기록이 없습니다.

+ <% } else { %> +
+ + + + + + + + + <% users.forEach(function (u) { %> + + + + + <% }); %> + +
이메일최근 접속일
<%= u.email %> + <% if (u.lastLoginAt) { %> + <%= new Date(u.lastLoginAt).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' }) %> + <% } else { %> + — + <% } %> +
+
+

총 <%= users.length %>명

+ <% } %> +
+
+
+
+ + diff --git a/views/ai-case-detail.ejs b/views/ai-case-detail.ejs new file mode 100644 index 0000000..a31a78b --- /dev/null +++ b/views/ai-case-detail.ejs @@ -0,0 +1,152 @@ + + + + + + <%= story.title %> - AI 성공 사례 - XAVIS + + + + <% var pdfUrlRaw = (typeof story.pdfUrl === 'string' ? story.pdfUrl : '').trim(); var showPdfViewer = pdfUrlRaw.length > 0; %> + <% if (typeof slideImageUrls === 'undefined') { slideImageUrls = []; } %> + <% if (typeof slides === 'undefined') { slides = []; } %> +
+ <%- include('partials/nav', { activeMenu: 'ai-cases' }) %> +
+ <% if (showPdfViewer) { %> +
+ ← AI 성공 사례로 돌아가기 +

<%= story.title %>

+

<%= story.excerpt || (story.department + ' · ' + story.author) %>

+
+ <%= slides.length %>페이지 + + <%= story.department %> · <%= story.author %><% if (story.publishedAt) { %> · <%= story.publishedAt %><% } %> + <% if (typeof adminMode !== 'undefined' && adminMode) { %> + + 편집 + <% } %> + + 다운로드 + <% if (typeof adminMode !== 'undefined' && adminMode) { %> + + + <% } %> +
+ <% if ((story.tags || []).length) { %> +
+ <% (story.tags || []).forEach((oneTag) => { %> + #<%= oneTag %> + <% }) %> +
+ <% } %> + <% if (!slideImageUrls || slideImageUrls.length === 0) { %> +

PDF를 페이지 이미지로 보여 주지 못했습니다. 원본 PDF로 보기. 서버에는 pdftoppm(Poppler)이 필요하고, 저장된 PDF 주소는 브라우저에서 열리는 것과 같이 /public/... 또는 그와 같은 경로의 전체 URL이어야 합니다.

+ <% } %> +
+ <% slides.forEach((slide, index) => { %> +
+
+

페이지 <%= index + 1 %>

+ <% if (slide.title) { %>

<%= slide.title %>

<% } %> +
+ <% if (slideImageUrls[index]) { %> +
+ 페이지 <%= index + 1 %> +
+ <% } %> + <% if (slide.lines && slide.lines.length > 0) { %> +
    + <% slide.lines.forEach((line) => { %> +
  • <%= line %>
  • + <% }) %> +
+ <% } %> +
+ <% }) %> +
+
+ <% } else { %> +
+ ← AI 성공 사례로 돌아가기 +

<%= story.title %>

+

<%= story.excerpt || (story.department + ' · ' + story.author) %>

+
+ <%= story.department %> · <%= story.author %><% if (story.publishedAt) { %> · <%= story.publishedAt %><% } %> + <% if (typeof adminMode !== 'undefined' && adminMode) { %> + + 편집 + + + <% } %> +
+ <% if ((story.tags || []).length) { %> +
+ <% (story.tags || []).forEach((oneTag) => { %> + #<%= oneTag %> + <% }) %> +
+ <% } %> +
+
+
+

본문

+
+
+
+
+ +
+ <% } %> +
+
+ <% if (typeof adminMode !== 'undefined' && adminMode) { %> + + <% } %> + <% if (!showPdfViewer) { %> + + + + <% } %> + + diff --git a/views/ai-cases-write.ejs b/views/ai-cases-write.ejs new file mode 100644 index 0000000..61b9aee --- /dev/null +++ b/views/ai-cases-write.ejs @@ -0,0 +1,228 @@ + + + + + + AI 성공 사례 관리 - XAVIS + + + + + <% if (typeof allStories === 'undefined') { allStories = []; } %> + <% if (typeof story === 'undefined') { story = null; } %> +
+ <%- include('partials/nav', { activeMenu: 'ai-cases' }) %> +
+
+

AI 성공 사례 관리

+ 목록(사용자 화면) +
+
+
+ +

슬러그는 URL에 쓰이므로 영문·숫자·하이픈만 사용하세요. 원문 PDF 경로(/public/...)가 있으면 상세는 PDF 페이지 이미지로 보여 주며, 이때 본문(Markdown)은 비워도 됩니다. PDF가 없을 때는 본문이 필수입니다.

+
+ + <% if (allStories.length) { %> +
+

등록된 사례

+
    + <% allStories.forEach(function(s) { %> +
  • + <%= s.title %> + /<%= s.slug %> + +
  • + <% }) %> +
+
+ <% } %> + +
+

<%= story ? '사례 수정' : '새 사례 등록' %>

+
+ + + + + + + + + + +
+ + +
+
+

+
+
+
+
+ + + diff --git a/views/ai-cases.ejs b/views/ai-cases.ejs new file mode 100644 index 0000000..de54c53 --- /dev/null +++ b/views/ai-cases.ejs @@ -0,0 +1,74 @@ + + + + + + AI 성공 사례 - XAVIS + + + + <% if (typeof filters === 'undefined') { filters = { q: '', tag: '' }; } %> + <% if (typeof availableTags === 'undefined') { availableTags = []; } %> + <% if (typeof stories === 'undefined') { stories = []; } %> + <% var _opsLoggedIn = typeof opsUserEmail !== 'undefined' && opsUserEmail; %> +
+ <%- include('partials/nav', { activeMenu: 'ai-cases' }) %> +
+
+

AI 성공 사례

+ <% if (typeof adminMode !== 'undefined' && adminMode && !_opsLoggedIn) { %> + 사례 등록·관리 + <% } %> +
+
+ <% if (typeof successStoryDetailAllowed !== 'undefined' && !successStoryDetailAllowed) { %> +

+ 로그인 후 이용 가능합니다. +

+ <% } %> +
+

현장에서 검증된 AI 업무 혁신 이야기

+

부서별 도입 과정과 성과를 카드에서 확인하고, 본문에서 상세 내용을 읽을 수 있습니다.

+
+
+

검색·필터

+
+ + +
+ + 초기화 +
+
+
+
+
+

등록된 사례

+ 총 <%= stories.length %>건 +
+ <% if (!stories.length) { %> +

조건에 맞는 사례가 없습니다.

+ <% } else { %> +
+ <% stories.forEach((story) => { %> + <%- include('partials/success-story-card', { story, successStoryDetailAllowed }) %> + <% }) %> +
+ <% } %> +
+
+
+
+ + diff --git a/views/ai-explore.ejs b/views/ai-explore.ejs new file mode 100644 index 0000000..49a7ebd --- /dev/null +++ b/views/ai-explore.ejs @@ -0,0 +1,142 @@ + + + + + + AI - XAVIS + + + + <% var aiGuestDev = typeof aiExploreDevGuestRestricted !== 'undefined' && aiExploreDevGuestRestricted; %> + <% var _opsLoggedIn = typeof opsUserEmail !== 'undefined' && opsUserEmail; %> +
+ <%- include('partials/nav', { activeMenu: 'ai-explore', adminMode: typeof adminMode !== 'undefined' ? adminMode : false }) %> +
+
+

AI

+ <% if (!_opsLoggedIn) { %> + <% if (aiGuestDev) { %> + AI 추가하기 + <% } else { %> + AI 추가하기 + <% } %> + <% } %> +
+
+ <% if (aiGuestDev) { %> +

로그인 후 이용 가능합니다.

+ <% } %> +
+

지식 시험이나 지식 보강은 물론, 스킬들을 다양하게 조합한 맞춤형 AI를 탐색하고 사용해보세요.

+ +
+
+

AI 서비스

+
+ +
+ 공개중 +
+

프롬프트

+

업무별 기본 프롬프트를 모아 두고, 복사해 바로 활용할 수 있는 라이브러리입니다.

+
#프롬프트
+
+ <% if (aiGuestDev) { %> +
+
+ 공개중 +
+

회의록 AI

+

회의록을 자동으로 작성·요약·정리해주는 AI 서비스입니다.

+
#업무관리#회의록
+
+ <% } else { %> + +
+ 공개중 +
+

회의록 AI

+

회의록을 자동으로 작성·요약·정리해주는 AI 서비스입니다.

+
#업무관리#회의록
+
+ <% } %> + <% if (aiGuestDev) { %> +
+
+ 공개중 +
+

업무 체크리스트 AI

+

회의록에서 추출된 할 일과 개인 업무를 통합 관리하고, 등록·수정·삭제를 통해 완결성을 높이는 AI 비서입니다.

+
#업무관리#체크리스트
+
+ <% } else { %> + +
+ 공개중 +
+

업무 체크리스트 AI

+

회의록에서 추출된 할 일과 개인 업무를 통합 관리하고, 등록·수정·삭제를 통해 완결성을 높이는 AI 비서입니다.

+
#업무관리#체크리스트
+
+ <% } %> +
+
+
+
+
+ + + diff --git a/views/ai-prompts.ejs b/views/ai-prompts.ejs new file mode 100644 index 0000000..1074ea1 --- /dev/null +++ b/views/ai-prompts.ejs @@ -0,0 +1,163 @@ + + + + + + 프롬프트 라이브러리 - XAVIS + + + +
+ <%- include('partials/nav', { activeMenu: 'ai-explore' }) %> +
+
+

프롬프트

+ AI 목록 +
+
+ ← AI + +
+

프롬프트 라이브러리

+

+ 업무별로 자주 쓰는 기본 프롬프트를 골라 바로 복사해 사용하세요. ChatGPT·Claude·자비스 채팅 등 어디에든 붙여 넣을 수 있습니다. +

+

+ <%= prompts.length %>가지 템플릿 · 사내 업무 시나리오 중심 · 복사 후 [ ] 부분만 채워 완성 +

+
+ + <% if (!prompts.length) { %> +
+

프롬프트 데이터를 불러오지 못했습니다. 관리자에게 문의해 주세요.

+
+ <% } else { %> +
+
+

시나리오 선택

+
+ <% prompts.forEach(function (p) { %> + + <% }); %> +
+
+
+

프롬프트 미리보기

+

왼쪽에서 카드를 선택하세요.

+ +
+
+ <% } %> +
+
+
+ <% if (prompts.length) { %> + + <% } %> + + + diff --git a/views/ax-apply.ejs b/views/ax-apply.ejs new file mode 100644 index 0000000..75ae3d3 --- /dev/null +++ b/views/ax-apply.ejs @@ -0,0 +1,479 @@ + + + + + + AX 과제 신청 - XAVIS + + + + <% var axDlSubmitOk = typeof axApplyDownloadSubmitAllowed !== 'undefined' ? axApplyDownloadSubmitAllowed : true; %> + <% if (typeof assignments === 'undefined') { assignments = []; } %> +
+ <%- include('partials/nav', { activeMenu: 'ax-apply' }) %> +
+
+

AX 과제 신청

+
+
+ <% if (!axDlSubmitOk) { %> +

+ 로그인 후 이용 가능합니다. +

+ <% } %> +
+

신청된 과제 목록

+

소속 부서, 이름, 이메일을 입력하면 조회됩니다.

+
+ <% if (!assignments.length) { %> +

신청된 과제가 없습니다.

+ <% } else { %> +
+ + + + + + + + + + + + + + + + + + <% assignments.forEach((a, idx) => { %> + + + + + + + + + + + + + + <% }) %> + +
순번신청일소속 부서이름사번직급이메일AI에게 원하는 것신청서상태수정<% if (typeof adminMode !== 'undefined' && adminMode) { %> / 삭제<% } %>
<%= assignments.length - idx %><%= new Date(a.createdAt).toLocaleDateString('ko-KR') %><%= a.department || '-' %><%= a.name || '-' %><%= a.employeeId || '-' %><%= a.position || '-' %><%= a.email || '-' %>"><%= (a.aiExpectation || '-').length > 30 ? (a.aiExpectation || '').slice(0, 30) + '…' : (a.aiExpectation || '-') %><% if (a.applicationFile) { %>보기<% } else { %>-<% } %><%= a.status || '신청' %> + 수정<% if (typeof adminMode !== 'undefined' && adminMode) { %> | " data-department="<%= (a.department || '').toString().replace(/"/g, '"') %>" data-name="<%= (a.name || '').toString().replace(/"/g, '"') %>" data-employee-id="<%= (a.employeeId || '').toString().replace(/"/g, '"') %>" data-position="<%= (a.position || '').toString().replace(/"/g, '"') %>" data-email="<%= (a.email || '').toString().replace(/"/g, '"') %>">삭제<% } %> +
+
+ <% } %> +
+ <% if (typeof adminMode !== 'undefined' && !adminMode) { %> +
+ +
+ <% } %> +
+ +
+

+ AX 과제 신청서 + <% if (axDlSubmitOk) { %> + (신청서 다운로드) + <% } else { %> + (신청서 다운로드) + <% } %> +

+

※ 모든 항목을 성실하게 작성해 주세요.

+
+ +

1. 기본 정보

+ + + + + + + +

2. 현재 업무 현황 (As-Is)

+ + + + + + + +

3. 희망하는 결과 (To-Be)

+ + + + +

4. 데이터 준비 상태

+ + + + + + +

5. 목표 및 기대 효과

+ + + + + + + + + + + +

6. 예상 AI 툴 및 인프라

+ + +

7. 리스크 및 제약사항

+ + + +

8. 작성 완료 신청서 업로드

+ + +

9. 현업 참여 확약

+ + +
+ + + +
+
+
+
+
+
+ <% if (typeof adminMode !== 'undefined' && adminMode) { %> + + <% } %> + + + diff --git a/views/chat.ejs b/views/chat.ejs new file mode 100644 index 0000000..8de1a9f --- /dev/null +++ b/views/chat.ejs @@ -0,0 +1,342 @@ + + + + + + 채팅 - XAVIS + + + +
+ <%- include('partials/nav', { activeMenu: 'chat' }) %> +
+
+

채팅

+
+
+ + +
+
+

안녕하세요, 오늘 무엇을 도와드릴까요?

+

AI와 대화하며 업무를 효율적으로 처리해보세요.

+
+
+
+
+ + + +
+

자비스는 실수를 할 수 있습니다. 중요한 정보는 재차 확인하세요.

+
+
+
+
+ + + + + diff --git a/views/index.ejs b/views/index.ejs new file mode 100644 index 0000000..2220149 --- /dev/null +++ b/views/index.ejs @@ -0,0 +1,333 @@ + + + + + + 학습센터 + + + + <% if (typeof filters === 'undefined') { filters = { q: '', type: 'all', tag: '' }; } %> + <% if (typeof availableTags === 'undefined') { availableTags = []; } %> + <% if (typeof pagination === 'undefined') { pagination = { page: 1, totalPages: 1, totalCount: 0, hasPrev: false, hasNext: false, prevQuery: '', nextQuery: '', pages: [] }; } %> + <% if (typeof thumbnailQueueInfo === 'undefined') { thumbnailQueueInfo = { pending: 0, working: false, maxRetry: 2 }; } %> + <% if (typeof thumbnailStatusSummary === 'undefined') { thumbnailStatusSummary = { ready: 0, processing: 0, failed: 0, pending: 0 }; } %> + <% if (typeof recentEvents === 'undefined') { recentEvents = []; } %> + <% if (typeof failureReasons === 'undefined') { failureReasons = []; } %> + <% if (typeof adminRequested === 'undefined') { adminRequested = false; } %> + <% if (typeof adminMode === 'undefined') { adminMode = false; } %> + <% if (typeof tokenRaw === 'undefined') { tokenRaw = ''; } %> + <% if (typeof tokenMasked === 'undefined') { tokenMasked = ''; } %> + <% if (typeof returnQuery === 'undefined') { returnQuery = ''; } %> + <% if (typeof retryQueued === 'undefined') { retryQueued = 0; } %> + <% if (typeof lectures === 'undefined') { lectures = []; } %> + <% if (typeof escapeHtml === 'undefined') { escapeHtml = function(s) { return String(s || ''); }; } %> +
+ <%- include('partials/nav', { activeMenu: 'learning' }) %> + +
+
+

학습센터

+ +
+ +
+
+

최신 컨텐츠로 학습하고, 바로 업무에 적용하세요.

+

유튜브 링크 또는 PPT를 등록한 뒤, 목록에서 클릭하여 강의를 시청할 수 있습니다.

+
+ +
+

강의 검색/필터

+
+ + + + <% if (adminRequested) { %> + + + <% } %> +
+ + 초기화 +
+
+
+ +
+

관리자 모드

+ <% if (retryQueued > 0) { %> +

실패 건 재시도 <%= retryQueued %>건이 큐에 등록되었습니다.

+ <% } %> + <% if (adminMode) { %> +

관리자 모드 활성화됨

+
+ 큐: <%= thumbnailQueueInfo.pending %> + 워커: <%= thumbnailQueueInfo.working ? "작동중" : "대기" %> + 실패 재시도 최대: <%= thumbnailQueueInfo.maxRetry %> +
+
+ PPT 썸네일 - 준비완료 <%= thumbnailStatusSummary.ready %> + 처리중 <%= thumbnailStatusSummary.processing %> + 대기 <%= thumbnailStatusSummary.pending %> + 실패 <%= thumbnailStatusSummary.failed %> +
+ <% } else { %> +

삭제 기능은 관리자 토큰이 있어야 활성화됩니다.

+ <% } %> +
+ + + + + + + +
+ <% if (adminMode) { %> +
+ + + +
+

메트릭 API: /api/queue/metrics?token=***

+

+ 이벤트 로그 페이지 열기 +

+ +
+
+

실패 원인 TOP

+ <% if (!failureReasons.length) { %> +

최근 실패 원인이 없습니다.

+ <% } else { %> +
    + <% failureReasons.slice(0, 5).forEach((item) => { %> +
  • + <%= item.count %>회 + <%= item.reason %> +
  • + <% }) %> +
+ <% } %> +
+ +
+

최근 썸네일 이벤트

+ <% if (!recentEvents.length) { %> +

이벤트 로그가 없습니다.

+ <% } else { %> +
    + <% recentEvents.forEach((evt) => { %> +
  • + <%= evt.type %> + <%= evt.lectureTitle || evt.lectureId %> + <%= new Date(evt.at).toLocaleString("ko-KR") %> +
  • + <% }) %> +
+ <% } %> +
+
+ <% } %> +
+ +
+

유튜브 강의 등록

+
+ + + + + +
+
+ +
+

PDF/PowerPoint 강의 등록

+
+ + + + + +
+
+ +
+

동영상 파일 등록

+
+ + + + + +
+
+ +
+
+

등록된 강의

+ 총 <%= pagination.totalCount %>건 +
+ <% if (!lectures.length) { %> +

등록된 강의가 없습니다.

+ <% } %> + +
+ <% lectures.forEach((lecture) => { %> + <% var _externalUrl = (lecture.type === "link" || lecture.type === "news") && (lecture.newsUrl || "").trim(); %> +
+ <% if (_externalUrl) { %> + + <% } else { %> + + <% } %> +
+ <% if (lecture.type === "ppt") { %> + <% if (lecture.thumbnailUrl) { %> + <%= lecture.title %> 썸네일 + <% } else { %> + 썸네일 <%= lecture.thumbnailStatus || "pending" %> + <% } %> + PPT 프리뷰 + <% if (lecture.previewTitle && lecture.previewTitle !== "제목 없음") { %><%= lecture.previewTitle %><% } %> + <% } else if (lecture.type === "news") { %> + <% if (lecture.thumbnailUrl) { %> + + <% } %> + 뉴스 + <% if (!lecture.thumbnailUrl) { %>외부 링크<% } %> + <% } else if (lecture.type === "link") { %> + <% if (lecture.thumbnailUrl) { %> + + <% } %> + 웹 링크 + <% if (!lecture.thumbnailUrl) { %>외부 페이지<% } %> + <% } else if (lecture.type === "video") { %> + + 동영상 파일 + 업로드 영상 + <% } else { %> + <% const ytThumb = typeof getYoutubeThumbnailUrl === 'function' ? getYoutubeThumbnailUrl(lecture.youtubeUrl) : null; %> + <% if (ytThumb) { %> + <%= lecture.title %> 썸네일 + <% } %> + YouTube + 영상 강의 + <% } %> +
+
+ <% if (lecture.type === "youtube") { %>YouTube<% } else if (lecture.type === "news") { %>뉴스<% } else if (lecture.type === "link") { %>링크<% } else if (lecture.type === "video") { %>동영상<% } else { %>PPT<% } %> +
+

<%= lecture.title %>

+

<%= lecture.description || "설명이 없습니다." %>

+
+ <% (lecture.tags || []).forEach((oneTag) => { %> + #<%= oneTag %> + <% }) %> +
+ <%= new Date(lecture.createdAt).toLocaleString("ko-KR") %> +
+ <% if (adminMode) { %> +
+ + + +
+ <% if (lecture.type === "ppt") { %> +
+ "> + <%= lecture.thumbnailStatus || "pending" %> + + <% if (lecture.thumbnailError) { %> + <%= lecture.thumbnailError %> + <% } %> +
+
+ + + +
+ <% } %> + <% } %> +
+ <% }) %> +
+ + <% if (pagination.totalPages > 1) { %> + + <% } %> +
+
+
+
+ + diff --git a/views/learning-admin.ejs b/views/learning-admin.ejs new file mode 100644 index 0000000..29bd078 --- /dev/null +++ b/views/learning-admin.ejs @@ -0,0 +1,335 @@ + + + + + + <% var admTitle = typeof adminPageTitle !== 'undefined' ? adminPageTitle : '학습센터 관리'; %> + <%= admTitle %> - XAVIS + + + + <% if (typeof filters === 'undefined') { filters = { q: '', type: 'all', tag: '' }; } %> + <% if (typeof availableTags === 'undefined') { availableTags = []; } %> + <% if (typeof pagination === 'undefined') { pagination = { page: 1, totalPages: 1, totalCount: 0, hasPrev: false, hasNext: false, prevQuery: '', nextQuery: '', pages: [] }; } %> + <% if (typeof adminRequested === 'undefined') { adminRequested = false; } %> + <% if (typeof adminMode === 'undefined') { adminMode = false; } %> + <% if (typeof tokenRaw === 'undefined') { tokenRaw = ''; } %> + <% if (typeof returnQuery === 'undefined') { returnQuery = ''; } %> + <% if (typeof retryQueued === 'undefined') { retryQueued = 0; } %> + <% if (typeof eventsCleared === 'undefined') { eventsCleared = false; } %> + <% if (typeof lectures === 'undefined') { lectures = []; } %> + <% if (typeof thumbnailQueueInfo === 'undefined') { thumbnailQueueInfo = { pending: 0, working: false, maxRetry: 2 }; } %> + <% if (typeof thumbnailStatusSummary === 'undefined') { thumbnailStatusSummary = { ready: 0, processing: 0, failed: 0, pending: 0 }; } %> + <% if (typeof recentEvents === 'undefined') { recentEvents = []; } %> + <% if (typeof failureReasons === 'undefined') { failureReasons = []; } %> + <% var adminBasePath = typeof adminBasePath !== 'undefined' ? adminBasePath : '/admin'; %> + <% var viewerBasePath = typeof viewerBasePath !== 'undefined' ? viewerBasePath : '/learning'; %> + <% var navMenu = typeof navActiveMenu !== 'undefined' ? navActiveMenu : 'learning'; %> +
+ <%- include('partials/nav', { activeMenu: navMenu, adminMode: typeof adminMode !== 'undefined' ? adminMode : false }) %> +
+
+

<%= admTitle %>

+ 목록 보기 +
+
+
+
+

관리자 모드

+
+ <% if (adminMode) { %> + 활성 + 비활성화 + <% } else { %> + 비활성 +
+ + + + + + + +
+ <% } %> +
+
+ <% if (retryQueued > 0) { %> +

실패 건 재시도 <%= retryQueued %>건이 큐에 등록되었습니다.

+ <% } %> + <% if (adminMode) { %> +
+ 큐: <%= thumbnailQueueInfo.pending %> + 워커: <%= thumbnailQueueInfo.working ? "작동중" : "대기" %> + 실패 재시도 최대: <%= thumbnailQueueInfo.maxRetry %> +
+
+ PPT 썸네일 - 준비완료 <%= thumbnailStatusSummary.ready %> + 처리중 <%= thumbnailStatusSummary.processing %> + 대기 <%= thumbnailStatusSummary.pending %> + 실패 <%= thumbnailStatusSummary.failed %> +
+ <% } else { %> + <% if (adminRequested) { %> +

입력한 토큰이 올바르지 않습니다. 다시 확인해주세요.

+ <% } %> +

삭제·썸네일 관리는 관리자 토큰을 입력한 뒤 활성화를 눌러주세요.

+ <% } %> +
+
+ > +
+

유튜브 강의 등록

+
+ + + + + + + + +
+
+
+

PDF/PowerPoint 강의 등록

+
+ + + + + + + + +
+
+
+

동영상 파일 등록

+

MP4·WebM·MOV 파일을 서버에 저장한 뒤 브라우저에서 재생합니다. (용량 제한은 서버 설정 LECTURE_VIDEO_MAX_MB, 기본 500MB)

+
+ + + + + + + + +
+
+
+

웹 링크 강의 등록

+

외부 사이트(http/https)를 카드로 등록합니다. (유튜브는 위 «유튜브 강의 등록»을 사용하세요.)

+
+ + + + + + + + +
+
+
+
+

등록된 강의

+ 총 <%= pagination.totalCount %>건 +
+ <% if (!adminMode && lectures.length > 0) { %> +

수정·삭제를 하려면 위에서 관리자 토큰을 입력하고 관리자 활성화를 눌러주세요.

+ <% } %> +
+ + + + + + +
+ <% if (!lectures.length) { %> +

등록된 항목이 없습니다.

+ <% } else { %> +
+ <% lectures.forEach((lecture) => { %> +
+ <% if (adminMode) { %> +
+ 수정 +
+ + + +
+
+ <% } %> + <% var _externalUrl = (lecture.type === "link" || lecture.type === "news") && (lecture.newsUrl || "").trim(); %> + <% if (_externalUrl) { %> + + <% } else { %> + + <% } %> +
+ <% if (lecture.type === "ppt") { %> + <% if (lecture.thumbnailUrl) { %> + <%= lecture.title %> 썸네일 + <% } else { %> + 썸네일 <%= lecture.thumbnailStatus || "pending" %> + <% } %> + PPT + <% if (lecture.previewTitle && lecture.previewTitle !== "제목 없음") { %><%= lecture.previewTitle %><% } %> + <% } else if (lecture.type === "news") { %> + <% if (lecture.thumbnailUrl) { %> + + <% } %> + 뉴스 + <% if (!lecture.thumbnailUrl) { %>외부 링크<% } %> + <% } else if (lecture.type === "link") { %> + <% if (lecture.thumbnailUrl) { %> + + <% } %> + 웹 링크 + <% if (!lecture.thumbnailUrl) { %>외부 페이지<% } %> + <% } else if (lecture.type === "video") { %> + + 동영상 파일 + 업로드 영상 + <% } else { %> + <% const ytThumb = typeof getYoutubeThumbnailUrl === 'function' ? getYoutubeThumbnailUrl(lecture.youtubeUrl) : null; %> + <% if (ytThumb) { %> + <%= lecture.title %> 썸네일 + <% } %> + YouTube + 영상 강의 + <% } %> +
+
<% if (lecture.type === "youtube") { %>YouTube<% } else if (lecture.type === "news") { %>뉴스<% } else if (lecture.type === "link") { %>링크<% } else if (lecture.type === "video") { %>동영상<% } else { %>PPT<% } %>
+

<%= lecture.title %>

+

<%= lecture.description || "" %>

+
<% (lecture.tags || []).forEach((t) => { %>#<%= t %><% }) %>
+ <%= new Date(lecture.createdAt).toLocaleString("ko-KR") %> +
+ <% if (adminMode && lecture.type === "ppt") { %> +
+ "><%= lecture.thumbnailStatus || "pending" %> + <% if (lecture.thumbnailError) { %><%= lecture.thumbnailError %><% } %> +
+
+
+ + + +
+
+ + + +
+
+ <% } %> +
+ <% }) %> +
+ <% if (pagination.totalPages > 1) { %> + + <% } %> + <% } %> +
+
+ <% if (adminMode) { %> +
+

썸네일 로그

+
+
+ + + +
+
+ + + +
+
+ <% if (eventsCleared) { %> +

썸네일 이벤트 로그가 초기화되었습니다.

+ <% } %> +

+ 이벤트 로그 페이지 열기 +

+
+
+

실패 원인 TOP

+ <% if (!failureReasons.length) { %> +

최근 실패 원인이 없습니다.

+ <% } else { %> +
    + <% failureReasons.slice(0, 5).forEach((item) => { %> +
  • <%= item.count %>회 <%= item.reason %>
  • + <% }) %> +
+ <% } %> +
+
+

최근 썸네일 이벤트

+ <% if (!recentEvents.length) { %> +

이벤트 로그가 없습니다.

+ <% } else { %> +
    + <% recentEvents.forEach((evt) => { %> +
  • + <%= evt.type %> + <%= evt.lectureTitle || evt.lectureId %> + <%= new Date(evt.at).toLocaleString("ko-KR") %> +
  • + <% }) %> +
+ <% } %> +
+
+
+ <% } %> +
+
+
+ + diff --git a/views/learning-edit.ejs b/views/learning-edit.ejs new file mode 100644 index 0000000..5b2c59f --- /dev/null +++ b/views/learning-edit.ejs @@ -0,0 +1,64 @@ + + + + + + 콘텐츠 수정 - XAVIS + + + + <% var adminBasePath = typeof adminBasePath !== 'undefined' ? adminBasePath : '/admin'; %> + <% var navMenu = typeof navActiveMenu !== 'undefined' ? navActiveMenu : 'learning'; %> +
+ <%- include('partials/nav', { activeMenu: navMenu }) %> +
+
+

콘텐츠 수정

+ 목록으로 +
+
+
+

<% if (lecture.type === 'youtube') { %>유튜브<% } else if (lecture.type === 'news') { %>뉴스 URL<% } else if (lecture.type === 'link') { %>웹 링크<% } else if (lecture.type === 'video') { %>동영상 파일<% } else { %>PDF/PPT<% } %> 수정

+
+ + + + <% if (lecture.type === 'youtube') { %> + + <% } else if (lecture.type === 'news') { %> + + <% } else if (lecture.type === 'link') { %> + + <% } else if (lecture.type === 'video') { %> + + <% } else { %> + + <% } %> + + + +
+ + 취소 +
+
+
+
+
+
+ + diff --git a/views/learning-lectures-partial.ejs b/views/learning-lectures-partial.ejs new file mode 100644 index 0000000..747ccf8 --- /dev/null +++ b/views/learning-lectures-partial.ejs @@ -0,0 +1 @@ +<%- include('partials/lecture-cards') %> diff --git a/views/learning-viewer.ejs b/views/learning-viewer.ejs new file mode 100644 index 0000000..d1ad184 --- /dev/null +++ b/views/learning-viewer.ejs @@ -0,0 +1,110 @@ + + + + + + <%= typeof pageTitle !== 'undefined' ? pageTitle : '학습센터' %> - XAVIS + + + + <% if (typeof filters === 'undefined') { filters = { q: '', type: 'all', tag: '' }; } %> + <% if (typeof availableTags === 'undefined') { availableTags = []; } %> + <% if (typeof pagination === 'undefined') { pagination = { page: 1, totalPages: 1, totalCount: 0, hasPrev: false, hasNext: false, prevQuery: '', nextQuery: '', pages: [] }; } %> + <% if (typeof lectures === 'undefined') { lectures = []; } %> + <% var viewerBasePath = typeof viewerBasePath !== 'undefined' ? viewerBasePath : '/learning'; %> + <% var learningApiPath = typeof learningApiPath !== 'undefined' ? learningApiPath : '/api/learning/lectures'; %> + <% var adminRegisterHref = typeof adminRegisterHref !== 'undefined' ? adminRegisterHref : '/admin'; %> + <% var navMenu = typeof navActiveMenu !== 'undefined' ? navActiveMenu : 'learning'; %> + <% var pTitle = typeof pageTitle !== 'undefined' ? pageTitle : '학습센터'; %> + <% var hTitle = typeof heroTitle !== 'undefined' ? heroTitle : '최신 컨텐츠로 학습하고, 바로 업무에 적용하세요.'; %> + <% var hDesc = typeof heroDesc !== 'undefined' ? heroDesc : '유튜브·PPT·동영상 파일·웹 링크를 등록한 뒤, 목록에서 클릭하여 강의를 시청하거나 외부 자료를 열 수 있습니다.'; %> + <% var listHeading = typeof sectionListHeading !== 'undefined' ? sectionListHeading : '등록된 강의'; %> + <% var filterTitle = typeof filterPanelTitle !== 'undefined' ? filterPanelTitle : '강의 검색/필터'; %> + <% var _opsLoggedIn = typeof opsUserEmail !== 'undefined' && opsUserEmail; %> +
+ <%- include('partials/nav', { activeMenu: navMenu }) %> +
+
+

<%= pTitle %>

+ <% if (typeof adminMode !== 'undefined' && adminMode && !_opsLoggedIn) { %> + 콘텐츠 등록하기 + <% } %> +
+
+
+

<%= hTitle %>

+

<%= hDesc %>

+
+
+

<%= filterTitle %>

+
+ + + +
+
+ + + + + +
+
+ + 초기화 +
+
+
+
+
+
+

<%= listHeading %>

+ <%= pagination.totalCount %> +
+
+ <% if (!lectures.length) { %> +

등록된 항목이 없습니다.

+ <% } else { %> +
+ <%- include('partials/lecture-cards') %> +
+ <% if (pagination.hasNext) { %> + + <% } %> + <% } %> +
+
+
+
+
+ + + + diff --git a/views/lecture-link.ejs b/views/lecture-link.ejs new file mode 100644 index 0000000..0dcbd50 --- /dev/null +++ b/views/lecture-link.ejs @@ -0,0 +1,31 @@ + + + + + + <%= lecture.title %> - 학습센터 - XAVIS + + + +
+ <%- include('partials/nav', { activeMenu: 'learning' }) %> +
+
+

<%= lecture.title %>

+ 목록으로 +
+
+
+ <% if (lecture.description) { %> +

<%= lecture.description %>

+ <% } %> +

+ 링크 열기 (새 탭) +

+

URL: <%= lecture.newsUrl %>

+
+
+
+
+ + diff --git a/views/lecture-news.ejs b/views/lecture-news.ejs new file mode 100644 index 0000000..f2943d4 --- /dev/null +++ b/views/lecture-news.ejs @@ -0,0 +1,31 @@ + + + + + + <%= lecture.title %> - 학습센터 - XAVIS + + + +
+ <%- include('partials/nav', { activeMenu: 'learning' }) %> +
+
+

<%= lecture.title %>

+ 목록으로 +
+
+
+ <% if (lecture.description) { %> +

<%= lecture.description %>

+ <% } %> +

+ 기사·원문 열기 (새 탭) +

+

URL: <%= lecture.newsUrl %>

+
+
+
+
+ + diff --git a/views/lecture-ppt.ejs b/views/lecture-ppt.ejs new file mode 100644 index 0000000..59cb94d --- /dev/null +++ b/views/lecture-ppt.ejs @@ -0,0 +1,57 @@ + + + + + + <%= lecture.title %> - PPT 뷰어 + + + + <% if (typeof slideImageUrls === 'undefined') { slideImageUrls = []; } %> +
+ <%- include('partials/nav', { activeMenu: 'learning' }) %> +
+
+ ← 학습센터로 돌아가기 +

<%= lecture.title %>

+

<%= lecture.description || "설명이 없습니다." %>

+ +
+ <%= slides.length %> +
+ + <% if (typeof slidesError !== 'undefined' && slidesError && (!slideImageUrls || slideImageUrls.length === 0)) { %> +

슬라이드 이미지 생성에 실패했습니다. LibreOffice가 설치되어 있는지 확인하세요. (macOS: brew install --cask libreoffice)

+ <% } %> + + <% if (!slides.length) { %> +

슬라이드 내용을 불러올 수 없습니다.

+ <% } %> + +
+ <% slides.forEach((slide, index) => { %> +
+
+

슬라이드 <%= index + 1 %>

+ <% if (slide.title) { %>

<%= slide.title %>

<% } %> +
+ <% if (slideImageUrls[index]) { %> +
+ 슬라이드 <%= index + 1 %> +
+ <% } %> + <% if (slide.lines && slide.lines.length > 0) { %> +
    + <% slide.lines.forEach((line) => { %> +
  • <%= line %>
  • + <% }) %> +
+ <% } %> +
+ <% }) %> +
+
+
+
+ + diff --git a/views/lecture-video.ejs b/views/lecture-video.ejs new file mode 100644 index 0000000..64cffed --- /dev/null +++ b/views/lecture-video.ejs @@ -0,0 +1,32 @@ + + + + + + <%= lecture.title %> - 동영상 강의 + + + +
+ <%- include('partials/nav', { activeMenu: 'learning' }) %> +
+
+ ← 학습센터로 돌아가기 +

<%= lecture.title %>

+

<%= lecture.description || "설명이 없습니다." %>

+
+ +
+
+
+
+ + diff --git a/views/lecture-youtube.ejs b/views/lecture-youtube.ejs new file mode 100644 index 0000000..d79f477 --- /dev/null +++ b/views/lecture-youtube.ejs @@ -0,0 +1,30 @@ + + + + + + <%= lecture.title %> - YouTube 강의 + + + +
+ <%- include('partials/nav', { activeMenu: 'learning' }) %> +
+
+ ← 학습센터로 돌아가기 +

<%= lecture.title %>

+

<%= lecture.description || "설명이 없습니다." %>

+
+ +
+
+
+
+ + diff --git a/views/login.ejs b/views/login.ejs new file mode 100644 index 0000000..ca4a744 --- /dev/null +++ b/views/login.ejs @@ -0,0 +1,236 @@ + + + + + + 서비스 접속 - XAVIS + + + + + + + + diff --git a/views/meeting-minutes.ejs b/views/meeting-minutes.ejs new file mode 100644 index 0000000..2cb39eb --- /dev/null +++ b/views/meeting-minutes.ejs @@ -0,0 +1,780 @@ + + + + + + 회의록 AI - XAVIS + + + + <% var mmDefaultCustomInstructions = `아래 회의 내용(또는 녹취/메모)을 바탕으로 다음 형식으로 정리해 주세요. + +원문·전사 전체를 회의록에 다시 붙여 넣지 마세요. ‘스크립트’·‘스크랩트’(오타)·‘원문 전사’ 같은 섹션은 만들지 말고, 요약·결정·액션·체크리스트만 작성하세요. 회의 제목·참석자·요약 등은 ## 마크다운 제목으로 구분하세요. + +1) 회의 개요: 일시, 참석자(알 수 있는 경우), 목적 +2) 논의 안건별 요약 +3) 결정 사항 (명확한 문장으로) +4) 액션 아이템: 별도 섹션. 각 항목에 What(할 일)·Who(담당자)·When(기한)을 구체적으로 +5) 회의 체크리스트: 전·중·후 준비·검토 항목을 [ ]로 (완료 여부 표시 가능하게) +체크리스트·액션 아이템 다음에 "추가 메모 / 확인 필요 사항" 같은 말미 섹션이나 CSV·템플릿 제안은 넣지 마세요. +체크리스트를 마지막 섹션으로 두고, 시연·피드백 회신 방식, 우선순위 재정렬·담당·기한 확정, DRM·후보군 추가 작성 제안 등 운영 안내 문단은 붙이지 마세요. +‘추가 권고’, ‘회의록 작성자의 제안’, 엑셀 템플릿·추적 체크리스트 제안 등은 넣지 마세요.`; %> + <% var mmNow = new Date(); var mmTodayIso = mmNow.getFullYear() + '-' + ('0' + (mmNow.getMonth() + 1)).slice(-2) + '-' + ('0' + mmNow.getDate()).slice(-2); %> +
+ <%- include('partials/nav', { activeMenu: 'ai-explore', adminMode: typeof adminMode !== 'undefined' ? adminMode : false }) %> +
+
+

회의록 AI

+ AI 목록 +
+
+ ← AI + <% var hasEmail = typeof meetingUserEmail !== 'undefined' && meetingUserEmail; %> + <% if (!hasEmail) { %> +

+ 이메일 인증(OPS PROD) 또는 DEV에서 관리자 모드, SUPER(데모)에서는 별도 로그인 없이 이용할 수 있습니다. (DEV 테스트: MEETING_DEV_EMAIL) +

+ <% } %> +
+ +
+
+
+

출력 형식 (프롬프트)

+ +
+ +
+ +
+
+ + +
+ +
+
+
+ 제목 + 날짜 +
+
+ /> +
+ /> + +
+
+
+ + +
+ +
+
+ + +
+ + + + +
+
+
+
+
+ + + + + + diff --git a/views/partials/admin-button.ejs b/views/partials/admin-button.ejs new file mode 100644 index 0000000..e18957d --- /dev/null +++ b/views/partials/admin-button.ejs @@ -0,0 +1,8 @@ +<% + const label = typeof buttonLabel !== 'undefined' && buttonLabel ? buttonLabel : '관리자'; +%> +<% if (typeof adminMode !== 'undefined' && adminMode) { %> +<%= label %> +<% } else { %> + +<% } %> diff --git a/views/partials/admin-token-modal.ejs b/views/partials/admin-token-modal.ejs new file mode 100644 index 0000000..982aaa7 --- /dev/null +++ b/views/partials/admin-token-modal.ejs @@ -0,0 +1,86 @@ + + diff --git a/views/partials/lecture-card.ejs b/views/partials/lecture-card.ejs new file mode 100644 index 0000000..707636f --- /dev/null +++ b/views/partials/lecture-card.ejs @@ -0,0 +1,54 @@ +<% var _externalUrl = (lecture.type === "link" || lecture.type === "news") && (lecture.newsUrl || "").trim(); %> +
+ <% if (_externalUrl) { %> + + <% } else { %> + + <% } %> +
+ <% if (lecture.type === "ppt") { %> + <% if (lecture.thumbnailUrl) { %> + <%= lecture.title %> 썸네일 + <% } else { %> + 썸네일 <%= lecture.thumbnailStatus || "pending" %> + <% } %> + PPT 프리뷰 + <% if (lecture.previewTitle && lecture.previewTitle !== "제목 없음") { %><%= lecture.previewTitle %><% } %> + <% } else if (lecture.type === "news") { %> + <% if (lecture.thumbnailUrl) { %> + + <% } %> + 뉴스 + <% if (!lecture.thumbnailUrl) { %>외부 링크<% } %> + <% } else if (lecture.type === "link") { %> + <% if (lecture.thumbnailUrl) { %> + + <% } %> + 웹 링크 + <% if (!lecture.thumbnailUrl) { %>외부 페이지<% } %> + <% } else if (lecture.type === "video") { %> + + 동영상 파일 + 업로드 영상 + <% } else { %> + <% const ytThumb = typeof getYoutubeThumbnailUrl === 'function' ? getYoutubeThumbnailUrl(lecture.youtubeUrl) : null; %> + <% if (ytThumb) { %> + <%= lecture.title %> 썸네일 + <% } %> + YouTube + 영상 강의 + <% } %> +
+
+ <% if (lecture.type === "youtube") { %>YouTube<% } else if (lecture.type === "news") { %>뉴스<% } else if (lecture.type === "link") { %>링크<% } else if (lecture.type === "video") { %>동영상<% } else { %>PPT<% } %> +
+

<%= lecture.title %>

+

<%= lecture.description || "설명이 없습니다." %>

+
+ <% (lecture.tags || []).forEach((oneTag) => { %> + #<%= oneTag %> + <% }) %> +
+ <%= new Date(lecture.createdAt).toLocaleString("ko-KR") %> +
+
diff --git a/views/partials/lecture-cards.ejs b/views/partials/lecture-cards.ejs new file mode 100644 index 0000000..3acd01e --- /dev/null +++ b/views/partials/lecture-cards.ejs @@ -0,0 +1,3 @@ +<% (lectures || []).forEach((lecture) => { %> +<%- include('lecture-card', { lecture }) %> +<% }) %> diff --git a/views/partials/nav.ejs b/views/partials/nav.ejs new file mode 100644 index 0000000..8355913 --- /dev/null +++ b/views/partials/nav.ejs @@ -0,0 +1,120 @@ + + + + +<%- include('admin-token-modal') %> diff --git a/views/partials/success-story-card.ejs b/views/partials/success-story-card.ejs new file mode 100644 index 0000000..b491d6c --- /dev/null +++ b/views/partials/success-story-card.ejs @@ -0,0 +1,43 @@ +<% var detailAllowed = typeof successStoryDetailAllowed !== 'undefined' ? successStoryDetailAllowed : true; %> +
+ <% if (detailAllowed) { %> + + +
<%= story.department || "사내" %> · <%= story.author || "" %>
+

<%= story.title %>

+

<%= story.excerpt || "" %>

+
+ <% (story.tags || []).forEach((oneTag) => { %> + #<%= oneTag %> + <% }) %> +
+ + <% if (story.publishedAt) { %><%= story.publishedAt %><% } %> + +
+ <% } else { %> +
+ +
<%= story.department || "사내" %> · <%= story.author || "" %>
+

<%= story.title %>

+

<%= story.excerpt || "" %>

+
+ <% (story.tags || []).forEach((oneTag) => { %> + #<%= oneTag %> + <% }) %> +
+ + <% if (story.publishedAt) { %><%= story.publishedAt %><% } %> + +
+ <% } %> +
diff --git a/views/task-checklist.ejs b/views/task-checklist.ejs new file mode 100644 index 0000000..6095a4f --- /dev/null +++ b/views/task-checklist.ejs @@ -0,0 +1,481 @@ + + + + + + 업무 체크리스트 AI - XAVIS + + + +
+ <%- include('partials/nav', { activeMenu: 'ai-explore', adminMode: typeof adminMode !== 'undefined' ? adminMode : false }) %> +
+
+

업무 체크리스트 AI

+ AI 목록 +
+
+ ← AI + <% var hasEmail = typeof meetingUserEmail !== 'undefined' && meetingUserEmail; %> + <% if (!hasEmail) { %> +

+ 이메일 인증(OPS PROD) 또는 DEV에서 관리자 모드, SUPER(데모)에서는 별도 로그인 없이 이용할 수 있습니다. +

+ <% } else { %> +

+ 회의록 AI에서 저장한 내용을 바탕으로 할 일이 모입니다. 회의록의 액션 아이템(번호별)회의 체크리스트가 모두 항목으로 반영됩니다. 진행상황 또는 회의록을 바꿀 때마다 선택한 범위에서 자동으로 가져온 뒤 목록을 맞춥니다. 항목에 마우스를 올리면 해당 회의 제목·일자·요약을 볼 수 있습니다. +

+ +
+
+ + +
+
+ +
+
+ 정렬 + + + +
+

업무 체크리스트

+ +
    +
    + + + + + <% } %> +
    +
    +
    + <% if (hasEmail) { %> + + <% } %> + +