Initial commit: AI platform app (server, views, lib, data, deploy docs)
Made-with: Cursor
This commit is contained in:
72
.env.example
Normal file
72
.env.example
Normal file
@@ -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
|
||||||
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal file
@@ -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
|
||||||
514
README.md
Normal file
514
README.md
Normal file
@@ -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 변환 파이프라인) 연동이 필요합니다.
|
||||||
|
- 이벤트 로그 페이지의 실시간 그래프는 클라이언트 폴링 기반이며, 다수 접속 시 폴링 주기 조정이 필요할 수 있습니다.
|
||||||
|
|
||||||
22
data/ai-success-stories.json
Normal file
22
data/ai-success-stories.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
86
data/ax-assignments.json
Normal file
86
data/ax-assignments.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
255
data/check-queue.html
Normal file
255
data/check-queue.html
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>학습센터</title>
|
||||||
|
<link rel="stylesheet" href="/public/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-shell">
|
||||||
|
<aside class="left-nav">
|
||||||
|
<div class="logo">DW</div>
|
||||||
|
<a href="#" class="nav-item">채팅</a>
|
||||||
|
<a href="#" class="nav-item">AI</a>
|
||||||
|
<a href="/" class="nav-item active">학습센터</a>
|
||||||
|
<a href="#" class="nav-item">과제신청</a>
|
||||||
|
<a href="#" class="nav-item">성공사례</a>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="content-area">
|
||||||
|
<header class="topbar">
|
||||||
|
<h1>학습센터</h1>
|
||||||
|
<a class="top-action-link" href="#register">강의 등록하기</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
<section class="hero panel">
|
||||||
|
<h2>최신 컨텐츠로 학습하고, 바로 업무에 적용하세요.</h2>
|
||||||
|
<p>유튜브 링크 또는 PPT를 등록한 뒤, 목록에서 클릭하여 강의를 시청할 수 있습니다.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel filter-panel">
|
||||||
|
<h2>강의 검색/필터</h2>
|
||||||
|
<form action="/" method="get" class="filter-grid">
|
||||||
|
<label>
|
||||||
|
검색어
|
||||||
|
<input type="text" name="q" value="" placeholder="제목" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
타입
|
||||||
|
<select name="type">
|
||||||
|
<option value="all" selected>전체</option>
|
||||||
|
<option value="youtube" >YouTube</option>
|
||||||
|
<option value="ppt" >PPT</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
태그
|
||||||
|
<select name="tag">
|
||||||
|
<option value="">전체</option>
|
||||||
|
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<input type="hidden" name="admin" value="1" />
|
||||||
|
<input type="hidden" name="token" value="test-token" />
|
||||||
|
|
||||||
|
<div class="filter-actions">
|
||||||
|
<button type="submit">필터 적용</button>
|
||||||
|
<a class="link-muted" href="/">초기화</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel admin-panel">
|
||||||
|
<h2>관리자 모드</h2>
|
||||||
|
|
||||||
|
|
||||||
|
<p class="admin-ok">관리자 모드 활성화됨</p>
|
||||||
|
<div class="queue-status">
|
||||||
|
<span>큐: <b>1</b></span>
|
||||||
|
<span>워커: <b>작동중</b></span>
|
||||||
|
<span>실패 재시도 최대: <b>2</b></span>
|
||||||
|
</div>
|
||||||
|
<div class="queue-status">
|
||||||
|
<span>PPT 썸네일 - 준비완료 <b>0</b></span>
|
||||||
|
<span>처리중 <b>0</b></span>
|
||||||
|
<span>대기 <b>0</b></span>
|
||||||
|
<span>실패 <b>2</b></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action="/" method="get" class="admin-inline">
|
||||||
|
<input type="hidden" name="q" value="" />
|
||||||
|
<input type="hidden" name="type" value="all" />
|
||||||
|
<input type="hidden" name="tag" value="" />
|
||||||
|
<input type="hidden" name="page" value="1" />
|
||||||
|
<input type="hidden" name="admin" value="1" />
|
||||||
|
<input type="password" name="token" placeholder="관리자 토큰" />
|
||||||
|
<button type="submit">관리자 활성화</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form action="/thumbnails/retry-failed" method="post" class="admin-inline">
|
||||||
|
<input type="hidden" name="token" value="test-token" />
|
||||||
|
<input type="hidden" name="returnTo" value="?admin=1&token=test-token" />
|
||||||
|
<button type="submit" class="ghost">실패 썸네일 일괄 재시도</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="register" class="panel">
|
||||||
|
<h2>유튜브 강의 등록</h2>
|
||||||
|
<form action="/lectures/youtube" method="post" class="form-grid">
|
||||||
|
<label>
|
||||||
|
제목
|
||||||
|
<input type="text" name="title" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
유튜브 링크
|
||||||
|
<input type="url" name="youtubeUrl" placeholder="https://www.youtube.com/watch?v=..." required />
|
||||||
|
</label>
|
||||||
|
<label class="full">
|
||||||
|
설명
|
||||||
|
<textarea name="description" rows="3" placeholder="강의 설명"></textarea>
|
||||||
|
</label>
|
||||||
|
<label class="full">
|
||||||
|
태그 (쉼표 구분)
|
||||||
|
<input type="text" name="tags" placeholder="예: AI에이전트, 바이브코딩" />
|
||||||
|
</label>
|
||||||
|
<button type="submit">유튜브 강의 등록</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>PowerPoint 강의 등록</h2>
|
||||||
|
<form action="/lectures/ppt" method="post" enctype="multipart/form-data" class="form-grid">
|
||||||
|
<label>
|
||||||
|
제목
|
||||||
|
<input type="text" name="title" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
PPT 파일(.pptx)
|
||||||
|
<input type="file" name="pptFile" accept=".pptx" required />
|
||||||
|
</label>
|
||||||
|
<label class="full">
|
||||||
|
설명
|
||||||
|
<textarea name="description" rows="3" placeholder="강의 설명"></textarea>
|
||||||
|
</label>
|
||||||
|
<label class="full">
|
||||||
|
태그 (쉼표 구분)
|
||||||
|
<input type="text" name="tags" placeholder="예: 프롬프트, 생성형AI" />
|
||||||
|
</label>
|
||||||
|
<button type="submit">강의 등록</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>등록된 강의</h2>
|
||||||
|
<span class="count-chip">총 2건</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="lecture-grid">
|
||||||
|
|
||||||
|
<article class="lecture-card">
|
||||||
|
<a class="lecture-link" href="/lectures/51ee515c-fff0-4156-97ac-09ec0b412345">
|
||||||
|
<div class="thumb ppt">
|
||||||
|
|
||||||
|
|
||||||
|
<span class="thumb-fallback">썸네일 failed</span>
|
||||||
|
|
||||||
|
<span class="thumb-kicker">PPT 프리뷰</span>
|
||||||
|
<strong>제목 없음</strong>
|
||||||
|
<small>1장</small>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="badge ppt">
|
||||||
|
PPT
|
||||||
|
</div>
|
||||||
|
<h3>생성형AI, LLM, 에이전틱 AI 이해하기</h3>
|
||||||
|
<p>초기 샘플 PPT 강의</p>
|
||||||
|
<div class="tag-row">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<small>2026. 3. 14. AM 11:23:13</small>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<form action="/lectures/51ee515c-fff0-4156-97ac-09ec0b412345/delete" method="post" class="delete-form">
|
||||||
|
<input type="hidden" name="token" value="test-token" />
|
||||||
|
<input type="hidden" name="returnTo" value="?admin=1&token=test-token" />
|
||||||
|
<button type="submit" class="danger">삭제</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="thumb-state-row">
|
||||||
|
<span class="state-chip failed">
|
||||||
|
failed
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<small class="error-text">썸네일 생성 도구 실행 실패 또는 미설치</small>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<form action="/lectures/51ee515c-fff0-4156-97ac-09ec0b412345/thumbnail/regenerate" method="post" class="delete-form">
|
||||||
|
<input type="hidden" name="token" value="test-token" />
|
||||||
|
<input type="hidden" name="returnTo" value="?admin=1&token=test-token" />
|
||||||
|
<button type="submit" class="ghost">썸네일 재생성</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="lecture-card">
|
||||||
|
<a class="lecture-link" href="/lectures/4a937c1e-98cb-402f-abd4-497120212974">
|
||||||
|
<div class="thumb ppt">
|
||||||
|
|
||||||
|
|
||||||
|
<span class="thumb-fallback">썸네일 failed</span>
|
||||||
|
|
||||||
|
<span class="thumb-kicker">PPT 프리뷰</span>
|
||||||
|
<strong>제목 없음</strong>
|
||||||
|
<small>12장</small>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="badge ppt">
|
||||||
|
PPT
|
||||||
|
</div>
|
||||||
|
<h3>claude cowork 가이드</h3>
|
||||||
|
<p>초기 샘플 PPT 강의</p>
|
||||||
|
<div class="tag-row">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<small>2026. 3. 14. AM 11:23:13</small>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<form action="/lectures/4a937c1e-98cb-402f-abd4-497120212974/delete" method="post" class="delete-form">
|
||||||
|
<input type="hidden" name="token" value="test-token" />
|
||||||
|
<input type="hidden" name="returnTo" value="?admin=1&token=test-token" />
|
||||||
|
<button type="submit" class="danger">삭제</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="thumb-state-row">
|
||||||
|
<span class="state-chip failed">
|
||||||
|
failed
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<small class="error-text">썸네일 생성 도구 실행 실패 또는 미설치</small>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<form action="/lectures/4a937c1e-98cb-402f-abd4-497120212974/thumbnail/regenerate" method="post" class="delete-form">
|
||||||
|
<input type="hidden" name="token" value="test-token" />
|
||||||
|
<input type="hidden" name="returnTo" value="?admin=1&token=test-token" />
|
||||||
|
<button type="submit" class="ghost">썸네일 재생성</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
|
||||||
|
</article>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
58
data/company-prompts.json
Normal file
58
data/company-prompts.json
Normal file
@@ -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[여기에 입력]"
|
||||||
|
}
|
||||||
|
]
|
||||||
36
data/lectures.json
Normal file
36
data/lectures.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
1
data/thumbnail-jobs.json
Normal file
1
data/thumbnail-jobs.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
19
db/bootstrap-role.sql.example
Normal file
19
db/bootstrap-role.sql.example
Normal file
@@ -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;
|
||||||
3
db/migrations/add-lecture-type-video.sql
Normal file
3
db/migrations/add-lecture-type-video.sql
Normal file
@@ -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'));
|
||||||
272
db/schema.sql
Normal file
272
db/schema.sql
Normal file
@@ -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);
|
||||||
58
deploy/apache-ai.ncue.net-ssl.conf.example
Normal file
58
deploy/apache-ai.ncue.net-ssl.conf.example
Normal file
@@ -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 수동 설정을 검토하세요.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
<IfModule mod_ssl.c>
|
||||||
|
<VirtualHost *:443>
|
||||||
|
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)
|
||||||
|
<IfModule mod_proxy_http.c>
|
||||||
|
ProxyAddHeaders On
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
<Location />
|
||||||
|
<RequireAll>
|
||||||
|
Require all granted
|
||||||
|
Require not env bad_bot
|
||||||
|
</RequireAll>
|
||||||
|
</Location>
|
||||||
|
|
||||||
|
SSLCertificateFile /etc/letsencrypt/live/ncue.net-0001/fullchain.pem
|
||||||
|
SSLCertificateKeyFile /etc/letsencrypt/live/ncue.net-0001/privkey.pem
|
||||||
|
</VirtualHost>
|
||||||
|
</IfModule>
|
||||||
44
docs/AX-APPLY-ARRAY-ERROR-ANALYSIS.md
Normal file
44
docs/AX-APPLY-ARRAY-ERROR-ANALYSIS.md
Normal file
@@ -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가 배포 서버에 반영되었는지 확인
|
||||||
239
docs/DEPLOYMENT-xavis.ncue.net.md
Normal file
239
docs/DEPLOYMENT-xavis.ncue.net.md
Normal file
@@ -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`
|
||||||
97
lib/link-preview.js
Normal file
97
lib/link-preview.js
Normal file
@@ -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 = [
|
||||||
|
/<meta\s[^>]*property=["']og:image["'][^>]*content=["']([^"']+)["']/i,
|
||||||
|
/<meta\s[^>]*content=["']([^"']+)["'][^>]*property=["']og:image["']/i,
|
||||||
|
/<meta\s[^>]*name=["']twitter:image:src["'][^>]*content=["']([^"']+)["']/i,
|
||||||
|
/<meta\s[^>]*content=["']([^"']+)["'][^>]*name=["']twitter:image:src["']/i,
|
||||||
|
/<meta\s[^>]*name=["']twitter:image["'][^>]*content=["']([^"']+)["']/i,
|
||||||
|
/<meta\s[^>]*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<string|null>} 절대 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 };
|
||||||
20
lib/meeting-date-format.js
Normal file
20
lib/meeting-date-format.js
Normal file
@@ -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 };
|
||||||
458
lib/meeting-minutes-store.js
Normal file
458
lib/meeting-minutes-store.js
Normal file
@@ -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<Map<string, { meetingTitle: string, meetingDate: string|null, meetingSummary: string }>>}
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
};
|
||||||
78
lib/meeting-minutes-summary.js
Normal file
78
lib/meeting-minutes-summary.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
752
lib/meeting-minutes.js
Normal file
752
lib/meeting-minutes.js
Normal file
@@ -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<Array<{ title: string, detail: string, assignee: string|null, due_note: string|null, completed: boolean }>>}
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
};
|
||||||
35
lib/ops-state.js
Normal file
35
lib/ops-state.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
354
lib/parse-checklist-from-minutes.js
Normal file
354
lib/parse-checklist-from-minutes.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
323
lib/task-checklist-store.js
Normal file
323
lib/task-checklist-store.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
185
lib/thumbnail-events-store.js
Normal file
185
lib/thumbnail-events-store.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
446
ops-auth.js
Normal file
446
ops-auth.js
Normal file
@@ -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, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인증 메일 본문 HTML — 인라인 스타일·테이블 기반(주요 클라이언트 호환)
|
||||||
|
*/
|
||||||
|
function buildMagicLinkEmailHtml(linkUrl, linkMinutes) {
|
||||||
|
const href = String(linkUrl).replace(/&/g, "&").replace(/"/g, """);
|
||||||
|
const linkText = escapeHtmlEmail(linkUrl);
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>인증 안내</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:0;background-color:#f1f5f9;-webkit-text-size-adjust:100%;">
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color:#f1f5f9;padding:24px 12px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="max-width:560px;background-color:#ffffff;border-radius:12px;overflow:hidden;border:1px solid #e2e8f0;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding:28px 22px 8px 22px;font-family:'Apple SD Gothic Neo','Malgun Gothic',Helvetica,Arial,sans-serif;">
|
||||||
|
<p style="margin:0 0 4px 0;font-size:13px;font-weight:700;letter-spacing:0.02em;color:#dc2626;">XAVIS</p>
|
||||||
|
<p style="margin:0 0 20px 0;font-size:17px;font-weight:700;color:#0f172a;line-height:1.35;">AI Platform</p>
|
||||||
|
<p style="margin:0 0 14px 0;font-size:15px;line-height:1.65;color:#334155;">계정 인증을 위해 아래의 인증 링크를 안내드립니다.</p>
|
||||||
|
<p style="margin:0 0 24px 0;font-size:15px;line-height:1.65;color:#334155;">아래 버튼을 누르시면 인증이 완료되며, 이후 서비스를 정상적으로 이용하실 수 있습니다.</p>
|
||||||
|
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" style="margin:0 auto 20px auto;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="border-radius:8px;background-color:#2563eb;">
|
||||||
|
<a href="${href}" target="_blank" rel="noopener noreferrer" style="display:inline-block;padding:14px 32px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;font-family:'Apple SD Gothic Neo','Malgun Gothic',Helvetica,Arial,sans-serif;">인증 완료하기</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p style="margin:0 0 8px 0;font-size:12px;line-height:1.5;color:#64748b;">버튼이 동작하지 않으면 아래 주소를 브라우저에 복사해 붙여 넣어 주세요.</p>
|
||||||
|
<p style="margin:0 0 22px 0;font-size:12px;line-height:1.5;color:#2563eb;word-break:break-all;"><a href="${href}" style="color:#2563eb;text-decoration:underline;">${linkText}</a></p>
|
||||||
|
<div style="border-top:1px solid #e2e8f0;padding-top:18px;margin-top:4px;">
|
||||||
|
<p style="margin:0 0 8px 0;font-size:13px;line-height:1.6;color:#64748b;">※ 본 인증 링크는 보안을 위해 발송 시점으로부터 <strong style="color:#475569;">${linkMinutes}분</strong> 동안만 유효합니다.</p>
|
||||||
|
<p style="margin:0 0 18px 0;font-size:13px;line-height:1.6;color:#64748b;">※ 인증 시간이 만료된 경우, 다시 인증 메일을 요청해 주시기 바랍니다.</p>
|
||||||
|
<p style="margin:0 0 6px 0;font-size:13px;line-height:1.6;color:#64748b;">본 메일은 발신 전용이며, 문의 사항이 있으실 경우 <strong style="color:#475569;">AI혁신팀</strong>으로 문의 부탁합니다.</p>
|
||||||
|
<p style="margin:0;font-size:14px;font-weight:600;color:#0f172a;">감사합니다.</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<p style="margin:16px 0 0 0;font-size:11px;line-height:1.4;color:#94a3b8;font-family:'Apple SD Gothic Neo','Malgun Gothic',Helvetica,Arial,sans-serif;">© XAVIS · 본 메일은 시스템에 의해 자동 발송되었습니다.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
39
package.json
Normal file
39
package.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
public/images/aiplatform-logo.png
Normal file
BIN
public/images/aiplatform-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
BIN
public/images/xavis-logo.png
Normal file
BIN
public/images/xavis-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
227
public/js/learning-infinite.js
Normal file
227
public/js/learning-infinite.js
Normal file
@@ -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 = '<p class="empty" id="lecture-empty-msg">등록된 항목이 없습니다.</p>';
|
||||||
|
} else {
|
||||||
|
var nextP = data.nextPage != null ? data.nextPage : 2;
|
||||||
|
var sentinelBlock = data.hasNext
|
||||||
|
? '<div id="lecture-infinite-footer"><div id="infinite-scroll-sentinel" class="infinite-scroll-sentinel" data-next-page="' +
|
||||||
|
String(nextP) +
|
||||||
|
'" data-has-next="true"></div><p class="infinite-scroll-loading" id="infinite-scroll-loading" style="display:none;text-align:center;padding:16px;color:#666;">불러오는 중...</p><div class="lecture-load-more-wrap" id="lecture-load-more-wrap"><button type="button" class="lecture-load-more-btn" id="lecture-load-more-btn">더 불러오기</button></div></div>'
|
||||||
|
: "";
|
||||||
|
resultsRoot.innerHTML = '<div class="lecture-grid" id="lecture-grid">' + data.html + "</div>" + 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();
|
||||||
|
})();
|
||||||
Binary file not shown.
Binary file not shown.
BIN
public/resources/ai-success/자비스_AI성공사례_조정숙과장.pdf
Normal file
BIN
public/resources/ai-success/자비스_AI성공사례_조정숙과장.pdf
Normal file
Binary file not shown.
3250
public/styles.css
Normal file
3250
public/styles.css
Normal file
File diff suppressed because it is too large
Load Diff
BIN
resources/ax-apply/AX_과제_신청서.docx
Normal file
BIN
resources/ax-apply/AX_과제_신청서.docx
Normal file
Binary file not shown.
62
scripts/apply-schema.js
Normal file
62
scripts/apply-schema.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
32
scripts/check-ax-schema.js
Normal file
32
scripts/check-ax-schema.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
269
scripts/migrate-db-ncue-to-env.js
Normal file
269
scripts/migrate-db-ncue-to-env.js
Normal file
@@ -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();
|
||||||
69
scripts/normalize-stored-meeting-minutes.js
Normal file
69
scripts/normalize-stored-meeting-minutes.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
42
scripts/test-ax-apply.js
Normal file
42
scripts/test-ax-apply.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
64
scripts/test-ax-insert.js
Normal file
64
scripts/test-ax-insert.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
238
views/admin-thumbnail-events.ejs
Normal file
238
views/admin-thumbnail-events.ejs
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>썸네일 이벤트 로그</title>
|
||||||
|
<link rel="stylesheet" href="/public/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-shell">
|
||||||
|
<%- include('partials/nav', { activeMenu: 'learning' }) %>
|
||||||
|
|
||||||
|
<div class="content-area">
|
||||||
|
<header class="topbar">
|
||||||
|
<h1>썸네일 이벤트 로그</h1>
|
||||||
|
<a class="top-action-link" href="/admin?token=<%= token %>">대시보드로</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
<section class="panel">
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>실시간 요약</h2>
|
||||||
|
<div class="admin-inline">
|
||||||
|
<label class="inline">
|
||||||
|
<input type="checkbox" id="pollToggle" checked />
|
||||||
|
자동 새로고침
|
||||||
|
</label>
|
||||||
|
<label class="inline">
|
||||||
|
간격(초)
|
||||||
|
<select id="pollIntervalSec">
|
||||||
|
<option value="5">5</option>
|
||||||
|
<option value="10" selected>10</option>
|
||||||
|
<option value="20">20</option>
|
||||||
|
<option value="30">30</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="api-hint">요약 API: <code>/api/queue/events-summary?token=***&hours=24</code></p>
|
||||||
|
<div class="kpi-grid">
|
||||||
|
<article class="kpi-card">
|
||||||
|
<small>처리 건수(24h)</small>
|
||||||
|
<b id="kpiProcessed">-</b>
|
||||||
|
</article>
|
||||||
|
<article class="kpi-card">
|
||||||
|
<small>실패율(24h)</small>
|
||||||
|
<b id="kpiFailureRate">-</b>
|
||||||
|
</article>
|
||||||
|
<article class="kpi-card">
|
||||||
|
<small>평균 소요(ms)</small>
|
||||||
|
<b id="kpiAvgDuration">-</b>
|
||||||
|
</article>
|
||||||
|
<article class="kpi-card">
|
||||||
|
<small>현재 큐</small>
|
||||||
|
<b id="kpiQueue">-</b>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
<div class="chart-grid">
|
||||||
|
<section class="mini-panel">
|
||||||
|
<h3>시간대별 처리량(24h)</h3>
|
||||||
|
<div id="processedChart" class="bar-chart"></div>
|
||||||
|
</section>
|
||||||
|
<section class="mini-panel">
|
||||||
|
<h3>시간대별 실패량(24h)</h3>
|
||||||
|
<div id="failedChart" class="bar-chart"></div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>필터</h2>
|
||||||
|
<% if (typeof eventsCleared !== 'undefined' && eventsCleared) { %>
|
||||||
|
<p class="admin-ok">썸네일 이벤트 로그가 초기화되었습니다.</p>
|
||||||
|
<% } %>
|
||||||
|
<form action="/admin/thumbnail-events" method="get" class="filter-grid">
|
||||||
|
<input type="hidden" name="token" value="<%= token %>" />
|
||||||
|
<label>
|
||||||
|
이벤트 타입
|
||||||
|
<select name="eventType">
|
||||||
|
<option value="all" <%= filters.eventType === "all" ? "selected" : "" %>>전체</option>
|
||||||
|
<option value="enqueue" <%= filters.eventType === "enqueue" ? "selected" : "" %>>enqueue</option>
|
||||||
|
<option value="start" <%= filters.eventType === "start" ? "selected" : "" %>>start</option>
|
||||||
|
<option value="success" <%= filters.eventType === "success" ? "selected" : "" %>>success</option>
|
||||||
|
<option value="failed" <%= filters.eventType === "failed" ? "selected" : "" %>>failed</option>
|
||||||
|
<option value="worker-error" <%= filters.eventType === "worker-error" ? "selected" : "" %>>worker-error</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Lecture ID
|
||||||
|
<input type="text" name="lectureId" value="<%= filters.lectureId %>" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Reason
|
||||||
|
<input type="text" name="reason" value="<%= filters.reason %>" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
From
|
||||||
|
<input type="date" name="from" value="<%= filters.from %>" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
To
|
||||||
|
<input type="date" name="to" value="<%= filters.to %>" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
페이지 크기
|
||||||
|
<input type="number" min="10" max="200" name="limit" value="<%= filters.limit %>" />
|
||||||
|
</label>
|
||||||
|
<div class="filter-actions">
|
||||||
|
<button type="submit">적용</button>
|
||||||
|
<a class="link-muted" href="/admin/thumbnail-events?token=<%= token %>">필터 초기화</a>
|
||||||
|
<a class="link-muted" href="/admin/thumbnail-events.csv?<%= csvQuery %>">CSV 다운로드</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<form action="/thumbnails/events/clear" method="post" class="admin-inline" style="margin-top:8px" onsubmit="return confirm('썸네일 이벤트 로그를 모두 삭제하시겠습니까?');">
|
||||||
|
<input type="hidden" name="token" value="<%= token %>" />
|
||||||
|
<input type="hidden" name="returnTo" value="/admin/thumbnail-events?token=<%= token %>" />
|
||||||
|
<button type="submit" class="ghost">로그 초기화</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>이벤트 목록</h2>
|
||||||
|
<span class="count-chip">총 <%= pagination.totalCount %>건</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if (!events.length) { %>
|
||||||
|
<p class="empty">조회 결과가 없습니다.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="event-table-wrap">
|
||||||
|
<table class="event-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>시간</th>
|
||||||
|
<th>타입</th>
|
||||||
|
<th>강의</th>
|
||||||
|
<th>사유</th>
|
||||||
|
<th>재시도</th>
|
||||||
|
<th>소요(ms)</th>
|
||||||
|
<th>에러</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% events.forEach((evt) => { %>
|
||||||
|
<tr>
|
||||||
|
<td><%= new Date(evt.at).toLocaleString("ko-KR") %></td>
|
||||||
|
<td><span class="evt-type <%= evt.type %>"><%= evt.type %></span></td>
|
||||||
|
<td>
|
||||||
|
<div><%= evt.lectureTitle || "-" %></div>
|
||||||
|
<small><%= evt.lectureId || "-" %></small>
|
||||||
|
</td>
|
||||||
|
<td><%= evt.reason || "-" %></td>
|
||||||
|
<td><%= evt.retryCount ?? "-" %></td>
|
||||||
|
<td><%= evt.durationMs ?? "-" %></td>
|
||||||
|
<td><%= evt.error || "-" %></td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<% if (pagination.totalPages > 1) { %>
|
||||||
|
<nav class="pagination">
|
||||||
|
<% if (pagination.hasPrev) { %>
|
||||||
|
<a href="/admin/thumbnail-events?<%= pagination.prevQuery %>">이전</a>
|
||||||
|
<% } %>
|
||||||
|
<% pagination.pages.forEach((p) => { %>
|
||||||
|
<a href="/admin/thumbnail-events?<%= p.query %>" class="<%= p.active ? "active" : "" %>"><%= p.page %></a>
|
||||||
|
<% }) %>
|
||||||
|
<% if (pagination.hasNext) { %>
|
||||||
|
<a href="/admin/thumbnail-events?<%= pagination.nextQuery %>">다음</a>
|
||||||
|
<% } %>
|
||||||
|
</nav>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const ADMIN_TOKEN = <%- JSON.stringify(token) %>;
|
||||||
|
const POLL_TOGGLE = document.getElementById("pollToggle");
|
||||||
|
const POLL_INTERVAL = document.getElementById("pollIntervalSec");
|
||||||
|
let timerId = null;
|
||||||
|
|
||||||
|
const fmtPercent = (value) => `${(Number(value || 0) * 100).toFixed(1)}%`;
|
||||||
|
|
||||||
|
const renderBars = (targetId, buckets, key, cls) => {
|
||||||
|
const el = document.getElementById(targetId);
|
||||||
|
if (!el) return;
|
||||||
|
const max = Math.max(1, ...buckets.map((b) => Number(b[key] || 0)));
|
||||||
|
el.innerHTML = "";
|
||||||
|
buckets.forEach((bucket) => {
|
||||||
|
const value = Number(bucket[key] || 0);
|
||||||
|
const item = document.createElement("div");
|
||||||
|
item.className = "bar-item";
|
||||||
|
const bar = document.createElement("div");
|
||||||
|
bar.className = `bar ${cls}`;
|
||||||
|
bar.style.height = `${Math.max((value / max) * 100, 2)}%`;
|
||||||
|
bar.title = `${bucket.label}: ${value}`;
|
||||||
|
const label = document.createElement("small");
|
||||||
|
label.textContent = bucket.label;
|
||||||
|
item.appendChild(bar);
|
||||||
|
item.appendChild(label);
|
||||||
|
el.appendChild(item);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadSummary = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/queue/events-summary?token=${encodeURIComponent(ADMIN_TOKEN)}&hours=24`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
document.getElementById("kpiProcessed").textContent = String(data.kpi.processedCount ?? 0);
|
||||||
|
document.getElementById("kpiFailureRate").textContent = fmtPercent(data.kpi.failureRate);
|
||||||
|
document.getElementById("kpiAvgDuration").textContent = String(data.kpi.avgDurationMs ?? 0);
|
||||||
|
document.getElementById("kpiQueue").textContent = `${data.queue.pending}${data.queue.working ? " (작동중)" : ""}`;
|
||||||
|
renderBars("processedChart", data.buckets || [], "processed", "processed");
|
||||||
|
renderBars("failedChart", data.buckets || [], "failed", "failed");
|
||||||
|
} catch {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshPolling = () => {
|
||||||
|
if (timerId) clearInterval(timerId);
|
||||||
|
if (!POLL_TOGGLE.checked) return;
|
||||||
|
const sec = Number(POLL_INTERVAL.value || 10);
|
||||||
|
timerId = setInterval(loadSummary, Math.max(sec, 3) * 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
POLL_TOGGLE.addEventListener("change", refreshPolling);
|
||||||
|
POLL_INTERVAL.addEventListener("change", refreshPolling);
|
||||||
|
loadSummary();
|
||||||
|
refreshPolling();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
60
views/admin-users.ejs
Normal file
60
views/admin-users.ejs
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>사용자 현황관리 - XAVIS</title>
|
||||||
|
<link rel="stylesheet" href="/public/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-shell">
|
||||||
|
<%- include('partials/nav', { activeMenu: 'admin-users', adminMode: true }) %>
|
||||||
|
<div class="content-area">
|
||||||
|
<header class="topbar">
|
||||||
|
<h1>사용자 현황관리</h1>
|
||||||
|
<a class="top-action-link" href="/learning">학습센터</a>
|
||||||
|
</header>
|
||||||
|
<main class="container">
|
||||||
|
<section class="panel">
|
||||||
|
<p class="subtitle" style="margin-bottom: 16px">
|
||||||
|
OPS 이메일(<strong>@xavis.co.kr</strong>) 매직 링크로 <strong>로그인에 성공한</strong> 사용자 목록입니다. 이메일과 최근 접속일(마지막 로그인 시각)을 표시합니다.
|
||||||
|
</p>
|
||||||
|
<% if (typeof dbError !== 'undefined' && dbError) { %>
|
||||||
|
<p class="admin-error">목록을 불러오지 못했습니다: <%= dbError %></p>
|
||||||
|
<% } else if (!pgConnected) { %>
|
||||||
|
<p class="admin-warn">PostgreSQL이 비활성화되어 있어 사용자 목록을 조회할 수 없습니다.</p>
|
||||||
|
<% } else if (!users || users.length === 0) { %>
|
||||||
|
<p class="admin-hint">아직 로그인 기록이 없습니다.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table" aria-label="인증 사용자 목록">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">이메일</th>
|
||||||
|
<th scope="col">최근 접속일</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% users.forEach(function (u) { %>
|
||||||
|
<tr>
|
||||||
|
<td><%= u.email %></td>
|
||||||
|
<td>
|
||||||
|
<% if (u.lastLoginAt) { %>
|
||||||
|
<%= new Date(u.lastLoginAt).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' }) %>
|
||||||
|
<% } else { %>
|
||||||
|
—
|
||||||
|
<% } %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }); %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p class="admin-hint" style="margin-top: 12px">총 <%= users.length %>명</p>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
152
views/ai-case-detail.ejs
Normal file
152
views/ai-case-detail.ejs
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title><%= story.title %> - AI 성공 사례 - XAVIS</title>
|
||||||
|
<link rel="stylesheet" href="/public/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<% var pdfUrlRaw = (typeof story.pdfUrl === 'string' ? story.pdfUrl : '').trim(); var showPdfViewer = pdfUrlRaw.length > 0; %>
|
||||||
|
<% if (typeof slideImageUrls === 'undefined') { slideImageUrls = []; } %>
|
||||||
|
<% if (typeof slides === 'undefined') { slides = []; } %>
|
||||||
|
<div class="app-shell">
|
||||||
|
<%- include('partials/nav', { activeMenu: 'ai-cases' }) %>
|
||||||
|
<div class="content-area">
|
||||||
|
<% if (showPdfViewer) { %>
|
||||||
|
<main class="viewer-wrap ai-case-viewer">
|
||||||
|
<a href="/ai-cases" class="back-link">← AI 성공 사례로 돌아가기</a>
|
||||||
|
<h1><%= story.title %></h1>
|
||||||
|
<p class="description"><%= story.excerpt || (story.department + ' · ' + story.author) %></p>
|
||||||
|
<div class="ppt-tools ai-case-ppt-tools">
|
||||||
|
<span>총 <b><%= slides.length %></b>페이지</span>
|
||||||
|
<span class="ai-case-tool-sep" aria-hidden="true">|</span>
|
||||||
|
<span><%= story.department %> · <%= story.author %><% if (story.publishedAt) { %> · <%= story.publishedAt %><% } %></span>
|
||||||
|
<% if (typeof adminMode !== 'undefined' && adminMode) { %>
|
||||||
|
<span class="ai-case-tool-sep" aria-hidden="true">|</span>
|
||||||
|
<a href="/ai-cases/write?edit=<%= story.slug %>" class="ai-case-inline-link">편집</a>
|
||||||
|
<% } %>
|
||||||
|
<span class="ai-case-tool-sep" aria-hidden="true">|</span>
|
||||||
|
<a href="<%= pdfUrlRaw %>" download class="ai-case-inline-link">다운로드</a>
|
||||||
|
<% if (typeof adminMode !== 'undefined' && adminMode) { %>
|
||||||
|
<span class="ai-case-tool-sep" aria-hidden="true">|</span>
|
||||||
|
<button type="button" class="ai-case-inline-link js-ai-case-delete" data-id="<%= story.id %>" data-title="<%= story.title %>">삭제</button>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<% if ((story.tags || []).length) { %>
|
||||||
|
<div class="tag-row ai-case-tag-row">
|
||||||
|
<% (story.tags || []).forEach((oneTag) => { %>
|
||||||
|
<span class="tag-chip">#<%= oneTag %></span>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<% if (!slideImageUrls || slideImageUrls.length === 0) { %>
|
||||||
|
<p class="admin-warn">PDF를 페이지 이미지로 보여 주지 못했습니다. <a href="<%= pdfUrlRaw %>" target="_blank" rel="noopener noreferrer">원본 PDF로 보기</a>. 서버에는 <code>pdftoppm</code>(Poppler)이 필요하고, 저장된 PDF 주소는 브라우저에서 열리는 것과 같이 <code>/public/...</code> 또는 그와 같은 경로의 전체 URL이어야 합니다.</p>
|
||||||
|
<% } %>
|
||||||
|
<section class="slide-list">
|
||||||
|
<% slides.forEach((slide, index) => { %>
|
||||||
|
<article class="slide-card">
|
||||||
|
<header>
|
||||||
|
<h2>페이지 <%= index + 1 %></h2>
|
||||||
|
<% if (slide.title) { %><p><%= slide.title %></p><% } %>
|
||||||
|
</header>
|
||||||
|
<% if (slideImageUrls[index]) { %>
|
||||||
|
<div class="slide-image-wrap">
|
||||||
|
<img src="<%= slideImageUrls[index] %>" alt="페이지 <%= index + 1 %>" class="slide-image" />
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<% if (slide.lines && slide.lines.length > 0) { %>
|
||||||
|
<ul>
|
||||||
|
<% slide.lines.forEach((line) => { %>
|
||||||
|
<li><%= line %></li>
|
||||||
|
<% }) %>
|
||||||
|
</ul>
|
||||||
|
<% } %>
|
||||||
|
</article>
|
||||||
|
<% }) %>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<% } else { %>
|
||||||
|
<main class="viewer-wrap ai-case-viewer">
|
||||||
|
<a href="/ai-cases" class="back-link">← AI 성공 사례로 돌아가기</a>
|
||||||
|
<h1><%= story.title %></h1>
|
||||||
|
<p class="description"><%= story.excerpt || (story.department + ' · ' + story.author) %></p>
|
||||||
|
<div class="ppt-tools ai-case-ppt-tools">
|
||||||
|
<span><%= story.department %> · <%= story.author %><% if (story.publishedAt) { %> · <%= story.publishedAt %><% } %></span>
|
||||||
|
<% if (typeof adminMode !== 'undefined' && adminMode) { %>
|
||||||
|
<span class="ai-case-tool-sep" aria-hidden="true">|</span>
|
||||||
|
<a href="/ai-cases/write?edit=<%= story.slug %>" class="ai-case-inline-link">편집</a>
|
||||||
|
<span class="ai-case-tool-sep" aria-hidden="true">|</span>
|
||||||
|
<button type="button" class="ai-case-inline-link js-ai-case-delete" data-id="<%= story.id %>" data-title="<%= story.title %>">삭제</button>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<% if ((story.tags || []).length) { %>
|
||||||
|
<div class="tag-row ai-case-tag-row">
|
||||||
|
<% (story.tags || []).forEach((oneTag) => { %>
|
||||||
|
<span class="tag-chip">#<%= oneTag %></span>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<section class="slide-list">
|
||||||
|
<article class="slide-card">
|
||||||
|
<header>
|
||||||
|
<h2>본문</h2>
|
||||||
|
</header>
|
||||||
|
<div id="success-md-render" class="chat-md-body success-detail-body-in-card"></div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
<script type="application/json" id="success-md-json"><%- JSON.stringify(story.bodyMarkdown || '') %></script>
|
||||||
|
</main>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% if (typeof adminMode !== 'undefined' && adminMode) { %>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var btn = document.querySelector('.js-ai-case-delete');
|
||||||
|
if (!btn) return;
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
var id = btn.getAttribute('data-id');
|
||||||
|
var title = btn.getAttribute('data-title') || '';
|
||||||
|
if (!id || !confirm('삭제할까요? ' + title)) return;
|
||||||
|
fetch('/api/ai-success-stories/' + encodeURIComponent(id), { method: 'DELETE' })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function() { window.location.href = '/ai-cases'; })
|
||||||
|
.catch(function() { alert('삭제 실패'); });
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<% } %>
|
||||||
|
<% if (!showPdfViewer) { %>
|
||||||
|
<script src="/vendor/marked/marked.umd.js"></script>
|
||||||
|
<script src="/vendor/dompurify/purify.min.js"></script>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var el = document.getElementById('success-md-json');
|
||||||
|
var out = document.getElementById('success-md-render');
|
||||||
|
if (!el || !out) return;
|
||||||
|
var raw = '';
|
||||||
|
try { raw = JSON.parse(el.textContent || '""'); } catch (e) { raw = ''; }
|
||||||
|
if (typeof marked === 'undefined' || typeof DOMPurify === 'undefined') {
|
||||||
|
out.textContent = raw;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
marked.setOptions({ breaks: true, gfm: true });
|
||||||
|
DOMPurify.addHook('afterSanitizeAttributes', function(node) {
|
||||||
|
if (node.tagName === 'A' && node.hasAttribute('href')) {
|
||||||
|
var href = node.getAttribute('href');
|
||||||
|
try {
|
||||||
|
var u = new URL(href, window.location.href);
|
||||||
|
if (u.protocol !== 'http:' && u.protocol !== 'https:') { node.removeAttribute('href'); return; }
|
||||||
|
} catch (e) { node.removeAttribute('href'); return; }
|
||||||
|
node.setAttribute('target', '_blank');
|
||||||
|
node.setAttribute('rel', 'noopener noreferrer');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
var html = marked.parse(String(raw || ''), { async: false });
|
||||||
|
out.innerHTML = DOMPurify.sanitize(html, { USE_PROFILES: { html: true } });
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<% } %>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
228
views/ai-cases-write.ejs
Normal file
228
views/ai-cases-write.ejs
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>AI 성공 사례 관리 - XAVIS</title>
|
||||||
|
<link rel="stylesheet" href="/public/styles.css" />
|
||||||
|
<style>
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
.pdf-upload-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem 0.75rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.pdf-upload-status {
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<% if (typeof allStories === 'undefined') { allStories = []; } %>
|
||||||
|
<% if (typeof story === 'undefined') { story = null; } %>
|
||||||
|
<div class="app-shell">
|
||||||
|
<%- include('partials/nav', { activeMenu: 'ai-cases' }) %>
|
||||||
|
<div class="content-area">
|
||||||
|
<header class="topbar">
|
||||||
|
<h1>AI 성공 사례 관리</h1>
|
||||||
|
<a class="top-action-link" href="/ai-cases">목록(사용자 화면)</a>
|
||||||
|
</header>
|
||||||
|
<main class="container">
|
||||||
|
<section class="panel">
|
||||||
|
<p class="breadcrumb"><a href="/ai-cases">AI 성공 사례</a> > 관리자</p>
|
||||||
|
<p class="muted admin-hint">슬러그는 URL에 쓰이므로 영문·숫자·하이픈만 사용하세요. <strong>원문 PDF 경로</strong>(<code>/public/...</code>)가 있으면 상세는 PDF 페이지 이미지로 보여 주며, 이때 <strong>본문(Markdown)은 비워도 됩니다</strong>. PDF가 없을 때는 본문이 필수입니다.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<% if (allStories.length) { %>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>등록된 사례</h2>
|
||||||
|
<ul class="admin-story-list">
|
||||||
|
<% allStories.forEach(function(s) { %>
|
||||||
|
<li>
|
||||||
|
<a href="/ai-cases/write?edit=<%= s.slug %>"><strong><%= s.title %></strong></a>
|
||||||
|
<span class="muted">/<%= s.slug %></span>
|
||||||
|
<button type="button" class="btn-ghost btn-sm js-delete-story" data-id="<%= s.id %>" data-title="<%= s.title %>">삭제</button>
|
||||||
|
</li>
|
||||||
|
<% }) %>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2><%= story ? '사례 수정' : '새 사례 등록' %></h2>
|
||||||
|
<form id="successStoryForm" class="form-grid">
|
||||||
|
<input type="hidden" id="storyId" value="<%= story ? story.id : '' %>" />
|
||||||
|
<label class="full">
|
||||||
|
슬러그 (URL, 영문·숫자·하이픈)
|
||||||
|
<input type="text" id="slug" name="slug" required placeholder="예: jojung-sook-hr-claude-cowork" value="<%= story ? story.slug : '' %>" />
|
||||||
|
</label>
|
||||||
|
<label class="full">
|
||||||
|
제목
|
||||||
|
<input type="text" id="title" name="title" required placeholder="제목" value="<%= story ? story.title : '' %>" />
|
||||||
|
</label>
|
||||||
|
<label class="full">
|
||||||
|
한 줄 요약 (카드에 표시)
|
||||||
|
<input type="text" id="excerpt" name="excerpt" placeholder="카드용 짧은 설명" value="<%= story ? story.excerpt : '' %>" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
부서
|
||||||
|
<input type="text" id="department" name="department" placeholder="예: 인사총무팀" value="<%= story ? story.department : '' %>" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
작성자
|
||||||
|
<input type="text" id="author" name="author" placeholder="예: 조정숙" value="<%= story ? story.author : '' %>" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
게시일 (YYYY-MM-DD)
|
||||||
|
<input type="text" id="publishedAt" name="publishedAt" placeholder="2026-03-25" value="<%= story ? story.publishedAt : '' %>" />
|
||||||
|
</label>
|
||||||
|
<label class="full">
|
||||||
|
태그 (쉼표로 구분)
|
||||||
|
<input type="text" id="tags" name="tags" placeholder="인사총무, Claude Cowork" value="<%= story ? (story.tags || []).join(', ') : '' %>" />
|
||||||
|
</label>
|
||||||
|
<label class="full">
|
||||||
|
원문 PDF (선택)
|
||||||
|
<span class="muted" style="display:block;font-weight:normal;font-size:0.9em;margin-bottom:0.35rem;">로컬 PDF를 선택하면 서버에 저장되고 아래 URL이 자동으로 채워집니다. 필요하면 URL을 직접 수정할 수 있습니다.</span>
|
||||||
|
<div class="pdf-upload-row">
|
||||||
|
<input type="file" id="pdfFileInput" accept="application/pdf,.pdf" class="visually-hidden" tabindex="-1" />
|
||||||
|
<button type="button" class="btn-ghost" id="pdfFilePickBtn" aria-label="PDF 파일 선택">PDF 파일 선택…</button>
|
||||||
|
<span id="pdfUploadStatus" class="muted pdf-upload-status" role="status" aria-live="polite"></span>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="pdfUrl" name="pdfUrl" placeholder="/public/resources/ai-success/..." value="<%= story && story.pdfUrl ? story.pdfUrl : '' %>" autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
<label class="full">
|
||||||
|
본문 (Markdown)
|
||||||
|
<textarea id="bodyMarkdown" name="bodyMarkdown" rows="22" placeholder="# 제목 본문..."><% if (story) { %><%- story.bodyMarkdown %><% } %></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="form-actions full">
|
||||||
|
<button type="button" class="btn-ghost" onclick="location.href='/ai-cases'">취소</button>
|
||||||
|
<button type="submit" class="top-action"><%= story ? '저장' : '등록' %></button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<p id="formMessage" class="form-message" role="status" aria-live="polite"></p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var form = document.getElementById('successStoryForm');
|
||||||
|
var msg = document.getElementById('formMessage');
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (msg) msg.textContent = '';
|
||||||
|
var id = (document.getElementById('storyId') || {}).value || '';
|
||||||
|
var pdfVal = ((document.getElementById('pdfUrl') || {}).value || '').trim();
|
||||||
|
var mdVal = ((document.getElementById('bodyMarkdown') || {}).value || '').trim();
|
||||||
|
if (!mdVal && !pdfVal) {
|
||||||
|
if (msg) msg.textContent = '본문(Markdown) 또는 원문 PDF 중 하나는 입력해 주세요.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var payload = {
|
||||||
|
slug: (document.getElementById('slug') || {}).value || '',
|
||||||
|
title: (document.getElementById('title') || {}).value || '',
|
||||||
|
excerpt: (document.getElementById('excerpt') || {}).value || '',
|
||||||
|
department: (document.getElementById('department') || {}).value || '',
|
||||||
|
author: (document.getElementById('author') || {}).value || '',
|
||||||
|
publishedAt: (document.getElementById('publishedAt') || {}).value || '',
|
||||||
|
tags: (document.getElementById('tags') || {}).value || '',
|
||||||
|
pdfUrl: (document.getElementById('pdfUrl') || {}).value || '',
|
||||||
|
bodyMarkdown: (document.getElementById('bodyMarkdown') || {}).value || ''
|
||||||
|
};
|
||||||
|
var url = id ? '/api/ai-success-stories/' + encodeURIComponent(id) : '/api/ai-success-stories';
|
||||||
|
var method = id ? 'PUT' : 'POST';
|
||||||
|
fetch(url, {
|
||||||
|
method: method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json().then(function(j) { return { ok: r.ok, j: j }; }); })
|
||||||
|
.then(function(_ref) {
|
||||||
|
if (_ref.ok) {
|
||||||
|
if (msg) msg.textContent = '저장되었습니다.';
|
||||||
|
setTimeout(function() { window.location.href = '/ai-cases'; }, 600);
|
||||||
|
} else {
|
||||||
|
if (msg) msg.textContent = _ref.j.error || '저장에 실패했습니다.';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
if (msg) msg.textContent = '네트워크 오류';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var pdfFileInput = document.getElementById('pdfFileInput');
|
||||||
|
var pdfPickBtn = document.getElementById('pdfFilePickBtn');
|
||||||
|
var pdfUploadStatus = document.getElementById('pdfUploadStatus');
|
||||||
|
if (pdfPickBtn && pdfFileInput) {
|
||||||
|
pdfPickBtn.addEventListener('click', function() {
|
||||||
|
pdfFileInput.click();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (pdfFileInput) {
|
||||||
|
pdfFileInput.addEventListener('change', function() {
|
||||||
|
var f = pdfFileInput.files && pdfFileInput.files[0];
|
||||||
|
if (!f) return;
|
||||||
|
if (pdfUploadStatus) pdfUploadStatus.textContent = '업로드 중…';
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append('pdfFile', f);
|
||||||
|
fetch('/api/ai-success-stories/upload-pdf', {
|
||||||
|
method: 'POST',
|
||||||
|
body: fd,
|
||||||
|
credentials: 'same-origin'
|
||||||
|
})
|
||||||
|
.then(function(r) {
|
||||||
|
return r.json().then(function(j) {
|
||||||
|
return { ok: r.ok, j: j };
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(function(_ref) {
|
||||||
|
if (_ref.ok && _ref.j.pdfUrl) {
|
||||||
|
var pdfEl = document.getElementById('pdfUrl');
|
||||||
|
if (pdfEl) pdfEl.value = _ref.j.pdfUrl;
|
||||||
|
if (pdfUploadStatus) {
|
||||||
|
pdfUploadStatus.textContent =
|
||||||
|
'업로드 완료 (' + (_ref.j.filename || 'PDF') + ')';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (pdfUploadStatus) {
|
||||||
|
pdfUploadStatus.textContent = _ref.j.error || '업로드 실패';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pdfFileInput.value = '';
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
if (pdfUploadStatus) pdfUploadStatus.textContent = '네트워크 오류';
|
||||||
|
pdfFileInput.value = '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.js-delete-story').forEach(function(btn) {
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
var id = btn.getAttribute('data-id');
|
||||||
|
var title = btn.getAttribute('data-title') || '';
|
||||||
|
if (!id || !confirm('삭제할까요? ' + title)) return;
|
||||||
|
fetch('/api/ai-success-stories/' + encodeURIComponent(id), { method: 'DELETE' })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function() { window.location.reload(); })
|
||||||
|
.catch(function() { alert('삭제 실패'); });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
74
views/ai-cases.ejs
Normal file
74
views/ai-cases.ejs
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>AI 성공 사례 - XAVIS</title>
|
||||||
|
<link rel="stylesheet" href="/public/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<% if (typeof filters === 'undefined') { filters = { q: '', tag: '' }; } %>
|
||||||
|
<% if (typeof availableTags === 'undefined') { availableTags = []; } %>
|
||||||
|
<% if (typeof stories === 'undefined') { stories = []; } %>
|
||||||
|
<% var _opsLoggedIn = typeof opsUserEmail !== 'undefined' && opsUserEmail; %>
|
||||||
|
<div class="app-shell">
|
||||||
|
<%- include('partials/nav', { activeMenu: 'ai-cases' }) %>
|
||||||
|
<div class="content-area">
|
||||||
|
<header class="topbar">
|
||||||
|
<h1>AI 성공 사례</h1>
|
||||||
|
<% if (typeof adminMode !== 'undefined' && adminMode && !_opsLoggedIn) { %>
|
||||||
|
<a class="top-action-link" href="/ai-cases/write">사례 등록·관리</a>
|
||||||
|
<% } %>
|
||||||
|
</header>
|
||||||
|
<main class="container">
|
||||||
|
<% if (typeof successStoryDetailAllowed !== 'undefined' && !successStoryDetailAllowed) { %>
|
||||||
|
<p class="chat-api-warning" style="margin-bottom: 16px">
|
||||||
|
로그인 후 이용 가능합니다.
|
||||||
|
</p>
|
||||||
|
<% } %>
|
||||||
|
<section class="hero panel success-hero">
|
||||||
|
<h2>현장에서 검증된 AI 업무 혁신 이야기</h2>
|
||||||
|
<p>부서별 도입 과정과 성과를 카드에서 확인하고, 본문에서 상세 내용을 읽을 수 있습니다.</p>
|
||||||
|
</section>
|
||||||
|
<section class="panel filter-panel">
|
||||||
|
<h2>검색·필터</h2>
|
||||||
|
<form action="/ai-cases" method="get" class="filter-grid success-filter">
|
||||||
|
<label>
|
||||||
|
검색어
|
||||||
|
<input type="text" name="q" value="<%= filters.q %>" placeholder="제목, 요약, 부서, 태그" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
태그
|
||||||
|
<select name="tag">
|
||||||
|
<option value="">전체</option>
|
||||||
|
<% (availableTags || []).forEach((oneTag) => { %>
|
||||||
|
<option value="<%= oneTag %>" <%= filters.tag === oneTag ? "selected" : "" %>><%= oneTag %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="filter-actions">
|
||||||
|
<button type="submit">적용</button>
|
||||||
|
<a class="link-muted" href="/ai-cases">초기화</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>등록된 사례</h2>
|
||||||
|
<span class="count-chip">총 <%= stories.length %>건</span>
|
||||||
|
</div>
|
||||||
|
<% if (!stories.length) { %>
|
||||||
|
<p class="empty">조건에 맞는 사례가 없습니다.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="success-story-grid">
|
||||||
|
<% stories.forEach((story) => { %>
|
||||||
|
<%- include('partials/success-story-card', { story, successStoryDetailAllowed }) %>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
142
views/ai-explore.ejs
Normal file
142
views/ai-explore.ejs
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>AI - XAVIS</title>
|
||||||
|
<link rel="stylesheet" href="/public/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body
|
||||||
|
data-ai-explore-dev-guest="<%= (typeof aiExploreDevGuestRestricted !== 'undefined' && aiExploreDevGuestRestricted) ? '1' : '0' %>"
|
||||||
|
class="<%= (typeof aiExploreDevGuestRestricted !== 'undefined' && aiExploreDevGuestRestricted) ? 'ai-explore-page ai-explore-dev-guest' : 'ai-explore-page' %>"
|
||||||
|
>
|
||||||
|
<% var aiGuestDev = typeof aiExploreDevGuestRestricted !== 'undefined' && aiExploreDevGuestRestricted; %>
|
||||||
|
<% var _opsLoggedIn = typeof opsUserEmail !== 'undefined' && opsUserEmail; %>
|
||||||
|
<div class="app-shell">
|
||||||
|
<%- include('partials/nav', { activeMenu: 'ai-explore', adminMode: typeof adminMode !== 'undefined' ? adminMode : false }) %>
|
||||||
|
<div class="content-area">
|
||||||
|
<header class="topbar">
|
||||||
|
<h1>AI</h1>
|
||||||
|
<% if (!_opsLoggedIn) { %>
|
||||||
|
<% if (aiGuestDev) { %>
|
||||||
|
<span class="top-action-link ai-explore-action-disabled" aria-disabled="true">AI 추가하기</span>
|
||||||
|
<% } else { %>
|
||||||
|
<a class="top-action-link" href="#">AI 추가하기</a>
|
||||||
|
<% } %>
|
||||||
|
<% } %>
|
||||||
|
</header>
|
||||||
|
<main class="container container-ai-full">
|
||||||
|
<% if (aiGuestDev) { %>
|
||||||
|
<p class="chat-api-warning" style="margin-bottom: 16px">로그인 후 이용 가능합니다.</p>
|
||||||
|
<% } %>
|
||||||
|
<section class="panel">
|
||||||
|
<p class="subtitle">지식 시험이나 지식 보강은 물론, 스킬들을 다양하게 조합한 맞춤형 AI를 탐색하고 사용해보세요.</p>
|
||||||
|
<form class="search-bar-wrap" id="aiExploreSearchForm" role="search">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
id="aiExploreSearch"
|
||||||
|
placeholder="제목, 설명으로 검색하세요"
|
||||||
|
class="search-input"
|
||||||
|
autocomplete="off"
|
||||||
|
aria-label="AI 서비스 제목·설명 검색"
|
||||||
|
<% if (aiGuestDev) { %>disabled aria-disabled="true"<% } %>
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>AI 서비스</h2>
|
||||||
|
<div class="ai-card-grid">
|
||||||
|
<a href="/ai-explore/prompts" class="ai-card ai-card-link">
|
||||||
|
<div class="ai-card-header">
|
||||||
|
<span class="status-chip public">공개중</span>
|
||||||
|
</div>
|
||||||
|
<h3>프롬프트</h3>
|
||||||
|
<p>업무별 기본 프롬프트를 모아 두고, 복사해 바로 활용할 수 있는 라이브러리입니다.</p>
|
||||||
|
<div class="tag-row"><span class="tag-chip">#프롬프트</span></div>
|
||||||
|
</a>
|
||||||
|
<% if (aiGuestDev) { %>
|
||||||
|
<article class="ai-card ai-card-disabled" aria-disabled="true">
|
||||||
|
<div class="ai-card-header">
|
||||||
|
<span class="status-chip public">공개중</span>
|
||||||
|
</div>
|
||||||
|
<h3>회의록 AI</h3>
|
||||||
|
<p>회의록을 자동으로 작성·요약·정리해주는 AI 서비스입니다.</p>
|
||||||
|
<div class="tag-row"><span class="tag-chip">#업무관리</span><span class="tag-chip">#회의록</span></div>
|
||||||
|
</article>
|
||||||
|
<% } else { %>
|
||||||
|
<a href="/ai-explore/meeting-minutes" class="ai-card ai-card-link">
|
||||||
|
<div class="ai-card-header">
|
||||||
|
<span class="status-chip public">공개중</span>
|
||||||
|
</div>
|
||||||
|
<h3>회의록 AI</h3>
|
||||||
|
<p>회의록을 자동으로 작성·요약·정리해주는 AI 서비스입니다.</p>
|
||||||
|
<div class="tag-row"><span class="tag-chip">#업무관리</span><span class="tag-chip">#회의록</span></div>
|
||||||
|
</a>
|
||||||
|
<% } %>
|
||||||
|
<% if (aiGuestDev) { %>
|
||||||
|
<article class="ai-card ai-card-disabled" aria-disabled="true">
|
||||||
|
<div class="ai-card-header">
|
||||||
|
<span class="status-chip public">공개중</span>
|
||||||
|
</div>
|
||||||
|
<h3>업무 체크리스트 AI</h3>
|
||||||
|
<p>회의록에서 추출된 할 일과 개인 업무를 통합 관리하고, 등록·수정·삭제를 통해 완결성을 높이는 AI 비서입니다.</p>
|
||||||
|
<div class="tag-row"><span class="tag-chip">#업무관리</span><span class="tag-chip">#체크리스트</span></div>
|
||||||
|
</article>
|
||||||
|
<% } else { %>
|
||||||
|
<a href="/ai-explore/task-checklist" class="ai-card ai-card-link">
|
||||||
|
<div class="ai-card-header">
|
||||||
|
<span class="status-chip public">공개중</span>
|
||||||
|
</div>
|
||||||
|
<h3>업무 체크리스트 AI</h3>
|
||||||
|
<p>회의록에서 추출된 할 일과 개인 업무를 통합 관리하고, 등록·수정·삭제를 통해 완결성을 높이는 AI 비서입니다.</p>
|
||||||
|
<div class="tag-row"><span class="tag-chip">#업무관리</span><span class="tag-chip">#체크리스트</span></div>
|
||||||
|
</a>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var form = document.getElementById("aiExploreSearchForm");
|
||||||
|
var input = document.getElementById("aiExploreSearch");
|
||||||
|
var grid = document.querySelector(".ai-card-grid");
|
||||||
|
if (!form || !input || !grid) return;
|
||||||
|
|
||||||
|
var devGuest = (document.body.getAttribute("data-ai-explore-dev-guest") || "0") === "1";
|
||||||
|
if (devGuest) return;
|
||||||
|
|
||||||
|
var cards = grid.querySelectorAll(".ai-card");
|
||||||
|
|
||||||
|
function cardTitleDescriptionText(el) {
|
||||||
|
var parts = [];
|
||||||
|
var h3 = el.querySelector("h3");
|
||||||
|
if (h3) parts.push(h3.textContent || "");
|
||||||
|
el.querySelectorAll("p").forEach(function (p) {
|
||||||
|
if (!p.closest(".tag-row")) parts.push(p.textContent || "");
|
||||||
|
});
|
||||||
|
return parts.join(" ").replace(/\s+/g, " ").trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilter() {
|
||||||
|
var q = (input.value || "").trim().toLowerCase();
|
||||||
|
cards.forEach(function (el) {
|
||||||
|
var text = cardTitleDescriptionText(el);
|
||||||
|
var show = !q || text.indexOf(q) !== -1;
|
||||||
|
el.hidden = !show;
|
||||||
|
el.setAttribute("aria-hidden", show ? "false" : "true");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener("input", applyFilter);
|
||||||
|
input.addEventListener("search", applyFilter);
|
||||||
|
|
||||||
|
form.addEventListener("submit", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
applyFilter();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
163
views/ai-prompts.ejs
Normal file
163
views/ai-prompts.ejs
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>프롬프트 라이브러리 - XAVIS</title>
|
||||||
|
<link rel="stylesheet" href="/public/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-shell">
|
||||||
|
<%- include('partials/nav', { activeMenu: 'ai-explore' }) %>
|
||||||
|
<div class="content-area">
|
||||||
|
<header class="topbar">
|
||||||
|
<h1>프롬프트</h1>
|
||||||
|
<a class="top-action-link" href="/ai-explore">AI 목록</a>
|
||||||
|
</header>
|
||||||
|
<main class="container container-ai-full">
|
||||||
|
<a href="/ai-explore" class="prompts-back" aria-label="AI 탐색으로 돌아가기">← AI</a>
|
||||||
|
|
||||||
|
<section class="prompts-hero">
|
||||||
|
<h1>프롬프트 라이브러리</h1>
|
||||||
|
<p class="prompts-lead">
|
||||||
|
업무별로 자주 쓰는 기본 프롬프트를 골라 바로 복사해 사용하세요. ChatGPT·Claude·자비스 채팅 등 어디에든 붙여 넣을 수 있습니다.
|
||||||
|
</p>
|
||||||
|
<p class="prompts-stats">
|
||||||
|
<%= prompts.length %>가지 템플릿 · 사내 업무 시나리오 중심 · 복사 후 [ ] 부분만 채워 완성
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<% if (!prompts.length) { %>
|
||||||
|
<section class="panel">
|
||||||
|
<p class="empty">프롬프트 데이터를 불러오지 못했습니다. 관리자에게 문의해 주세요.</p>
|
||||||
|
</section>
|
||||||
|
<% } else { %>
|
||||||
|
<section class="panel prompts-layout">
|
||||||
|
<div>
|
||||||
|
<h2 class="prompts-grid-title">시나리오 선택</h2>
|
||||||
|
<div class="prompts-grid" id="promptCardList" role="list">
|
||||||
|
<% prompts.forEach(function (p) { %>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="prompt-template-card"
|
||||||
|
role="listitem"
|
||||||
|
data-prompt-id="<%= p.id %>"
|
||||||
|
aria-pressed="false"
|
||||||
|
>
|
||||||
|
<h3><%= p.title %></h3>
|
||||||
|
<p><%= p.description %></p>
|
||||||
|
<span class="prompt-mini-tag"><%= p.tag %></span>
|
||||||
|
</button>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="prompts-preview-panel">
|
||||||
|
<h2 id="previewTitle">프롬프트 미리보기</h2>
|
||||||
|
<p class="prompts-preview-empty" id="previewEmpty">왼쪽에서 카드를 선택하세요.</p>
|
||||||
|
<div id="previewActive" hidden>
|
||||||
|
<div class="prompts-preview-toolbar">
|
||||||
|
<button type="button" class="prompts-copy-btn" id="copyPromptBtn" disabled>클립보드에 복사</button>
|
||||||
|
</div>
|
||||||
|
<label class="visually-hidden" for="promptBody">선택한 프롬프트 전문</label>
|
||||||
|
<textarea id="promptBody" class="prompts-body-textarea" readonly spellcheck="false" aria-describedby="previewHint"></textarea>
|
||||||
|
<p class="prompts-hint" id="previewHint">대괄호 [ ] 안은 상황에 맞게 수정한 뒤 사용하세요.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<% } %>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% if (prompts.length) { %>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var library = <%- JSON.stringify(prompts) %>;
|
||||||
|
var byId = {};
|
||||||
|
library.forEach(function (p) {
|
||||||
|
byId[p.id] = p;
|
||||||
|
});
|
||||||
|
|
||||||
|
var cards = document.querySelectorAll(".prompt-template-card");
|
||||||
|
var titleEl = document.getElementById("previewTitle");
|
||||||
|
var bodyEl = document.getElementById("promptBody");
|
||||||
|
var copyBtn = document.getElementById("copyPromptBtn");
|
||||||
|
var emptyEl = document.getElementById("previewEmpty");
|
||||||
|
var activeWrap = document.getElementById("previewActive");
|
||||||
|
var selectedId = null;
|
||||||
|
|
||||||
|
function setSelected(id) {
|
||||||
|
selectedId = id;
|
||||||
|
cards.forEach(function (btn) {
|
||||||
|
var on = btn.getAttribute("data-prompt-id") === id;
|
||||||
|
btn.classList.toggle("is-selected", on);
|
||||||
|
btn.setAttribute("aria-pressed", on ? "true" : "false");
|
||||||
|
});
|
||||||
|
var p = byId[id];
|
||||||
|
if (!p) {
|
||||||
|
emptyEl.hidden = false;
|
||||||
|
activeWrap.hidden = true;
|
||||||
|
copyBtn.disabled = true;
|
||||||
|
titleEl.textContent = "프롬프트 미리보기";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emptyEl.hidden = true;
|
||||||
|
activeWrap.hidden = false;
|
||||||
|
titleEl.textContent = p.title;
|
||||||
|
bodyEl.value = p.body;
|
||||||
|
copyBtn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
cards.forEach(function (btn) {
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
setSelected(btn.getAttribute("data-prompt-id"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
copyBtn.addEventListener("click", function () {
|
||||||
|
if (!bodyEl.value) return;
|
||||||
|
navigator.clipboard.writeText(bodyEl.value).then(
|
||||||
|
function () {
|
||||||
|
var t = copyBtn.textContent;
|
||||||
|
copyBtn.textContent = "복사됨";
|
||||||
|
setTimeout(function () {
|
||||||
|
copyBtn.textContent = t;
|
||||||
|
}, 1600);
|
||||||
|
},
|
||||||
|
function () {
|
||||||
|
bodyEl.select();
|
||||||
|
try {
|
||||||
|
document.execCommand("copy");
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
var params = new URLSearchParams(window.location.search);
|
||||||
|
var initial = params.get("id");
|
||||||
|
if (initial && byId[initial]) {
|
||||||
|
setSelected(initial);
|
||||||
|
} else if (library[0]) {
|
||||||
|
setSelected(library[0].id);
|
||||||
|
} else {
|
||||||
|
emptyEl.hidden = false;
|
||||||
|
activeWrap.hidden = true;
|
||||||
|
copyBtn.disabled = true;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<% } %>
|
||||||
|
<style>
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
479
views/ax-apply.ejs
Normal file
479
views/ax-apply.ejs
Normal file
@@ -0,0 +1,479 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>AX 과제 신청 - XAVIS</title>
|
||||||
|
<link rel="stylesheet" href="/public/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body
|
||||||
|
data-ax-admin-mode="<%= (typeof adminMode !== 'undefined' && adminMode) ? '1' : '0' %>"
|
||||||
|
data-ax-apply-download-submit-allowed="<%= (typeof axApplyDownloadSubmitAllowed !== 'undefined' && axApplyDownloadSubmitAllowed) ? '1' : '0' %>"
|
||||||
|
>
|
||||||
|
<% var axDlSubmitOk = typeof axApplyDownloadSubmitAllowed !== 'undefined' ? axApplyDownloadSubmitAllowed : true; %>
|
||||||
|
<% if (typeof assignments === 'undefined') { assignments = []; } %>
|
||||||
|
<div class="app-shell">
|
||||||
|
<%- include('partials/nav', { activeMenu: 'ax-apply' }) %>
|
||||||
|
<div class="content-area">
|
||||||
|
<header class="topbar">
|
||||||
|
<h1>AX 과제 신청</h1>
|
||||||
|
</header>
|
||||||
|
<main class="container">
|
||||||
|
<% if (!axDlSubmitOk) { %>
|
||||||
|
<p class="chat-api-warning" style="margin-bottom: 16px">
|
||||||
|
로그인 후 이용 가능합니다.
|
||||||
|
</p>
|
||||||
|
<% } %>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>신청된 과제 목록</h2>
|
||||||
|
<p class="list-hint">소속 부서, 이름, 이메일을 입력하면 조회됩니다.</p>
|
||||||
|
<div id="axAssignmentsListWrap">
|
||||||
|
<% if (!assignments.length) { %>
|
||||||
|
<p class="empty">신청된 과제가 없습니다.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>순번</th>
|
||||||
|
<th>신청일</th>
|
||||||
|
<th>소속 부서</th>
|
||||||
|
<th>이름</th>
|
||||||
|
<th>사번</th>
|
||||||
|
<th>직급</th>
|
||||||
|
<th>이메일</th>
|
||||||
|
<th>AI에게 원하는 것</th>
|
||||||
|
<th>신청서</th>
|
||||||
|
<th>상태</th>
|
||||||
|
<th>수정<% if (typeof adminMode !== 'undefined' && adminMode) { %> / 삭제<% } %></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% assignments.forEach((a, idx) => { %>
|
||||||
|
<tr>
|
||||||
|
<td><%= assignments.length - idx %></td>
|
||||||
|
<td><%= new Date(a.createdAt).toLocaleDateString('ko-KR') %></td>
|
||||||
|
<td><%= a.department || '-' %></td>
|
||||||
|
<td><%= a.name || '-' %></td>
|
||||||
|
<td><%= a.employeeId || '-' %></td>
|
||||||
|
<td><%= a.position || '-' %></td>
|
||||||
|
<td><%= a.email || '-' %></td>
|
||||||
|
<td class="cell-truncate" title="<%= (a.aiExpectation || '').replace(/"/g, '"') %>"><%= (a.aiExpectation || '-').length > 30 ? (a.aiExpectation || '').slice(0, 30) + '…' : (a.aiExpectation || '-') %></td>
|
||||||
|
<td><% if (a.applicationFile) { %><a href="<%= a.applicationFile %>" target="_blank" download>보기</a><% } else { %>-<% } %></td>
|
||||||
|
<td><%= a.status || '신청' %></td>
|
||||||
|
<td>
|
||||||
|
<a href="#" class="ax-edit-link" data-id="<%= a.id %>">수정</a><% if (typeof adminMode !== 'undefined' && adminMode) { %> | <a href="#" class="ax-delete-link" data-id="<%= a.id %>" data-created-at="<%= (a.createdAt || '').toString().replace(/"/g, '"') %>" 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, '"') %>">삭제</a><% } %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<% if (typeof adminMode !== 'undefined' && !adminMode) { %>
|
||||||
|
<div class="list-query-actions">
|
||||||
|
<button type="button" id="axApplyListQueryBtn" class="btn-ghost">조회</button>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" id="ax-apply-form">
|
||||||
|
<h2>
|
||||||
|
AX 과제 신청서
|
||||||
|
<% if (axDlSubmitOk) { %>
|
||||||
|
<a href="/resources/ax-apply/AX_과제_신청서.docx" download="AX_과제_신청서.docx" class="link-download">(신청서 다운로드)</a>
|
||||||
|
<% } else { %>
|
||||||
|
<span class="link-muted" tabindex="-1" aria-disabled="true">(신청서 다운로드)</span>
|
||||||
|
<% } %>
|
||||||
|
</h2>
|
||||||
|
<p class="subtitle">※ 모든 항목을 성실하게 작성해 주세요.</p>
|
||||||
|
<form id="axApplyForm" class="form-grid" enctype="multipart/form-data">
|
||||||
|
<input type="hidden" name="editId" id="axEditId" value="" />
|
||||||
|
<h3>1. 기본 정보</h3>
|
||||||
|
<label><span class="label-text">소속 부서<span class="required">*</span></span> <input type="text" name="department" required placeholder="부서명 입력" /></label>
|
||||||
|
<label><span class="label-text">이름<span class="required">*</span></span> <input type="text" name="name" required placeholder="이름 입력" /></label>
|
||||||
|
<label>사번 <input type="text" name="employeeId" placeholder="사번 입력" /></label>
|
||||||
|
<label>직급/직책 <input type="text" name="position" placeholder="직급 입력" /></label>
|
||||||
|
<label>연락처 <input type="text" name="phone" placeholder="연락처 입력" /></label>
|
||||||
|
<label><span class="label-text">이메일<span class="required">*</span></span> <input type="email" name="email" required placeholder="이메일 입력" /></label>
|
||||||
|
|
||||||
|
<h3 class="full">2. 현재 업무 현황 (As-Is)</h3>
|
||||||
|
<label class="full"><span class="label-text">업무 프로세스 설명<span class="required">*</span></span> <textarea name="workProcessDescription" rows="4" required placeholder="현재 업무의 전체 흐름을 단계별로 설명해 주세요."></textarea></label>
|
||||||
|
<label class="full"><span class="label-text">Pain Point<span class="required">*</span></span> <textarea name="painPoint" rows="3" required placeholder="현재 업무에서 가장 불편하거나 비효율적인 점을 구체적으로 기재해 주세요."></textarea></label>
|
||||||
|
<label><span class="label-text">현재 소요 시간<span class="required">*</span></span> <input type="text" name="currentTimeSpent" required placeholder="예: 1건 처리에 30분 소요" /></label>
|
||||||
|
<label><span class="label-text">AX 전환 전 오류율<span class="required">*</span></span> <input type="text" name="errorRateBefore" required placeholder="예: 약 5%" /></label>
|
||||||
|
<label class="full">협업 필요 부서 <input type="text" name="collaborationDepts" placeholder="AI혁신팀 외 협업이 필요한 부서가 있다면 기재해 주세요." /></label>
|
||||||
|
<label class="full"><span class="label-text">지금 해결해야 하는 이유<span class="required">*</span></span> <textarea name="reasonToSolve" rows="2" required placeholder="왜 지금 이 문제를 해결해야 하는지 배경을 설명해 주세요."></textarea></label>
|
||||||
|
|
||||||
|
<h3 class="full">3. 희망하는 결과 (To-Be)</h3>
|
||||||
|
<label class="full"><span class="label-text">AI에게 원하는 것<span class="required">*</span></span> <textarea name="aiExpectation" rows="3" required placeholder="예: 폴더에 파일만 넣으면 자동으로 합쳐진 엑셀 파일이 1개 생성되었으면 함"></textarea></label>
|
||||||
|
<label class="full"><span class="label-text">산출물 형태<span class="required">*</span></span> <textarea name="outputType" rows="2" required placeholder="원하는 결과물의 형태를 설명해 주세요."></textarea></label>
|
||||||
|
<label class="full">기대 자동화 수준
|
||||||
|
<select name="automationLevel">
|
||||||
|
<option value="">선택</option>
|
||||||
|
<option value="단순 자동화">단순 자동화 (사람이 결과를 검토·확정)</option>
|
||||||
|
<option value="AI 기반 의사결정">AI 기반 의사결정 (AI가 판단하고 사람이 승인)</option>
|
||||||
|
<option value="완전 무인화">완전 무인화 (AI가 처음부터 끝까지 수행)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<h3 class="full">4. 데이터 준비 상태</h3>
|
||||||
|
<label>데이터 준비 여부
|
||||||
|
<select name="dataReadiness">
|
||||||
|
<option value="">선택</option>
|
||||||
|
<option value="준비완료">준비완료</option>
|
||||||
|
<option value="준비중">준비중</option>
|
||||||
|
<option value="미준비">미준비</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>데이터 위치 <input type="text" name="dataLocation" placeholder="예: 사내 DB / 업무 PC / 외부 API" /></label>
|
||||||
|
<label>개인정보 포함
|
||||||
|
<select name="personalInfo">
|
||||||
|
<option value="">선택</option>
|
||||||
|
<option value="포함">포함</option>
|
||||||
|
<option value="미포함">미포함</option>
|
||||||
|
<option value="확인 필요">확인 필요</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>데이터 정합성
|
||||||
|
<select name="dataQuality">
|
||||||
|
<option value="">선택</option>
|
||||||
|
<option value="양호">양호</option>
|
||||||
|
<option value="보통">보통</option>
|
||||||
|
<option value="정제 필요">정제 필요</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>데이터 건수 <input type="text" name="dataCount" placeholder="예: 약 10,000건" /></label>
|
||||||
|
|
||||||
|
<h3 class="full">5. 목표 및 기대 효과</h3>
|
||||||
|
<label>업무 시간 단축 <input type="text" name="timeReduction" placeholder="예: 30% 단축" /></label>
|
||||||
|
<label>오류율 감소 <input type="text" name="errorReduction" placeholder="예: 10% → 3%" /></label>
|
||||||
|
<label>월 처리량 증가 <input type="text" name="volumeIncrease" placeholder="예: 2배 증가" /></label>
|
||||||
|
<label>비용 절감 <input type="text" name="costReduction" placeholder="예: 월 200만원" /></label>
|
||||||
|
<label>응답/처리 시간 <input type="text" name="responseTime" placeholder="예: 2분 → 30초" /></label>
|
||||||
|
<label>기타 지표 <input type="text" name="otherMetrics" placeholder="직접 기재" /></label>
|
||||||
|
<label>연간 절감 비용 <input type="text" name="annualSavings" placeholder="예: 약 2,400만원/년" /></label>
|
||||||
|
<label>인력 대체 시간 <input type="text" name="laborReplacement" placeholder="예: 월 80시간" /></label>
|
||||||
|
<label>매출 증가 예상 <input type="text" name="revenueIncrease" placeholder="예: 해당 없음" /></label>
|
||||||
|
<label class="full">기타 <input type="text" name="otherEffects" placeholder="직접 기재" /></label>
|
||||||
|
|
||||||
|
<h3 class="full">6. 예상 AI 툴 및 인프라</h3>
|
||||||
|
<label class="full">필요 기술 스택 (쉼표 구분) <input type="text" name="techStackStr" placeholder="예: 노코드 워크플로우, LLM, OCR" /></label>
|
||||||
|
|
||||||
|
<h3 class="full">7. 리스크 및 제약사항</h3>
|
||||||
|
<label class="full">해당되는 리스크 (쉼표 구분) <input type="text" name="risksStr" placeholder="예: 개인정보 규제, 법적 이슈" /></label>
|
||||||
|
<label class="full">상세 설명 <textarea name="riskDetail" rows="2" placeholder="리스크 및 제약사항에 대한 추가 설명"></textarea></label>
|
||||||
|
|
||||||
|
<h3 class="full">8. 작성 완료 신청서 업로드</h3>
|
||||||
|
<label class="full">
|
||||||
|
<span class="label-text">AX_과제_신청서.docx<span class="required">*</span></span>
|
||||||
|
<input type="file" name="applicationFile" id="axApplicationFile" accept=".docx,.doc" />
|
||||||
|
<span class="form-hint" id="axFileHint">다운로드한 신청서를 작성 후 업로드해 주세요.</span>
|
||||||
|
<span class="form-hint" id="axFileEditHint" style="display:none">수정 시 기존 파일이 있습니다. 변경 시에만 새 파일을 선택하세요.</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<h3 class="full">9. 현업 참여 확약</h3>
|
||||||
|
<label class="full">
|
||||||
|
<span class="label-text">현업 참여 확약<span class="required">*</span></span>
|
||||||
|
<input type="checkbox" name="participationPledge" value="1" required />
|
||||||
|
프로젝트 수행 기간(약 1주일) 동안 매일 6시간 이상 AI담당팀과 협업, 실제 업무 데이터 제공, 결과물 테스트·피드백, 유지보수 1차 책임 인지에 동의합니다.
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="form-actions full">
|
||||||
|
<button type="button" class="btn-ghost" onclick="document.getElementById('axApplyForm').reset()">초기화</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="top-action"
|
||||||
|
id="axSubmitBtn"
|
||||||
|
<% if (!axDlSubmitOk) { %>disabled aria-disabled="true" title="로그인 후 이용 가능합니다."<% } %>
|
||||||
|
>
|
||||||
|
신청하기
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-ghost" id="axCancelEditBtn" style="display:none">취소하기</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% if (typeof adminMode !== 'undefined' && adminMode) { %>
|
||||||
|
<div id="axDeleteModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:1000;align-items:center;justify-content:center" class="flex-center">
|
||||||
|
<div style="background:#fff;padding:1.5rem;border-radius:8px;max-width:400px;width:90%;box-shadow:0 4px 20px rgba(0,0,0,.2)">
|
||||||
|
<h3 style="margin:0 0 1rem">삭제 확인</h3>
|
||||||
|
<div id="axDeleteModalBody" style="font-size:0.9rem;line-height:1.6;margin-bottom:1rem"></div>
|
||||||
|
<p style="margin:0 0 1rem"><strong>이 신청을 삭제할까요?</strong></p>
|
||||||
|
<div style="display:flex;gap:0.5rem;justify-content:flex-end">
|
||||||
|
<button type="button" id="axDeleteModalNo" class="btn-ghost">아니오</button>
|
||||||
|
<button type="button" id="axDeleteModalYes" class="top-action" style="background:#c00">네</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var form = document.getElementById('axApplyForm');
|
||||||
|
if (!form) return;
|
||||||
|
var axDlSubmitOk = (document.body.getAttribute('data-ax-apply-download-submit-allowed') || '0') === '1';
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!axDlSubmitOk) return;
|
||||||
|
var editId = (form.querySelector('#axEditId') || {}).value || '';
|
||||||
|
var fileInput = form.querySelector('input[name="applicationFile"]');
|
||||||
|
var isEdit = !!editId.trim();
|
||||||
|
var isAdmin = (document.body.getAttribute('data-ax-admin-mode') || '0') === '1';
|
||||||
|
var dept = (form.querySelector('input[name="department"]') || {}).value || '';
|
||||||
|
var nameVal = (form.querySelector('input[name="name"]') || {}).value || '';
|
||||||
|
var isSample = ((dept || '').trim() === '샘플' && (nameVal || '').trim() === '데이터');
|
||||||
|
if (isEdit && isSample && !isAdmin) {
|
||||||
|
alert('샘플 데이터는 참고용으로 수정할 수 없습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isEdit && (!fileInput || !fileInput.files || !fileInput.files.length)) {
|
||||||
|
alert('작성 완료 신청서(.docx) 파일을 업로드해 주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var fd = new FormData(form);
|
||||||
|
var btn = form.querySelector('button[type="submit"]');
|
||||||
|
btn.disabled = true;
|
||||||
|
var url = isEdit ? '/api/ax-apply/' + editId : '/api/ax-apply';
|
||||||
|
var method = isEdit ? 'PUT' : 'POST';
|
||||||
|
fetch(url, {
|
||||||
|
method: method,
|
||||||
|
body: fd
|
||||||
|
})
|
||||||
|
.then(function(r) {
|
||||||
|
return r.json().then(function(res) {
|
||||||
|
if (r.ok && res.ok) { alert(isEdit ? '수정되었습니다.' : '신청이 완료되었습니다.'); window.location.reload(); }
|
||||||
|
else { alert(res.error || res.message || '저장 실패'); }
|
||||||
|
}).catch(function() {
|
||||||
|
if (r.status === 403) alert('접근이 거부되었습니다. 서버 설정을 확인해주세요.');
|
||||||
|
else alert('저장 중 오류가 발생했습니다.');
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(function() { alert('저장 중 오류가 발생했습니다.'); })
|
||||||
|
.finally(function() { btn.disabled = false; });
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
(function() {
|
||||||
|
var btn = document.getElementById('axApplyListQueryBtn');
|
||||||
|
var form = document.getElementById('axApplyForm');
|
||||||
|
var wrap = document.getElementById('axAssignmentsListWrap');
|
||||||
|
if (!btn || !form || !wrap) return;
|
||||||
|
btn.addEventListener('click', function() {
|
||||||
|
var dept = (form.querySelector('input[name="department"]') || {}).value || '';
|
||||||
|
var name = (form.querySelector('input[name="name"]') || {}).value || '';
|
||||||
|
var email = (form.querySelector('input[name="email"]') || {}).value || '';
|
||||||
|
if (!dept.trim() || !name.trim() || !email.trim()) {
|
||||||
|
alert('소속 부서, 이름, 이메일을 모두 입력해 주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
btn.disabled = true;
|
||||||
|
fetch('/api/ax-apply-list?department=' + encodeURIComponent(dept) + '&name=' + encodeURIComponent(name) + '&email=' + encodeURIComponent(email))
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(list) {
|
||||||
|
if (!Array.isArray(list)) list = [];
|
||||||
|
if (list.length === 0) {
|
||||||
|
wrap.innerHTML = '<p class="empty">신청된 과제가 없습니다.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var html = '<div class="table-wrap"><table class="data-table"><thead><tr><th>순번</th><th>신청일</th><th>소속 부서</th><th>이름</th><th>사번</th><th>직급</th><th>이메일</th><th>AI에게 원하는 것</th><th>신청서</th><th>상태</th><th>수정</th></tr></thead><tbody>';
|
||||||
|
list.forEach(function(a, idx) {
|
||||||
|
var aiExp = (a.aiExpectation || '-');
|
||||||
|
var aiExpTitle = (a.aiExpectation || '').replace(/"/g, '"');
|
||||||
|
var aiExpShort = aiExp.length > 30 ? (a.aiExpectation || '').slice(0, 30) + '…' : aiExp;
|
||||||
|
var fileLink = a.applicationFile ? '<a href="' + a.applicationFile + '" target="_blank" download>보기</a>' : '-';
|
||||||
|
html += '<tr><td>' + (list.length - idx) + '</td><td>' + new Date(a.createdAt).toLocaleDateString('ko-KR') + '</td><td>' + (a.department || '-') + '</td><td>' + (a.name || '-') + '</td><td>' + (a.employeeId || '-') + '</td><td>' + (a.position || '-') + '</td><td>' + (a.email || '-') + '</td><td class="cell-truncate" title="' + aiExpTitle + '">' + aiExpShort + '</td><td>' + fileLink + '</td><td>' + (a.status || '신청') + '</td><td><a href="#" class="ax-edit-link" data-id="' + (a.id || '') + '">수정</a></td></tr>';
|
||||||
|
});
|
||||||
|
html += '</tbody></table></div>';
|
||||||
|
wrap.innerHTML = html;
|
||||||
|
})
|
||||||
|
.catch(function() { wrap.innerHTML = '<p class="empty">조회 중 오류가 발생했습니다.</p>'; })
|
||||||
|
.finally(function() { btn.disabled = false; });
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
(function() {
|
||||||
|
var wrap = document.getElementById('axAssignmentsListWrap');
|
||||||
|
var form = document.getElementById('axApplyForm');
|
||||||
|
var editIdEl = document.getElementById('axEditId');
|
||||||
|
var submitBtn = document.getElementById('axSubmitBtn');
|
||||||
|
var cancelBtn = document.getElementById('axCancelEditBtn');
|
||||||
|
var initBtn = form ? form.querySelector('button[onclick*="reset"]') : null;
|
||||||
|
var fileHint = document.getElementById('axFileHint');
|
||||||
|
var fileEditHint = document.getElementById('axFileEditHint');
|
||||||
|
var axDlSubmitOk = (document.body.getAttribute('data-ax-apply-download-submit-allowed') || '0') === '1';
|
||||||
|
function applyAxDlSubmitGate() {
|
||||||
|
if (!submitBtn || axDlSubmitOk) return;
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.setAttribute('disabled', 'disabled');
|
||||||
|
submitBtn.setAttribute('aria-disabled', 'true');
|
||||||
|
submitBtn.title = '로그인 후 이용 가능합니다.';
|
||||||
|
submitBtn.style.pointerEvents = '';
|
||||||
|
submitBtn.classList.remove('ax-sample-readonly');
|
||||||
|
}
|
||||||
|
function setEditMode(on) {
|
||||||
|
if (!editIdEl || !submitBtn || !cancelBtn) return;
|
||||||
|
if (on) {
|
||||||
|
editIdEl.value = editIdEl.value || '';
|
||||||
|
submitBtn.textContent = '수정하기';
|
||||||
|
submitBtn.style.display = '';
|
||||||
|
cancelBtn.style.display = '';
|
||||||
|
if (initBtn) initBtn.style.display = 'none';
|
||||||
|
if (fileHint) fileHint.style.display = 'none';
|
||||||
|
if (fileEditHint) fileEditHint.style.display = '';
|
||||||
|
applyAxDlSubmitGate();
|
||||||
|
} else {
|
||||||
|
editIdEl.value = '';
|
||||||
|
submitBtn.textContent = '신청하기';
|
||||||
|
submitBtn.style.display = '';
|
||||||
|
cancelBtn.style.display = 'none';
|
||||||
|
if (initBtn) initBtn.style.display = '';
|
||||||
|
if (fileHint) fileHint.style.display = '';
|
||||||
|
if (fileEditHint) fileEditHint.style.display = 'none';
|
||||||
|
if (axDlSubmitOk) {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.removeAttribute('disabled');
|
||||||
|
submitBtn.removeAttribute('aria-disabled');
|
||||||
|
submitBtn.classList.remove('ax-sample-readonly');
|
||||||
|
submitBtn.title = '';
|
||||||
|
submitBtn.style.pointerEvents = '';
|
||||||
|
} else {
|
||||||
|
applyAxDlSubmitGate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function populateForm(a) {
|
||||||
|
if (!form || !a) return;
|
||||||
|
var set = function(name, val) { var el = form.querySelector('[name="' + name + '"]'); if (el) el.value = val || ''; };
|
||||||
|
var setCheck = function(name, val) { var el = form.querySelector('[name="' + name + '"]'); if (el) el.checked = !!val; };
|
||||||
|
set('department', a.department);
|
||||||
|
set('name', a.name);
|
||||||
|
set('employeeId', a.employeeId);
|
||||||
|
set('position', a.position);
|
||||||
|
set('phone', a.phone);
|
||||||
|
set('email', a.email);
|
||||||
|
set('workProcessDescription', a.workProcessDescription);
|
||||||
|
set('painPoint', a.painPoint);
|
||||||
|
set('currentTimeSpent', a.currentTimeSpent);
|
||||||
|
set('errorRateBefore', a.errorRateBefore);
|
||||||
|
set('collaborationDepts', a.collaborationDepts);
|
||||||
|
set('reasonToSolve', a.reasonToSolve);
|
||||||
|
set('aiExpectation', a.aiExpectation);
|
||||||
|
set('outputType', a.outputType);
|
||||||
|
set('automationLevel', a.automationLevel);
|
||||||
|
set('dataReadiness', a.dataReadiness);
|
||||||
|
set('dataLocation', a.dataLocation);
|
||||||
|
set('personalInfo', a.personalInfo);
|
||||||
|
set('dataQuality', a.dataQuality);
|
||||||
|
set('dataCount', a.dataCount);
|
||||||
|
set('timeReduction', a.timeReduction);
|
||||||
|
set('errorReduction', a.errorReduction);
|
||||||
|
set('volumeIncrease', a.volumeIncrease);
|
||||||
|
set('costReduction', a.costReduction);
|
||||||
|
set('responseTime', a.responseTime);
|
||||||
|
set('otherMetrics', a.otherMetrics);
|
||||||
|
set('annualSavings', a.annualSavings);
|
||||||
|
set('laborReplacement', a.laborReplacement);
|
||||||
|
set('revenueIncrease', a.revenueIncrease);
|
||||||
|
set('otherEffects', a.otherEffects);
|
||||||
|
set('techStackStr', Array.isArray(a.techStack) ? a.techStack.join(', ') : (a.techStack || ''));
|
||||||
|
set('risksStr', Array.isArray(a.risks) ? a.risks.join(', ') : (a.risks || ''));
|
||||||
|
set('riskDetail', a.riskDetail);
|
||||||
|
setCheck('participationPledge', a.participationPledge);
|
||||||
|
if (editIdEl) editIdEl.value = a.id || '';
|
||||||
|
setEditMode(true);
|
||||||
|
var isAdmin = (document.body.getAttribute('data-ax-admin-mode') || '0') === '1';
|
||||||
|
var deptVal = (a.department || a.department_name || '').toString().trim();
|
||||||
|
var nameVal = (a.name || a.name_ko || '').toString().trim();
|
||||||
|
var isSample = (deptVal === '샘플' && nameVal === '데이터');
|
||||||
|
if (submitBtn) {
|
||||||
|
if (isSample && !isAdmin) {
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.setAttribute('disabled', 'disabled');
|
||||||
|
submitBtn.setAttribute('aria-disabled', 'true');
|
||||||
|
submitBtn.classList.add('ax-sample-readonly');
|
||||||
|
submitBtn.title = '샘플 데이터는 참고용으로 수정할 수 없습니다.';
|
||||||
|
submitBtn.style.pointerEvents = 'none';
|
||||||
|
} else if (axDlSubmitOk) {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.removeAttribute('disabled');
|
||||||
|
submitBtn.removeAttribute('aria-disabled');
|
||||||
|
submitBtn.classList.remove('ax-sample-readonly');
|
||||||
|
submitBtn.title = '';
|
||||||
|
submitBtn.style.pointerEvents = '';
|
||||||
|
} else {
|
||||||
|
applyAxDlSubmitGate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (wrap) {
|
||||||
|
wrap.addEventListener('click', function(e) {
|
||||||
|
var editLink = e.target && e.target.closest && e.target.closest('.ax-edit-link');
|
||||||
|
var deleteLink = e.target && e.target.closest && e.target.closest('.ax-delete-link');
|
||||||
|
if (editLink && form) {
|
||||||
|
e.preventDefault();
|
||||||
|
var id = (editLink.getAttribute('data-id') || '').trim();
|
||||||
|
if (!id) return;
|
||||||
|
var dept = (form.querySelector('input[name="department"]') || {}).value || '';
|
||||||
|
var name = (form.querySelector('input[name="name"]') || {}).value || '';
|
||||||
|
var email = (form.querySelector('input[name="email"]') || {}).value || '';
|
||||||
|
var q = '?department=' + encodeURIComponent(dept) + '&name=' + encodeURIComponent(name) + '&email=' + encodeURIComponent(email);
|
||||||
|
fetch('/api/ax-apply/' + id + q)
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(a) { if (a && a.id) populateForm(a); else alert(a && a.error ? a.error : '조회 실패'); })
|
||||||
|
.catch(function() { alert('조회 중 오류가 발생했습니다.'); });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (deleteLink) {
|
||||||
|
e.preventDefault();
|
||||||
|
var modal = document.getElementById('axDeleteModal');
|
||||||
|
var modalBody = document.getElementById('axDeleteModalBody');
|
||||||
|
if (!modal || !modalBody) return;
|
||||||
|
var id = (deleteLink.getAttribute('data-id') || '').trim();
|
||||||
|
var createdAt = deleteLink.getAttribute('data-created-at') || '-';
|
||||||
|
var dept = deleteLink.getAttribute('data-department') || '-';
|
||||||
|
var name = deleteLink.getAttribute('data-name') || '-';
|
||||||
|
var empId = deleteLink.getAttribute('data-employee-id') || '-';
|
||||||
|
var pos = deleteLink.getAttribute('data-position') || '-';
|
||||||
|
var email = deleteLink.getAttribute('data-email') || '-';
|
||||||
|
if (createdAt !== '-' && createdAt) {
|
||||||
|
try { createdAt = new Date(createdAt).toLocaleDateString('ko-KR'); } catch(err) {}
|
||||||
|
}
|
||||||
|
modalBody.innerHTML = '신청일: ' + createdAt + '<br>소속 부서: ' + dept + '<br>이름: ' + name + '<br>사번: ' + empId + '<br>직급: ' + pos + '<br>이메일: ' + email;
|
||||||
|
modal.dataset.deleteId = id;
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
(function() {
|
||||||
|
var modal = document.getElementById('axDeleteModal');
|
||||||
|
var btnYes = document.getElementById('axDeleteModalYes');
|
||||||
|
var btnNo = document.getElementById('axDeleteModalNo');
|
||||||
|
if (!modal || !btnYes || !btnNo) return;
|
||||||
|
btnNo.addEventListener('click', function() { modal.style.display = 'none'; modal.dataset.deleteId = ''; });
|
||||||
|
modal.addEventListener('click', function(e) { if (e.target === modal) { modal.style.display = 'none'; modal.dataset.deleteId = ''; } });
|
||||||
|
btnYes.addEventListener('click', function() {
|
||||||
|
var id = (modal.dataset.deleteId || '').trim();
|
||||||
|
if (!id) { modal.style.display = 'none'; return; }
|
||||||
|
btnYes.disabled = true;
|
||||||
|
fetch('/api/ax-apply/' + id, { method: 'DELETE' })
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(res) {
|
||||||
|
if (res.ok) { modal.style.display = 'none'; modal.dataset.deleteId = ''; window.location.reload(); }
|
||||||
|
else { alert(res.error || '삭제 실패'); }
|
||||||
|
})
|
||||||
|
.catch(function() { alert('삭제 중 오류가 발생했습니다.'); })
|
||||||
|
.finally(function() { btnYes.disabled = false; });
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
if (cancelBtn) {
|
||||||
|
cancelBtn.addEventListener('click', function() {
|
||||||
|
if (editIdEl) editIdEl.value = '';
|
||||||
|
setEditMode(false);
|
||||||
|
if (form) form.reset();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
342
views/chat.ejs
Normal file
342
views/chat.ejs
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>채팅 - XAVIS</title>
|
||||||
|
<link rel="stylesheet" href="/public/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-shell">
|
||||||
|
<%- include('partials/nav', { activeMenu: 'chat' }) %>
|
||||||
|
<div class="content-area chat-area">
|
||||||
|
<header class="topbar">
|
||||||
|
<h1>채팅</h1>
|
||||||
|
</header>
|
||||||
|
<main class="chat-main">
|
||||||
|
<p id="chatApiWarning" class="chat-api-warning" hidden>
|
||||||
|
<strong>API 키 없음.</strong> 프로젝트 루트 <code>.env</code>에 <code>OPENAI_API_KEY</code>를 설정한 뒤 서버를 재시작하세요.
|
||||||
|
</p>
|
||||||
|
<p id="chatGateNotice" class="chat-api-warning" hidden></p>
|
||||||
|
<div class="chat-messages" id="chatMessages">
|
||||||
|
<div class="chat-welcome" id="chatWelcome">
|
||||||
|
<h2>안녕하세요, 오늘 무엇을 도와드릴까요?</h2>
|
||||||
|
<p>AI와 대화하며 업무를 효율적으로 처리해보세요.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chat-input-wrap">
|
||||||
|
<form class="chat-form" id="chatForm">
|
||||||
|
<select id="chatModel" class="chat-model-select" title="채팅 모델">
|
||||||
|
<option value="gpt-5-mini" selected>gpt-5-mini</option>
|
||||||
|
<option value="gpt-5.4">gpt-5.4</option>
|
||||||
|
</select>
|
||||||
|
<textarea id="chatInput" placeholder="무엇이든 물어보세요" rows="1"></textarea>
|
||||||
|
<button type="submit" id="sendBtn" class="chat-send-btn" title="전송">↑</button>
|
||||||
|
</form>
|
||||||
|
<p class="chat-disclaimer">자비스는 실수를 할 수 있습니다. 중요한 정보는 재차 확인하세요.</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="/vendor/marked/marked.umd.js"></script>
|
||||||
|
<script src="/vendor/dompurify/purify.min.js"></script>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var chatGptAllowed = <%= JSON.stringify(!!chatGptAllowed) %>;
|
||||||
|
var opsState = <%- JSON.stringify(typeof opsState !== 'undefined' ? opsState : 'DEV') %>;
|
||||||
|
var adminMode = <%= JSON.stringify(!!adminMode) %>;
|
||||||
|
var opsUserEmail = <%= JSON.stringify(!!(typeof opsUserEmail !== 'undefined' && opsUserEmail)) %>;
|
||||||
|
|
||||||
|
const messagesEl = document.getElementById('chatMessages');
|
||||||
|
const welcomeEl = document.getElementById('chatWelcome');
|
||||||
|
const form = document.getElementById('chatForm');
|
||||||
|
const input = document.getElementById('chatInput');
|
||||||
|
const sendBtn = document.getElementById('sendBtn');
|
||||||
|
const modelSelect = document.getElementById('chatModel');
|
||||||
|
let conversationHistory = [];
|
||||||
|
|
||||||
|
function applyChatGptGate(allowed) {
|
||||||
|
chatGptAllowed = !!allowed;
|
||||||
|
var notice = document.getElementById('chatGateNotice');
|
||||||
|
if (chatGptAllowed) {
|
||||||
|
if (notice) notice.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (input) input.disabled = true;
|
||||||
|
if (sendBtn) sendBtn.disabled = true;
|
||||||
|
if (modelSelect) modelSelect.disabled = true;
|
||||||
|
if (notice) {
|
||||||
|
notice.hidden = false;
|
||||||
|
if (opsState === 'DEV' && !adminMode) {
|
||||||
|
notice.textContent = '로그인 후 이용 가능합니다.';
|
||||||
|
} else if (opsState === 'PROD' && !opsUserEmail) {
|
||||||
|
notice.textContent = '회사 이메일 인증(로그인) 후 이용할 수 있습니다.';
|
||||||
|
} else {
|
||||||
|
notice.textContent = '이 환경에서는 GPT 채팅을 사용할 수 없습니다.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyChatGptGate(chatGptAllowed);
|
||||||
|
|
||||||
|
fetch('/api/chat/config')
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(cfg) {
|
||||||
|
var w = document.getElementById('chatApiWarning');
|
||||||
|
if (w && cfg && !cfg.configured) w.hidden = false;
|
||||||
|
if (cfg && typeof cfg.chatGptAllowed === 'boolean') {
|
||||||
|
applyChatGptGate(cfg.chatGptAllowed);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() {});
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = s;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
var chatMarkdownConfigured = false;
|
||||||
|
|
||||||
|
function configureChatMarkdown() {
|
||||||
|
if (chatMarkdownConfigured) return;
|
||||||
|
chatMarkdownConfigured = true;
|
||||||
|
if (typeof marked !== 'undefined') {
|
||||||
|
marked.setOptions({ breaks: true, gfm: true });
|
||||||
|
}
|
||||||
|
if (typeof DOMPurify !== 'undefined') {
|
||||||
|
DOMPurify.addHook('afterSanitizeAttributes', function(node) {
|
||||||
|
if (node.tagName === 'A' && node.hasAttribute('href')) {
|
||||||
|
var href = node.getAttribute('href');
|
||||||
|
try {
|
||||||
|
var u = new URL(href, window.location.href);
|
||||||
|
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
||||||
|
node.removeAttribute('href');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
node.removeAttribute('href');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
node.setAttribute('target', '_blank');
|
||||||
|
node.setAttribute('rel', 'noopener noreferrer');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 마크다운 → HTML 후 DOMPurify로 정제 (marked 출력은 신뢰하지 않음) */
|
||||||
|
function renderAssistantMarkdown(text) {
|
||||||
|
configureChatMarkdown();
|
||||||
|
if (typeof marked === 'undefined' || typeof DOMPurify === 'undefined') {
|
||||||
|
return escapeHtml(text).replace(/\n/g, '<br>');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var raw = marked.parse(String(text || ''), { async: false });
|
||||||
|
return DOMPurify.sanitize(raw, { USE_PROFILES: { html: true } });
|
||||||
|
} catch (err) {
|
||||||
|
return escapeHtml(text).replace(/\n/g, '<br>');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMessage(role, content) {
|
||||||
|
if (welcomeEl) welcomeEl.style.display = 'none';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'chat-msg ' + (role === 'user' ? 'chat-msg-user' : 'chat-msg-assistant');
|
||||||
|
var inner =
|
||||||
|
role === 'assistant'
|
||||||
|
? renderAssistantMarkdown(content)
|
||||||
|
: escapeHtml(content).replace(/\n/g, '<br>');
|
||||||
|
var contentClass =
|
||||||
|
role === 'assistant' ? 'chat-msg-content chat-md-body' : 'chat-msg-content';
|
||||||
|
div.innerHTML = '<div class="' + contentClass + '">' + inner + '</div>';
|
||||||
|
messagesEl.appendChild(div);
|
||||||
|
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendAssistantStreamingPlaceholder() {
|
||||||
|
if (welcomeEl) welcomeEl.style.display = 'none';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'chat-msg chat-msg-assistant chat-msg-streaming';
|
||||||
|
const inner = document.createElement('div');
|
||||||
|
inner.className = 'chat-msg-content chat-md-body';
|
||||||
|
inner.innerHTML =
|
||||||
|
'<span class="chat-typing-dots" aria-label="응답 생성 중"><span class="chat-typing-dot">·</span><span class="chat-typing-dot">·</span><span class="chat-typing-dot">·</span></span>';
|
||||||
|
div.appendChild(inner);
|
||||||
|
const statusEl = document.createElement('div');
|
||||||
|
statusEl.className = 'chat-status-line';
|
||||||
|
statusEl.hidden = true;
|
||||||
|
statusEl.setAttribute('aria-live', 'polite');
|
||||||
|
div.appendChild(statusEl);
|
||||||
|
const sourcesEl = document.createElement('div');
|
||||||
|
sourcesEl.className = 'chat-sources';
|
||||||
|
sourcesEl.hidden = true;
|
||||||
|
div.appendChild(sourcesEl);
|
||||||
|
messagesEl.appendChild(div);
|
||||||
|
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||||
|
return { bubble: div, contentEl: inner, statusEl: statusEl, sourcesEl: sourcesEl };
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendSourceLinks(sourcesEl, items) {
|
||||||
|
if (!sourcesEl || !items || !items.length) return;
|
||||||
|
sourcesEl.innerHTML = '';
|
||||||
|
const title = document.createElement('div');
|
||||||
|
title.className = 'chat-sources-title';
|
||||||
|
title.textContent = '출처';
|
||||||
|
sourcesEl.appendChild(title);
|
||||||
|
const ol = document.createElement('ol');
|
||||||
|
ol.className = 'chat-sources-list';
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const item = items[i];
|
||||||
|
const url = item && item.url;
|
||||||
|
if (!url || typeof url !== 'string') continue;
|
||||||
|
let href;
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
if (u.protocol !== 'http:' && u.protocol !== 'https:') continue;
|
||||||
|
href = u.href;
|
||||||
|
} catch (e) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const li = document.createElement('li');
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = href;
|
||||||
|
a.target = '_blank';
|
||||||
|
a.rel = 'noopener noreferrer';
|
||||||
|
const t = item.title && String(item.title).trim();
|
||||||
|
a.textContent = t || href;
|
||||||
|
li.appendChild(a);
|
||||||
|
ol.appendChild(li);
|
||||||
|
}
|
||||||
|
if (ol.children.length) {
|
||||||
|
sourcesEl.appendChild(ol);
|
||||||
|
sourcesEl.hidden = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStreamingContent(contentEl, text) {
|
||||||
|
contentEl.innerHTML = renderAssistantMarkdown(text);
|
||||||
|
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLoading(loading) {
|
||||||
|
sendBtn.disabled = loading;
|
||||||
|
sendBtn.textContent = loading ? '...' : '↑';
|
||||||
|
sendBtn.classList.toggle('is-busy', loading);
|
||||||
|
sendBtn.setAttribute('aria-busy', loading ? 'true' : 'false');
|
||||||
|
if (modelSelect) modelSelect.disabled = loading;
|
||||||
|
if (input) input.disabled = loading;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readSseStream(response, body) {
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
let fullText = '';
|
||||||
|
let started = false;
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
let sep;
|
||||||
|
while ((sep = buffer.indexOf('\n\n')) !== -1) {
|
||||||
|
const block = buffer.slice(0, sep);
|
||||||
|
buffer = buffer.slice(sep + 2);
|
||||||
|
const lines = block.split('\n');
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i];
|
||||||
|
if (line.indexOf('data: ') !== 0) continue;
|
||||||
|
let obj;
|
||||||
|
try {
|
||||||
|
obj = JSON.parse(line.slice(6));
|
||||||
|
} catch (parseErr) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (obj.type === 'error') {
|
||||||
|
throw new Error(obj.error || '스트림 오류');
|
||||||
|
}
|
||||||
|
if (obj.type === 'status' && obj.phase === 'web_search' && body.statusEl) {
|
||||||
|
body.statusEl.textContent = '웹 검색 중…';
|
||||||
|
body.statusEl.hidden = false;
|
||||||
|
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||||
|
}
|
||||||
|
if (obj.type === 'sources' && body.sourcesEl && obj.items) {
|
||||||
|
appendSourceLinks(body.sourcesEl, obj.items);
|
||||||
|
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||||
|
}
|
||||||
|
if (obj.type === 'done' && body.statusEl) {
|
||||||
|
body.statusEl.hidden = true;
|
||||||
|
}
|
||||||
|
if (obj.type === 'delta' && obj.text) {
|
||||||
|
if (body.statusEl) body.statusEl.hidden = true;
|
||||||
|
if (!started) {
|
||||||
|
started = true;
|
||||||
|
body.bubble.classList.remove('chat-msg-streaming');
|
||||||
|
}
|
||||||
|
fullText += obj.text;
|
||||||
|
setStreamingContent(body.contentEl, fullText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fullText;
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!chatGptAllowed) return;
|
||||||
|
const text = (input.value || '').trim();
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
addMessage('user', text);
|
||||||
|
conversationHistory.push({ role: 'user', content: text });
|
||||||
|
input.value = '';
|
||||||
|
input.style.height = 'auto';
|
||||||
|
|
||||||
|
const body = appendAssistantStreamingPlaceholder();
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const model = document.getElementById('chatModel').value;
|
||||||
|
const res = await fetch('/api/chat/stream', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ model: model, messages: conversationHistory.slice(-20) })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const errData = await res.json().catch(function() { return {}; });
|
||||||
|
body.bubble.remove();
|
||||||
|
addMessage('assistant', '오류: ' + (errData.error || res.statusText));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullReply = await readSseStream(res, body);
|
||||||
|
body.bubble.classList.remove('chat-msg-streaming');
|
||||||
|
if (!fullReply.trim()) {
|
||||||
|
setStreamingContent(body.contentEl, '(응답이 비어 있습니다.)');
|
||||||
|
}
|
||||||
|
conversationHistory.push({ role: 'assistant', content: fullReply || '(빈 응답)' });
|
||||||
|
} catch (err) {
|
||||||
|
body.bubble.remove();
|
||||||
|
addMessage('assistant', '오류: ' + (err.message || '네트워크 오류'));
|
||||||
|
} finally {
|
||||||
|
if (body && body.statusEl) body.statusEl.hidden = true;
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener('input', function() {
|
||||||
|
this.style.height = 'auto';
|
||||||
|
this.style.height = Math.min(this.scrollHeight, 120) + 'px';
|
||||||
|
});
|
||||||
|
input.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
form.dispatchEvent(new Event('submit'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
333
views/index.ejs
Normal file
333
views/index.ejs
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>학습센터</title>
|
||||||
|
<link rel="stylesheet" href="/public/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<% 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 || ''); }; } %>
|
||||||
|
<div class="app-shell">
|
||||||
|
<%- include('partials/nav', { activeMenu: 'learning' }) %>
|
||||||
|
|
||||||
|
<div class="content-area">
|
||||||
|
<header class="topbar">
|
||||||
|
<h1>학습센터</h1>
|
||||||
|
<button type="button" class="top-action-link" onclick="openAdminTokenModal()">강의 등록하기</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="container">
|
||||||
|
<section class="hero panel">
|
||||||
|
<h2>최신 컨텐츠로 학습하고, 바로 업무에 적용하세요.</h2>
|
||||||
|
<p>유튜브 링크 또는 PPT를 등록한 뒤, 목록에서 클릭하여 강의를 시청할 수 있습니다.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel filter-panel">
|
||||||
|
<h2>강의 검색/필터</h2>
|
||||||
|
<form action="/" method="get" class="filter-grid">
|
||||||
|
<label>
|
||||||
|
검색어
|
||||||
|
<input type="text" name="q" value="<%= filters.q %>" placeholder="제목" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
타입
|
||||||
|
<select name="type">
|
||||||
|
<option value="all" <%= filters.type === "all" ? "selected" : "" %>>전체</option>
|
||||||
|
<option value="youtube" <%= filters.type === "youtube" ? "selected" : "" %>>YouTube</option>
|
||||||
|
<option value="ppt" <%= filters.type === "ppt" ? "selected" : "" %>>PPT</option>
|
||||||
|
<option value="video" <%= filters.type === "video" ? "selected" : "" %>>동영상</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
태그
|
||||||
|
<select name="tag">
|
||||||
|
<option value="">전체</option>
|
||||||
|
<% availableTags.forEach((oneTag) => { %>
|
||||||
|
<option value="<%= oneTag %>" <%= filters.tag === oneTag ? "selected" : "" %>><%= oneTag %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<% if (adminRequested) { %>
|
||||||
|
<input type="hidden" name="admin" value="1" />
|
||||||
|
<input type="hidden" name="token" value="<%= tokenRaw %>" />
|
||||||
|
<% } %>
|
||||||
|
<div class="filter-actions">
|
||||||
|
<button type="submit">필터 적용</button>
|
||||||
|
<a class="link-muted" href="/">초기화</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel admin-panel">
|
||||||
|
<h2>관리자 모드</h2>
|
||||||
|
<% if (retryQueued > 0) { %>
|
||||||
|
<p class="admin-ok">실패 건 재시도 <%= retryQueued %>건이 큐에 등록되었습니다.</p>
|
||||||
|
<% } %>
|
||||||
|
<% if (adminMode) { %>
|
||||||
|
<p class="admin-ok">관리자 모드 활성화됨</p>
|
||||||
|
<div class="queue-status">
|
||||||
|
<span>큐: <b><%= thumbnailQueueInfo.pending %></b></span>
|
||||||
|
<span>워커: <b><%= thumbnailQueueInfo.working ? "작동중" : "대기" %></b></span>
|
||||||
|
<span>실패 재시도 최대: <b><%= thumbnailQueueInfo.maxRetry %></b></span>
|
||||||
|
</div>
|
||||||
|
<div class="queue-status">
|
||||||
|
<span>PPT 썸네일 - 준비완료 <b><%= thumbnailStatusSummary.ready %></b></span>
|
||||||
|
<span>처리중 <b><%= thumbnailStatusSummary.processing %></b></span>
|
||||||
|
<span>대기 <b><%= thumbnailStatusSummary.pending %></b></span>
|
||||||
|
<span>실패 <b><%= thumbnailStatusSummary.failed %></b></span>
|
||||||
|
</div>
|
||||||
|
<% } else { %>
|
||||||
|
<p class="admin-warn">삭제 기능은 관리자 토큰이 있어야 활성화됩니다.</p>
|
||||||
|
<% } %>
|
||||||
|
<form action="/" method="get" class="admin-inline">
|
||||||
|
<input type="hidden" name="q" value="<%= filters.q %>" />
|
||||||
|
<input type="hidden" name="type" value="<%= filters.type %>" />
|
||||||
|
<input type="hidden" name="tag" value="<%= filters.tag %>" />
|
||||||
|
<input type="hidden" name="page" value="<%= pagination.page %>" />
|
||||||
|
<input type="hidden" name="admin" value="1" />
|
||||||
|
<input type="password" name="token" placeholder="관리자 토큰" />
|
||||||
|
<button type="submit">관리자 활성화</button>
|
||||||
|
</form>
|
||||||
|
<% if (adminMode) { %>
|
||||||
|
<form action="/thumbnails/retry-failed" method="post" class="admin-inline">
|
||||||
|
<input type="hidden" name="token" value="<%= tokenRaw %>" />
|
||||||
|
<input type="hidden" name="returnTo" value="?<%= returnQuery %>" />
|
||||||
|
<button type="submit" class="ghost">실패 썸네일 일괄 재시도</button>
|
||||||
|
</form>
|
||||||
|
<p class="api-hint">메트릭 API: <code>/api/queue/metrics?token=***</code></p>
|
||||||
|
<p class="api-hint">
|
||||||
|
<a href="/admin/thumbnail-events?token=<%= tokenRaw %>" class="link-muted">이벤트 로그 페이지 열기</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="dashboard-grid">
|
||||||
|
<section class="mini-panel">
|
||||||
|
<h3>실패 원인 TOP</h3>
|
||||||
|
<% if (!failureReasons.length) { %>
|
||||||
|
<p class="empty">최근 실패 원인이 없습니다.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<ul class="plain-list">
|
||||||
|
<% failureReasons.slice(0, 5).forEach((item) => { %>
|
||||||
|
<li>
|
||||||
|
<b><%= item.count %>회</b>
|
||||||
|
<span><%= item.reason %></span>
|
||||||
|
</li>
|
||||||
|
<% }) %>
|
||||||
|
</ul>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mini-panel">
|
||||||
|
<h3>최근 썸네일 이벤트</h3>
|
||||||
|
<% if (!recentEvents.length) { %>
|
||||||
|
<p class="empty">이벤트 로그가 없습니다.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<ul class="plain-list">
|
||||||
|
<% recentEvents.forEach((evt) => { %>
|
||||||
|
<li>
|
||||||
|
<span class="evt-type <%= evt.type %>"><%= evt.type %></span>
|
||||||
|
<span><%= evt.lectureTitle || evt.lectureId %></span>
|
||||||
|
<small><%= new Date(evt.at).toLocaleString("ko-KR") %></small>
|
||||||
|
</li>
|
||||||
|
<% }) %>
|
||||||
|
</ul>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="register" class="panel">
|
||||||
|
<h2>유튜브 강의 등록</h2>
|
||||||
|
<form action="/lectures/youtube" method="post" class="form-grid">
|
||||||
|
<label>
|
||||||
|
제목
|
||||||
|
<input type="text" name="title" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
유튜브 링크
|
||||||
|
<input type="url" name="youtubeUrl" placeholder="https://www.youtube.com/watch?v=..." required />
|
||||||
|
</label>
|
||||||
|
<label class="full">
|
||||||
|
설명
|
||||||
|
<textarea name="description" rows="3" placeholder="강의 설명"></textarea>
|
||||||
|
</label>
|
||||||
|
<label class="full">
|
||||||
|
태그 (쉼표 구분)
|
||||||
|
<input type="text" name="tags" placeholder="예: AI에이전트, 바이브코딩" />
|
||||||
|
</label>
|
||||||
|
<button type="submit">유튜브 강의 등록</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>PDF/PowerPoint 강의 등록</h2>
|
||||||
|
<form action="/lectures/ppt" method="post" enctype="multipart/form-data" class="form-grid">
|
||||||
|
<label>
|
||||||
|
제목
|
||||||
|
<input type="text" name="title" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
PDF 또는 PPT 파일
|
||||||
|
<input type="file" name="pptFile" accept=".pdf,.pptx" required />
|
||||||
|
</label>
|
||||||
|
<label class="full">
|
||||||
|
설명
|
||||||
|
<textarea name="description" rows="3" placeholder="강의 설명"></textarea>
|
||||||
|
</label>
|
||||||
|
<label class="full">
|
||||||
|
태그 (쉼표 구분)
|
||||||
|
<input type="text" name="tags" placeholder="예: 프롬프트, 생성형AI" />
|
||||||
|
</label>
|
||||||
|
<button type="submit">강의 등록</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2>동영상 파일 등록</h2>
|
||||||
|
<form action="/lectures/video" method="post" enctype="multipart/form-data" class="form-grid">
|
||||||
|
<label>
|
||||||
|
제목
|
||||||
|
<input type="text" name="title" required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
동영상 파일 (MP4·WebM·MOV)
|
||||||
|
<input type="file" name="videoFile" accept=".mp4,.webm,.mov,video/mp4,video/webm,video/quicktime" required />
|
||||||
|
</label>
|
||||||
|
<label class="full">
|
||||||
|
설명
|
||||||
|
<textarea name="description" rows="3" placeholder="강의 설명"></textarea>
|
||||||
|
</label>
|
||||||
|
<label class="full">
|
||||||
|
태그 (쉼표 구분)
|
||||||
|
<input type="text" name="tags" placeholder="예: 온보딩, 내부교육" />
|
||||||
|
</label>
|
||||||
|
<button type="submit">동영상 등록</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>등록된 강의</h2>
|
||||||
|
<span class="count-chip">총 <%= pagination.totalCount %>건</span>
|
||||||
|
</div>
|
||||||
|
<% if (!lectures.length) { %>
|
||||||
|
<p class="empty">등록된 강의가 없습니다.</p>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<div class="lecture-grid">
|
||||||
|
<% lectures.forEach((lecture) => { %>
|
||||||
|
<% var _externalUrl = (lecture.type === "link" || lecture.type === "news") && (lecture.newsUrl || "").trim(); %>
|
||||||
|
<article class="lecture-card">
|
||||||
|
<% if (_externalUrl) { %>
|
||||||
|
<a class="lecture-link lecture-link-external" href="<%= _externalUrl %>" target="_blank" rel="noopener noreferrer">
|
||||||
|
<% } else { %>
|
||||||
|
<a class="lecture-link" href="/lectures/<%= lecture.id %>">
|
||||||
|
<% } %>
|
||||||
|
<div class="thumb <%= lecture.type %>">
|
||||||
|
<% if (lecture.type === "ppt") { %>
|
||||||
|
<% if (lecture.thumbnailUrl) { %>
|
||||||
|
<img src="<%= lecture.thumbnailUrl %>" alt="<%= lecture.title %> 썸네일" class="thumb-image" />
|
||||||
|
<% } else { %>
|
||||||
|
<span class="thumb-fallback">썸네일 <%= lecture.thumbnailStatus || "pending" %></span>
|
||||||
|
<% } %>
|
||||||
|
<span class="thumb-kicker">PPT 프리뷰</span>
|
||||||
|
<% if (lecture.previewTitle && lecture.previewTitle !== "제목 없음") { %><strong><%= lecture.previewTitle %></strong><% } %>
|
||||||
|
<% } else if (lecture.type === "news") { %>
|
||||||
|
<% if (lecture.thumbnailUrl) { %>
|
||||||
|
<img src="<%= lecture.thumbnailUrl %>" alt="" class="thumb-image thumb-image-og" loading="lazy" referrerpolicy="no-referrer" />
|
||||||
|
<% } %>
|
||||||
|
<span class="thumb-kicker">뉴스</span>
|
||||||
|
<% if (!lecture.thumbnailUrl) { %><strong>외부 링크</strong><% } %>
|
||||||
|
<% } else if (lecture.type === "link") { %>
|
||||||
|
<% if (lecture.thumbnailUrl) { %>
|
||||||
|
<img src="<%= lecture.thumbnailUrl %>" alt="" class="thumb-image thumb-image-og" loading="lazy" referrerpolicy="no-referrer" />
|
||||||
|
<% } %>
|
||||||
|
<span class="thumb-kicker">웹 링크</span>
|
||||||
|
<% if (!lecture.thumbnailUrl) { %><strong>외부 페이지</strong><% } %>
|
||||||
|
<% } else if (lecture.type === "video") { %>
|
||||||
|
<span class="thumb-fallback thumb-fallback-video">▶</span>
|
||||||
|
<span class="thumb-kicker">동영상 파일</span>
|
||||||
|
<strong>업로드 영상</strong>
|
||||||
|
<% } else { %>
|
||||||
|
<% const ytThumb = typeof getYoutubeThumbnailUrl === 'function' ? getYoutubeThumbnailUrl(lecture.youtubeUrl) : null; %>
|
||||||
|
<% if (ytThumb) { %>
|
||||||
|
<img src="<%= ytThumb %>" alt="<%= lecture.title %> 썸네일" class="thumb-image thumb-image-youtube" />
|
||||||
|
<% } %>
|
||||||
|
<span class="thumb-kicker">YouTube</span>
|
||||||
|
<strong>영상 강의</strong>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<div class="badge <%= lecture.type %>">
|
||||||
|
<% if (lecture.type === "youtube") { %>YouTube<% } else if (lecture.type === "news") { %>뉴스<% } else if (lecture.type === "link") { %>링크<% } else if (lecture.type === "video") { %>동영상<% } else { %>PPT<% } %>
|
||||||
|
</div>
|
||||||
|
<h3><%= lecture.title %></h3>
|
||||||
|
<p><%= lecture.description || "설명이 없습니다." %></p>
|
||||||
|
<div class="tag-row">
|
||||||
|
<% (lecture.tags || []).forEach((oneTag) => { %>
|
||||||
|
<span class="tag-chip">#<%= oneTag %></span>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
<small><%= new Date(lecture.createdAt).toLocaleString("ko-KR") %></small>
|
||||||
|
</a>
|
||||||
|
<% if (adminMode) { %>
|
||||||
|
<form action="/lectures/<%= lecture.id %>/delete" method="post" class="delete-form">
|
||||||
|
<input type="hidden" name="token" value="<%= tokenRaw %>" />
|
||||||
|
<input type="hidden" name="returnTo" value="?<%= returnQuery %>" />
|
||||||
|
<button type="submit" class="danger">삭제</button>
|
||||||
|
</form>
|
||||||
|
<% if (lecture.type === "ppt") { %>
|
||||||
|
<div class="thumb-state-row">
|
||||||
|
<span class="state-chip <%= lecture.thumbnailStatus || "pending" %>">
|
||||||
|
<%= lecture.thumbnailStatus || "pending" %>
|
||||||
|
</span>
|
||||||
|
<% if (lecture.thumbnailError) { %>
|
||||||
|
<small class="error-text"><%= lecture.thumbnailError %></small>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<form action="/lectures/<%= lecture.id %>/thumbnail/regenerate" method="post" class="delete-form">
|
||||||
|
<input type="hidden" name="token" value="<%= tokenRaw %>" />
|
||||||
|
<input type="hidden" name="returnTo" value="?<%= returnQuery %>" />
|
||||||
|
<button type="submit" class="ghost">썸네일 재생성</button>
|
||||||
|
</form>
|
||||||
|
<% } %>
|
||||||
|
<% } %>
|
||||||
|
</article>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if (pagination.totalPages > 1) { %>
|
||||||
|
<nav class="pagination">
|
||||||
|
<% if (pagination.hasPrev) { %>
|
||||||
|
<a href="/?<%= pagination.prevQuery %>">이전</a>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<% pagination.pages.forEach((p) => { %>
|
||||||
|
<a href="/?<%= p.query %>" class="<%= p.active ? "active" : "" %>"><%= p.page %></a>
|
||||||
|
<% }) %>
|
||||||
|
|
||||||
|
<% if (pagination.hasNext) { %>
|
||||||
|
<a href="/?<%= pagination.nextQuery %>">다음</a>
|
||||||
|
<% } %>
|
||||||
|
</nav>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
335
views/learning-admin.ejs
Normal file
335
views/learning-admin.ejs
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<% var admTitle = typeof adminPageTitle !== 'undefined' ? adminPageTitle : '학습센터 관리'; %>
|
||||||
|
<title><%= admTitle %> - XAVIS</title>
|
||||||
|
<link rel="stylesheet" href="/public/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<% 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'; %>
|
||||||
|
<div class="app-shell">
|
||||||
|
<%- include('partials/nav', { activeMenu: navMenu, adminMode: typeof adminMode !== 'undefined' ? adminMode : false }) %>
|
||||||
|
<div class="content-area">
|
||||||
|
<header class="topbar">
|
||||||
|
<h1><%= admTitle %></h1>
|
||||||
|
<a class="top-action-link" href="<%= viewerBasePath %>">목록 보기</a>
|
||||||
|
</header>
|
||||||
|
<main class="container">
|
||||||
|
<section class="panel admin-panel">
|
||||||
|
<div class="admin-mode-header">
|
||||||
|
<h2>관리자 모드</h2>
|
||||||
|
<div class="admin-mode-toggle">
|
||||||
|
<% if (adminMode) { %>
|
||||||
|
<span class="admin-status active">활성</span>
|
||||||
|
<a href="<%= adminBasePath %>?q=<%= filters.q %>&type=<%= filters.type %>&tag=<%= filters.tag %>&category=<%= filters.category || '' %>&page=<%= pagination.page %>" class="btn-admin-off">비활성화</a>
|
||||||
|
<% } else { %>
|
||||||
|
<span class="admin-status inactive">비활성</span>
|
||||||
|
<form action="<%= adminBasePath %>" method="get" class="admin-activate-inline">
|
||||||
|
<input type="hidden" name="q" value="<%= filters.q %>" />
|
||||||
|
<input type="hidden" name="type" value="<%= filters.type %>" />
|
||||||
|
<input type="hidden" name="tag" value="<%= filters.tag %>" />
|
||||||
|
<input type="hidden" name="category" value="<%= filters.category || '' %>" />
|
||||||
|
<input type="hidden" name="page" value="<%= pagination.page %>" />
|
||||||
|
<input type="password" name="token" placeholder="관리자 토큰" required autocomplete="off" />
|
||||||
|
<button type="submit">활성화</button>
|
||||||
|
</form>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% if (retryQueued > 0) { %>
|
||||||
|
<p class="admin-ok">실패 건 재시도 <%= retryQueued %>건이 큐에 등록되었습니다.</p>
|
||||||
|
<% } %>
|
||||||
|
<% if (adminMode) { %>
|
||||||
|
<div class="queue-status">
|
||||||
|
<span>큐: <b><%= thumbnailQueueInfo.pending %></b></span>
|
||||||
|
<span>워커: <b><%= thumbnailQueueInfo.working ? "작동중" : "대기" %></b></span>
|
||||||
|
<span>실패 재시도 최대: <b><%= thumbnailQueueInfo.maxRetry %></b></span>
|
||||||
|
</div>
|
||||||
|
<div class="queue-status">
|
||||||
|
<span>PPT 썸네일 - 준비완료 <b><%= thumbnailStatusSummary.ready %></b></span>
|
||||||
|
<span>처리중 <b><%= thumbnailStatusSummary.processing %></b></span>
|
||||||
|
<span>대기 <b><%= thumbnailStatusSummary.pending %></b></span>
|
||||||
|
<span>실패 <b><%= thumbnailStatusSummary.failed %></b></span>
|
||||||
|
</div>
|
||||||
|
<% } else { %>
|
||||||
|
<% if (adminRequested) { %>
|
||||||
|
<p class="admin-error">입력한 토큰이 올바르지 않습니다. 다시 확인해주세요.</p>
|
||||||
|
<% } %>
|
||||||
|
<p class="admin-warn">삭제·썸네일 관리는 관리자 토큰을 입력한 뒤 <strong>활성화</strong>를 눌러주세요.</p>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
<div
|
||||||
|
class="admin-body-gated <%= adminMode ? 'admin-body-gated--unlocked' : 'admin-body-gated--locked' %>"
|
||||||
|
<%= !adminMode ? 'inert' : '' %>
|
||||||
|
>
|
||||||
|
<section id="register" class="panel">
|
||||||
|
<h2>유튜브 강의 등록</h2>
|
||||||
|
<form action="/lectures/youtube" method="post" class="form-grid">
|
||||||
|
<input type="hidden" name="token" value="<%= tokenRaw %>" />
|
||||||
|
<input type="hidden" name="returnTo" value="<%= adminBasePath %>?<%= returnQuery %>" />
|
||||||
|
<label>제목 <input type="text" name="title" required /></label>
|
||||||
|
<label>유튜브 링크 <input type="url" name="youtubeUrl" placeholder="https://www.youtube.com/watch?v=..." required /></label>
|
||||||
|
<label class="full">설명 <textarea name="description" rows="3" placeholder="강의 설명"></textarea></label>
|
||||||
|
<label class="full">카테고리
|
||||||
|
<div class="category-radios-inline">
|
||||||
|
<label><input type="radio" name="category" value="" checked />선택 안함</label>
|
||||||
|
<label><input type="radio" name="category" value="AX 사고 전환" />AX 사고 전환</label>
|
||||||
|
<label><input type="radio" name="category" value="AI 툴 활용" />AI 툴 활용</label>
|
||||||
|
<label><input type="radio" name="category" value="AI Agent" />AI Agent</label>
|
||||||
|
<label><input type="radio" name="category" value="바이브 코딩" />바이브 코딩</label>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="full">태그 (쉼표 구분) <input type="text" name="tags" placeholder="예: AI에이전트, 바이브코딩" /></label>
|
||||||
|
<button type="submit">유튜브 등록</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>PDF/PowerPoint 강의 등록</h2>
|
||||||
|
<form action="/lectures/ppt" method="post" enctype="multipart/form-data" class="form-grid">
|
||||||
|
<input type="hidden" name="token" value="<%= tokenRaw %>" />
|
||||||
|
<input type="hidden" name="returnTo" value="<%= adminBasePath %>?<%= returnQuery %>" />
|
||||||
|
<label>제목 <input type="text" name="title" required /></label>
|
||||||
|
<label>PDF 또는 PPT 파일 <input type="file" name="pptFile" accept=".pdf,.pptx" required /></label>
|
||||||
|
<label class="full">설명 <textarea name="description" rows="3" placeholder="강의 설명"></textarea></label>
|
||||||
|
<label class="full">카테고리
|
||||||
|
<div class="category-radios-inline">
|
||||||
|
<label><input type="radio" name="category" value="" checked />선택 안함</label>
|
||||||
|
<label><input type="radio" name="category" value="AX 사고 전환" />AX 사고 전환</label>
|
||||||
|
<label><input type="radio" name="category" value="AI 툴 활용" />AI 툴 활용</label>
|
||||||
|
<label><input type="radio" name="category" value="AI Agent" />AI Agent</label>
|
||||||
|
<label><input type="radio" name="category" value="바이브 코딩" />바이브 코딩</label>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="full">태그 (쉼표 구분) <input type="text" name="tags" placeholder="예: 프롬프트, 생성형AI" /></label>
|
||||||
|
<button type="submit">파일 등록</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>동영상 파일 등록</h2>
|
||||||
|
<p class="muted" style="margin-top:0;font-size:13px;">MP4·WebM·MOV 파일을 서버에 저장한 뒤 브라우저에서 재생합니다. (용량 제한은 서버 설정 <code>LECTURE_VIDEO_MAX_MB</code>, 기본 500MB)</p>
|
||||||
|
<form action="/lectures/video" method="post" enctype="multipart/form-data" class="form-grid">
|
||||||
|
<input type="hidden" name="token" value="<%= tokenRaw %>" />
|
||||||
|
<input type="hidden" name="returnTo" value="<%= adminBasePath %>?<%= returnQuery %>" />
|
||||||
|
<label>제목 <input type="text" name="title" required /></label>
|
||||||
|
<label>동영상 파일 <input type="file" name="videoFile" accept=".mp4,.webm,.mov,video/mp4,video/webm,video/quicktime" required /></label>
|
||||||
|
<label class="full">설명 <textarea name="description" rows="3" placeholder="강의 설명"></textarea></label>
|
||||||
|
<label class="full">카테고리
|
||||||
|
<div class="category-radios-inline">
|
||||||
|
<label><input type="radio" name="category" value="" checked />선택 안함</label>
|
||||||
|
<label><input type="radio" name="category" value="AX 사고 전환" />AX 사고 전환</label>
|
||||||
|
<label><input type="radio" name="category" value="AI 툴 활용" />AI 툴 활용</label>
|
||||||
|
<label><input type="radio" name="category" value="AI Agent" />AI Agent</label>
|
||||||
|
<label><input type="radio" name="category" value="바이브 코딩" />바이브 코딩</label>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="full">태그 (쉼표 구분) <input type="text" name="tags" placeholder="예: 온보딩, 내부교육" /></label>
|
||||||
|
<button type="submit">동영상 등록</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<h2>웹 링크 강의 등록</h2>
|
||||||
|
<p class="muted" style="margin-top:0;font-size:13px;">외부 사이트(http/https)를 카드로 등록합니다. (유튜브는 위 «유튜브 강의 등록»을 사용하세요.)</p>
|
||||||
|
<form action="/lectures/link" method="post" class="form-grid">
|
||||||
|
<input type="hidden" name="token" value="<%= tokenRaw %>" />
|
||||||
|
<input type="hidden" name="returnTo" value="<%= adminBasePath %>?<%= returnQuery %>" />
|
||||||
|
<label>제목 <input type="text" name="title" required /></label>
|
||||||
|
<label>URL <input type="url" name="linkUrl" placeholder="https://..." required /></label>
|
||||||
|
<label class="full">설명 <textarea name="description" rows="3" placeholder="강의 설명"></textarea></label>
|
||||||
|
<label class="full">카테고리
|
||||||
|
<div class="category-radios-inline">
|
||||||
|
<label><input type="radio" name="category" value="" checked />선택 안함</label>
|
||||||
|
<label><input type="radio" name="category" value="AX 사고 전환" />AX 사고 전환</label>
|
||||||
|
<label><input type="radio" name="category" value="AI 툴 활용" />AI 툴 활용</label>
|
||||||
|
<label><input type="radio" name="category" value="AI Agent" />AI Agent</label>
|
||||||
|
<label><input type="radio" name="category" value="바이브 코딩" />바이브 코딩</label>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="full">태그 (쉼표 구분) <input type="text" name="tags" placeholder="예: 참고자료, 링크모음" /></label>
|
||||||
|
<button type="submit">웹 링크 등록</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="section-head">
|
||||||
|
<h2>등록된 강의</h2>
|
||||||
|
<span class="count-chip">총 <%= pagination.totalCount %>건</span>
|
||||||
|
</div>
|
||||||
|
<% if (!adminMode && lectures.length > 0) { %>
|
||||||
|
<p class="admin-warn" style="margin-bottom:12px">수정·삭제를 하려면 위에서 관리자 토큰을 입력하고 <strong>관리자 활성화</strong>를 눌러주세요.</p>
|
||||||
|
<% } %>
|
||||||
|
<form action="<%= adminBasePath %>" method="get" class="filter-grid" style="margin-bottom:12px">
|
||||||
|
<input type="hidden" name="token" value="<%= tokenRaw %>" />
|
||||||
|
<label>검색 <input type="text" name="q" value="<%= filters.q %>" /></label>
|
||||||
|
<label>타입 <select name="type"><option value="all" <%= filters.type === "all" ? "selected" : "" %>>전체</option><option value="youtube" <%= filters.type === "youtube" ? "selected" : "" %>>YouTube</option><option value="ppt" <%= filters.type === "ppt" ? "selected" : "" %>>PPT</option><option value="video" <%= filters.type === "video" ? "selected" : "" %>>동영상 파일</option><option value="link" <%= filters.type === "link" ? "selected" : "" %>>웹 링크</option><option value="news" <%= filters.type === "news" ? "selected" : "" %>>뉴스 URL</option></select></label>
|
||||||
|
<label>태그 <select name="tag"><option value="">전체</option><% (availableTags || []).forEach((t) => { %><option value="<%= t %>" <%= filters.tag === t ? "selected" : "" %>><%= t %></option><% }) %></select></label>
|
||||||
|
<label>카테고리 <select name="category"><option value="">전체</option><option value="AX 사고 전환" <%= filters.category === 'AX 사고 전환' ? 'selected' : '' %>>AX 사고 전환</option><option value="AI 툴 활용" <%= filters.category === 'AI 툴 활용' ? 'selected' : '' %>>AI 툴 활용</option><option value="AI Agent" <%= filters.category === 'AI Agent' ? 'selected' : '' %>>AI Agent</option><option value="바이브 코딩" <%= filters.category === '바이브 코딩' ? 'selected' : '' %>>바이브 코딩</option></select></label>
|
||||||
|
<button type="submit">필터</button>
|
||||||
|
</form>
|
||||||
|
<% if (!lectures.length) { %>
|
||||||
|
<p class="empty">등록된 항목이 없습니다.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="lecture-grid">
|
||||||
|
<% lectures.forEach((lecture) => { %>
|
||||||
|
<article class="lecture-card">
|
||||||
|
<% if (adminMode) { %>
|
||||||
|
<div class="lecture-card-actions">
|
||||||
|
<a href="/lectures/<%= lecture.id %>/edit?<%= returnQuery %>" class="btn-edit">수정</a>
|
||||||
|
<form action="/lectures/<%= lecture.id %>/delete" method="post" class="delete-form-inline" onsubmit="return confirm('정말 삭제하시겠습니까?');">
|
||||||
|
<input type="hidden" name="token" value="<%= tokenRaw %>" />
|
||||||
|
<input type="hidden" name="returnTo" value="<%= adminBasePath %>?<%= returnQuery %>" />
|
||||||
|
<button type="submit" class="danger">삭제</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<% var _externalUrl = (lecture.type === "link" || lecture.type === "news") && (lecture.newsUrl || "").trim(); %>
|
||||||
|
<% if (_externalUrl) { %>
|
||||||
|
<a class="lecture-link lecture-link-external" href="<%= _externalUrl %>" target="_blank" rel="noopener noreferrer">
|
||||||
|
<% } else { %>
|
||||||
|
<a class="lecture-link" href="/lectures/<%= lecture.id %>">
|
||||||
|
<% } %>
|
||||||
|
<div class="thumb <%= lecture.type %>">
|
||||||
|
<% if (lecture.type === "ppt") { %>
|
||||||
|
<% if (lecture.thumbnailUrl) { %>
|
||||||
|
<img src="<%= lecture.thumbnailUrl %>" alt="<%= lecture.title %> 썸네일" class="thumb-image" />
|
||||||
|
<% } else { %>
|
||||||
|
<span class="thumb-fallback">썸네일 <%= lecture.thumbnailStatus || "pending" %></span>
|
||||||
|
<% } %>
|
||||||
|
<span class="thumb-kicker">PPT</span>
|
||||||
|
<% if (lecture.previewTitle && lecture.previewTitle !== "제목 없음") { %><strong><%= lecture.previewTitle %></strong><% } %>
|
||||||
|
<% } else if (lecture.type === "news") { %>
|
||||||
|
<% if (lecture.thumbnailUrl) { %>
|
||||||
|
<img src="<%= lecture.thumbnailUrl %>" alt="" class="thumb-image thumb-image-og" loading="lazy" referrerpolicy="no-referrer" />
|
||||||
|
<% } %>
|
||||||
|
<span class="thumb-kicker">뉴스</span>
|
||||||
|
<% if (!lecture.thumbnailUrl) { %><strong>외부 링크</strong><% } %>
|
||||||
|
<% } else if (lecture.type === "link") { %>
|
||||||
|
<% if (lecture.thumbnailUrl) { %>
|
||||||
|
<img src="<%= lecture.thumbnailUrl %>" alt="" class="thumb-image thumb-image-og" loading="lazy" referrerpolicy="no-referrer" />
|
||||||
|
<% } %>
|
||||||
|
<span class="thumb-kicker">웹 링크</span>
|
||||||
|
<% if (!lecture.thumbnailUrl) { %><strong>외부 페이지</strong><% } %>
|
||||||
|
<% } else if (lecture.type === "video") { %>
|
||||||
|
<span class="thumb-fallback thumb-fallback-video">▶</span>
|
||||||
|
<span class="thumb-kicker">동영상 파일</span>
|
||||||
|
<strong>업로드 영상</strong>
|
||||||
|
<% } else { %>
|
||||||
|
<% const ytThumb = typeof getYoutubeThumbnailUrl === 'function' ? getYoutubeThumbnailUrl(lecture.youtubeUrl) : null; %>
|
||||||
|
<% if (ytThumb) { %>
|
||||||
|
<img src="<%= ytThumb %>" alt="<%= lecture.title %> 썸네일" class="thumb-image thumb-image-youtube" />
|
||||||
|
<% } %>
|
||||||
|
<span class="thumb-kicker">YouTube</span>
|
||||||
|
<strong>영상 강의</strong>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<div class="badge <%= lecture.type %>"><% if (lecture.type === "youtube") { %>YouTube<% } else if (lecture.type === "news") { %>뉴스<% } else if (lecture.type === "link") { %>링크<% } else if (lecture.type === "video") { %>동영상<% } else { %>PPT<% } %></div>
|
||||||
|
<h3><%= lecture.title %></h3>
|
||||||
|
<p><%= lecture.description || "" %></p>
|
||||||
|
<div class="tag-row"><% (lecture.tags || []).forEach((t) => { %><span class="tag-chip">#<%= t %></span><% }) %></div>
|
||||||
|
<small><%= new Date(lecture.createdAt).toLocaleString("ko-KR") %></small>
|
||||||
|
</a>
|
||||||
|
<% if (adminMode && lecture.type === "ppt") { %>
|
||||||
|
<div class="thumb-state-row">
|
||||||
|
<span class="state-chip <%= lecture.thumbnailStatus || "pending" %>"><%= lecture.thumbnailStatus || "pending" %></span>
|
||||||
|
<% if (lecture.thumbnailError) { %><small class="error-text"><%= lecture.thumbnailError %></small><% } %>
|
||||||
|
</div>
|
||||||
|
<div class="thumb-state-row" style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
||||||
|
<form action="/lectures/<%= lecture.id %>/thumbnail/regenerate" method="post" class="delete-form">
|
||||||
|
<input type="hidden" name="token" value="<%= tokenRaw %>" />
|
||||||
|
<input type="hidden" name="returnTo" value="<%= adminBasePath %>?<%= returnQuery %>" />
|
||||||
|
<button type="submit" class="ghost">썸네일 재생성</button>
|
||||||
|
</form>
|
||||||
|
<form action="/lectures/<%= lecture.id %>/slides/regenerate" method="post" class="delete-form">
|
||||||
|
<input type="hidden" name="token" value="<%= tokenRaw %>" />
|
||||||
|
<input type="hidden" name="returnTo" value="<%= adminBasePath %>?<%= returnQuery %>" />
|
||||||
|
<button type="submit" class="ghost">슬라이드 이미지 재생성</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</article>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
<% if (pagination.totalPages > 1) { %>
|
||||||
|
<nav class="pagination">
|
||||||
|
<% if (pagination.hasPrev) { %><a href="<%= adminBasePath %>?<%= pagination.prevQuery %>">이전</a><% } %>
|
||||||
|
<% pagination.pages.forEach((p) => { %><a href="<%= adminBasePath %>?<%= p.query %>" class="<%= p.active ? "active" : "" %>"><%= p.page %></a><% }) %>
|
||||||
|
<% if (pagination.hasNext) { %><a href="<%= adminBasePath %>?<%= pagination.nextQuery %>">다음</a><% } %>
|
||||||
|
</nav>
|
||||||
|
<% } %>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<% if (adminMode) { %>
|
||||||
|
<section class="panel admin-logs-panel">
|
||||||
|
<h2>썸네일 로그</h2>
|
||||||
|
<div class="admin-inline" style="margin-bottom:8px">
|
||||||
|
<form action="/thumbnails/retry-failed" method="post" class="admin-inline">
|
||||||
|
<input type="hidden" name="token" value="<%= tokenRaw %>" />
|
||||||
|
<input type="hidden" name="returnTo" value="<%= adminBasePath %>?<%= returnQuery %>" />
|
||||||
|
<button type="submit" class="ghost">실패 썸네일 일괄 재시도</button>
|
||||||
|
</form>
|
||||||
|
<form action="/thumbnails/events/clear" method="post" class="admin-inline" onsubmit="return confirm('썸네일 이벤트 로그를 모두 삭제하시겠습니까?');">
|
||||||
|
<input type="hidden" name="token" value="<%= tokenRaw %>" />
|
||||||
|
<input type="hidden" name="returnTo" value="<%= adminBasePath %>?<%= returnQuery %>" />
|
||||||
|
<button type="submit" class="ghost">초기화</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<% if (eventsCleared) { %>
|
||||||
|
<p class="admin-ok">썸네일 이벤트 로그가 초기화되었습니다.</p>
|
||||||
|
<% } %>
|
||||||
|
<p class="api-hint">
|
||||||
|
<a href="/admin/thumbnail-events?token=<%= tokenRaw %>" class="link-muted">이벤트 로그 페이지 열기</a>
|
||||||
|
</p>
|
||||||
|
<div class="dashboard-grid">
|
||||||
|
<section class="mini-panel">
|
||||||
|
<h3>실패 원인 TOP</h3>
|
||||||
|
<% if (!failureReasons.length) { %>
|
||||||
|
<p class="empty">최근 실패 원인이 없습니다.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<ul class="plain-list">
|
||||||
|
<% failureReasons.slice(0, 5).forEach((item) => { %>
|
||||||
|
<li><b><%= item.count %>회</b> <span><%= item.reason %></span></li>
|
||||||
|
<% }) %>
|
||||||
|
</ul>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
<section class="mini-panel">
|
||||||
|
<h3>최근 썸네일 이벤트</h3>
|
||||||
|
<% if (!recentEvents.length) { %>
|
||||||
|
<p class="empty">이벤트 로그가 없습니다.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<ul class="plain-list">
|
||||||
|
<% recentEvents.forEach((evt) => { %>
|
||||||
|
<li>
|
||||||
|
<span class="evt-type <%= evt.type %>"><%= evt.type %></span>
|
||||||
|
<span><%= evt.lectureTitle || evt.lectureId %></span>
|
||||||
|
<small><%= new Date(evt.at).toLocaleString("ko-KR") %></small>
|
||||||
|
</li>
|
||||||
|
<% }) %>
|
||||||
|
</ul>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<% } %>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
64
views/learning-edit.ejs
Normal file
64
views/learning-edit.ejs
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>콘텐츠 수정 - XAVIS</title>
|
||||||
|
<link rel="stylesheet" href="/public/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<% var adminBasePath = typeof adminBasePath !== 'undefined' ? adminBasePath : '/admin'; %>
|
||||||
|
<% var navMenu = typeof navActiveMenu !== 'undefined' ? navActiveMenu : 'learning'; %>
|
||||||
|
<div class="app-shell">
|
||||||
|
<%- include('partials/nav', { activeMenu: navMenu }) %>
|
||||||
|
<div class="content-area">
|
||||||
|
<header class="topbar">
|
||||||
|
<h1>콘텐츠 수정</h1>
|
||||||
|
<a class="top-action-link" href="<%= adminBasePath %>?<%= returnQuery %>">목록으로</a>
|
||||||
|
</header>
|
||||||
|
<main class="container">
|
||||||
|
<section class="panel">
|
||||||
|
<h2><% if (lecture.type === 'youtube') { %>유튜브<% } else if (lecture.type === 'news') { %>뉴스 URL<% } else if (lecture.type === 'link') { %>웹 링크<% } else if (lecture.type === 'video') { %>동영상 파일<% } else { %>PDF/PPT<% } %> 수정</h2>
|
||||||
|
<form action="/lectures/<%= lecture.id %>/update" method="post" class="form-grid">
|
||||||
|
<input type="hidden" name="token" value="<%= tokenRaw %>" />
|
||||||
|
<input type="hidden" name="returnTo" value="<%= adminBasePath %>?<%= returnQuery %>" />
|
||||||
|
<label>제목 <input type="text" name="title" value="<%= lecture.title %>" required /></label>
|
||||||
|
<% if (lecture.type === 'youtube') { %>
|
||||||
|
<label>유튜브 링크 <input type="url" name="youtubeUrl" value="<%= lecture.youtubeUrl || '' %>" placeholder="https://www.youtube.com/watch?v=..." required /></label>
|
||||||
|
<% } else if (lecture.type === 'news') { %>
|
||||||
|
<label>뉴스 URL <input type="url" name="newsUrl" value="<%= lecture.newsUrl || '' %>" placeholder="https://..." required /></label>
|
||||||
|
<% } else if (lecture.type === 'link') { %>
|
||||||
|
<label>웹 링크 URL <input type="url" name="newsUrl" value="<%= lecture.newsUrl || '' %>" placeholder="https://..." required /></label>
|
||||||
|
<% } else if (lecture.type === 'video') { %>
|
||||||
|
<label class="full">동영상 파일 <span class="muted">(변경 불가)</span> <input type="text" value="<%= lecture.originalName || lecture.fileName || '' %>" disabled /></label>
|
||||||
|
<% } else { %>
|
||||||
|
<label class="full">PDF/PPT 파일 <span class="muted">(변경 불가)</span> <input type="text" value="<%= lecture.originalName || lecture.fileName || '' %>" disabled /></label>
|
||||||
|
<% } %>
|
||||||
|
<label class="full">설명 <textarea name="description" rows="3"><%= lecture.description || '' %></textarea></label>
|
||||||
|
<label class="full">카테고리
|
||||||
|
<div class="category-radios-inline">
|
||||||
|
<%
|
||||||
|
const cats = ['AX 사고 전환','AI 툴 활용','AI Agent','바이브 코딩'];
|
||||||
|
const lectureTags = lecture.tags || [];
|
||||||
|
const cat = cats.find(function(c){ return lectureTags.indexOf(c) >= 0; });
|
||||||
|
const otherTags = lectureTags.filter(function(t){ return cats.indexOf(t) < 0; });
|
||||||
|
%>
|
||||||
|
<label><input type="radio" name="category" value="" <%= !cat ? 'checked' : '' %> />선택 안함</label>
|
||||||
|
<label><input type="radio" name="category" value="AX 사고 전환" <%= cat === 'AX 사고 전환' ? 'checked' : '' %> />AX 사고 전환</label>
|
||||||
|
<label><input type="radio" name="category" value="AI 툴 활용" <%= cat === 'AI 툴 활용' ? 'checked' : '' %> />AI 툴 활용</label>
|
||||||
|
<label><input type="radio" name="category" value="AI Agent" <%= cat === 'AI Agent' ? 'checked' : '' %> />AI Agent</label>
|
||||||
|
<label><input type="radio" name="category" value="바이브 코딩" <%= cat === '바이브 코딩' ? 'checked' : '' %> />바이브 코딩</label>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="full">태그 (쉼표 구분) <input type="text" name="tags" value="<%= otherTags.join(', ') %>" placeholder="예: AI에이전트, 바이브코딩" /></label>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit">저장</button>
|
||||||
|
<a href="<%= adminBasePath %>?<%= returnQuery %>" class="link-muted">취소</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1
views/learning-lectures-partial.ejs
Normal file
1
views/learning-lectures-partial.ejs
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<%- include('partials/lecture-cards') %>
|
||||||
110
views/learning-viewer.ejs
Normal file
110
views/learning-viewer.ejs
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title><%= typeof pageTitle !== 'undefined' ? pageTitle : '학습센터' %> - XAVIS</title>
|
||||||
|
<link rel="stylesheet" href="/public/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<% 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; %>
|
||||||
|
<div class="app-shell">
|
||||||
|
<%- include('partials/nav', { activeMenu: navMenu }) %>
|
||||||
|
<div class="content-area">
|
||||||
|
<header class="topbar">
|
||||||
|
<h1><%= pTitle %></h1>
|
||||||
|
<% if (typeof adminMode !== 'undefined' && adminMode && !_opsLoggedIn) { %>
|
||||||
|
<a href="<%= adminRegisterHref %>" class="top-action-link">콘텐츠 등록하기</a>
|
||||||
|
<% } %>
|
||||||
|
</header>
|
||||||
|
<main class="container">
|
||||||
|
<section class="hero panel">
|
||||||
|
<h2><%= hTitle %></h2>
|
||||||
|
<p><%= hDesc %></p>
|
||||||
|
</section>
|
||||||
|
<section class="panel filter-panel">
|
||||||
|
<h2><%= filterTitle %></h2>
|
||||||
|
<form action="<%= viewerBasePath %>" method="get" class="filter-grid">
|
||||||
|
<label>
|
||||||
|
검색어
|
||||||
|
<input type="text" name="q" id="learning-filter-q" value="<%= filters.q %>" placeholder="제목·설명·미리보기·파일명 (클로드↔claude)" autocomplete="off" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
타입
|
||||||
|
<select name="type">
|
||||||
|
<option value="all" <%= filters.type === "all" ? "selected" : "" %>>전체</option>
|
||||||
|
<option value="youtube" <%= filters.type === "youtube" ? "selected" : "" %>>YouTube</option>
|
||||||
|
<option value="ppt" <%= filters.type === "ppt" ? "selected" : "" %>>PPT/PDF</option>
|
||||||
|
<option value="video" <%= filters.type === "video" ? "selected" : "" %>>동영상 파일</option>
|
||||||
|
<option value="link" <%= filters.type === "link" ? "selected" : "" %>>웹 링크</option>
|
||||||
|
<option value="news" <%= filters.type === "news" ? "selected" : "" %>>뉴스 URL</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
태그
|
||||||
|
<select name="tag">
|
||||||
|
<option value="">전체</option>
|
||||||
|
<% (availableTags || []).forEach((oneTag) => { %>
|
||||||
|
<option value="<%= oneTag %>" <%= filters.tag === oneTag ? "selected" : "" %>><%= oneTag %></option>
|
||||||
|
<% }) %>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="filter-row-bottom">
|
||||||
|
<div class="category-radios" id="category-filter">
|
||||||
|
<label><input type="radio" name="category" value="" <%= !filters.category ? 'checked' : '' %> />전체</label>
|
||||||
|
<label><input type="radio" name="category" value="AX 사고 전환" <%= filters.category === 'AX 사고 전환' ? 'checked' : '' %> />AX 사고 전환</label>
|
||||||
|
<label><input type="radio" name="category" value="AI 툴 활용" <%= filters.category === 'AI 툴 활용' ? 'checked' : '' %> />AI 툴 활용</label>
|
||||||
|
<label><input type="radio" name="category" value="AI Agent" <%= filters.category === 'AI Agent' ? 'checked' : '' %> />AI Agent</label>
|
||||||
|
<label><input type="radio" name="category" value="바이브 코딩" <%= filters.category === '바이브 코딩' ? 'checked' : '' %> />바이브 코딩</label>
|
||||||
|
</div>
|
||||||
|
<div class="filter-actions">
|
||||||
|
<button type="submit">필터 적용</button>
|
||||||
|
<a class="link-muted" href="<%= viewerBasePath %>">초기화</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="section-head">
|
||||||
|
<h2><%= listHeading %></h2>
|
||||||
|
<span class="count-chip">총 <span id="lecture-total-count"><%= pagination.totalCount %></span>건</span>
|
||||||
|
</div>
|
||||||
|
<div id="lecture-results-root">
|
||||||
|
<% if (!lectures.length) { %>
|
||||||
|
<p class="empty" id="lecture-empty-msg">등록된 항목이 없습니다.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="lecture-grid" id="lecture-grid">
|
||||||
|
<%- include('partials/lecture-cards') %>
|
||||||
|
</div>
|
||||||
|
<% if (pagination.hasNext) { %>
|
||||||
|
<div id="lecture-infinite-footer">
|
||||||
|
<div id="infinite-scroll-sentinel" class="infinite-scroll-sentinel" data-next-page="<%= pagination.page + 1 %>" data-has-next="true"></div>
|
||||||
|
<p class="infinite-scroll-loading" id="infinite-scroll-loading" style="display:none;text-align:center;padding:16px;color:#666;">불러오는 중...</p>
|
||||||
|
<div class="lecture-load-more-wrap" id="lecture-load-more-wrap">
|
||||||
|
<button type="button" class="lecture-load-more-btn" id="lecture-load-more-btn">더 불러오기</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="lecture-page-config" data-learning-api="<%= learningApiPath %>" data-viewer-base="<%= viewerBasePath %>" hidden aria-hidden="true"></div>
|
||||||
|
<script src="/public/js/learning-infinite.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
31
views/lecture-link.ejs
Normal file
31
views/lecture-link.ejs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title><%= lecture.title %> - 학습센터 - XAVIS</title>
|
||||||
|
<link rel="stylesheet" href="/public/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-shell">
|
||||||
|
<%- include('partials/nav', { activeMenu: 'learning' }) %>
|
||||||
|
<div class="content-area">
|
||||||
|
<header class="topbar">
|
||||||
|
<h1><%= lecture.title %></h1>
|
||||||
|
<a class="top-action-link" href="/learning">목록으로</a>
|
||||||
|
</header>
|
||||||
|
<main class="container">
|
||||||
|
<section class="panel">
|
||||||
|
<% if (lecture.description) { %>
|
||||||
|
<p class="lecture-news-desc"><%= lecture.description %></p>
|
||||||
|
<% } %>
|
||||||
|
<p class="lecture-news-actions">
|
||||||
|
<a href="<%= lecture.newsUrl %>" target="_blank" rel="noopener noreferrer" class="top-action-link lecture-link-open">링크 열기 (새 탭)</a>
|
||||||
|
</p>
|
||||||
|
<p class="muted" style="font-size:13px;word-break:break-all;">URL: <%= lecture.newsUrl %></p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
31
views/lecture-news.ejs
Normal file
31
views/lecture-news.ejs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title><%= lecture.title %> - 학습센터 - XAVIS</title>
|
||||||
|
<link rel="stylesheet" href="/public/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-shell">
|
||||||
|
<%- include('partials/nav', { activeMenu: 'learning' }) %>
|
||||||
|
<div class="content-area">
|
||||||
|
<header class="topbar">
|
||||||
|
<h1><%= lecture.title %></h1>
|
||||||
|
<a class="top-action-link" href="/learning">목록으로</a>
|
||||||
|
</header>
|
||||||
|
<main class="container">
|
||||||
|
<section class="panel">
|
||||||
|
<% if (lecture.description) { %>
|
||||||
|
<p class="lecture-news-desc"><%= lecture.description %></p>
|
||||||
|
<% } %>
|
||||||
|
<p class="lecture-news-actions">
|
||||||
|
<a href="<%= lecture.newsUrl %>" target="_blank" rel="noopener noreferrer" class="top-action-link">기사·원문 열기 (새 탭)</a>
|
||||||
|
</p>
|
||||||
|
<p class="muted" style="font-size:13px;word-break:break-all;">URL: <%= lecture.newsUrl %></p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
57
views/lecture-ppt.ejs
Normal file
57
views/lecture-ppt.ejs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title><%= lecture.title %> - PPT 뷰어</title>
|
||||||
|
<link rel="stylesheet" href="/public/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<% if (typeof slideImageUrls === 'undefined') { slideImageUrls = []; } %>
|
||||||
|
<div class="app-shell">
|
||||||
|
<%- include('partials/nav', { activeMenu: 'learning' }) %>
|
||||||
|
<div class="content-area">
|
||||||
|
<main class="viewer-wrap">
|
||||||
|
<a href="/learning" class="back-link">← 학습센터로 돌아가기</a>
|
||||||
|
<h1><%= lecture.title %></h1>
|
||||||
|
<p class="description"><%= lecture.description || "설명이 없습니다." %></p>
|
||||||
|
|
||||||
|
<div class="ppt-tools">
|
||||||
|
<span>총 <b><%= slides.length %></b>장</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if (typeof slidesError !== 'undefined' && slidesError && (!slideImageUrls || slideImageUrls.length === 0)) { %>
|
||||||
|
<p class="admin-warn">슬라이드 이미지 생성에 실패했습니다. LibreOffice가 설치되어 있는지 확인하세요. (macOS: <code>brew install --cask libreoffice</code>)</p>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<% if (!slides.length) { %>
|
||||||
|
<p class="empty">슬라이드 내용을 불러올 수 없습니다.</p>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<section class="slide-list">
|
||||||
|
<% slides.forEach((slide, index) => { %>
|
||||||
|
<article class="slide-card">
|
||||||
|
<header>
|
||||||
|
<h2>슬라이드 <%= index + 1 %></h2>
|
||||||
|
<% if (slide.title) { %><p><%= slide.title %></p><% } %>
|
||||||
|
</header>
|
||||||
|
<% if (slideImageUrls[index]) { %>
|
||||||
|
<div class="slide-image-wrap">
|
||||||
|
<img src="<%= slideImageUrls[index] %>" alt="슬라이드 <%= index + 1 %>" class="slide-image" />
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<% if (slide.lines && slide.lines.length > 0) { %>
|
||||||
|
<ul>
|
||||||
|
<% slide.lines.forEach((line) => { %>
|
||||||
|
<li><%= line %></li>
|
||||||
|
<% }) %>
|
||||||
|
</ul>
|
||||||
|
<% } %>
|
||||||
|
</article>
|
||||||
|
<% }) %>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
32
views/lecture-video.ejs
Normal file
32
views/lecture-video.ejs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title><%= lecture.title %> - 동영상 강의</title>
|
||||||
|
<link rel="stylesheet" href="/public/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-shell">
|
||||||
|
<%- include('partials/nav', { activeMenu: 'learning' }) %>
|
||||||
|
<div class="content-area">
|
||||||
|
<main class="viewer-wrap">
|
||||||
|
<a href="/learning" class="back-link">← 학습센터로 돌아가기</a>
|
||||||
|
<h1><%= lecture.title %></h1>
|
||||||
|
<p class="description"><%= lecture.description || "설명이 없습니다." %></p>
|
||||||
|
<div class="lecture-video-wrap">
|
||||||
|
<video
|
||||||
|
class="lecture-video-player"
|
||||||
|
controls
|
||||||
|
playsinline
|
||||||
|
preload="metadata"
|
||||||
|
src="<%= videoSrc %>"
|
||||||
|
>
|
||||||
|
이 브라우저는 HTML5 동영상 재생을 지원하지 않습니다.
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
30
views/lecture-youtube.ejs
Normal file
30
views/lecture-youtube.ejs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title><%= lecture.title %> - YouTube 강의</title>
|
||||||
|
<link rel="stylesheet" href="/public/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-shell">
|
||||||
|
<%- include('partials/nav', { activeMenu: 'learning' }) %>
|
||||||
|
<div class="content-area">
|
||||||
|
<main class="viewer-wrap">
|
||||||
|
<a href="/learning" class="back-link">← 학습센터로 돌아가기</a>
|
||||||
|
<h1><%= lecture.title %></h1>
|
||||||
|
<p class="description"><%= lecture.description || "설명이 없습니다." %></p>
|
||||||
|
<div class="youtube-frame">
|
||||||
|
<iframe
|
||||||
|
src="<%= embedUrl %>"
|
||||||
|
title="<%= lecture.title %>"
|
||||||
|
frameborder="0"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
allowfullscreen
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
236
views/login.ejs
Normal file
236
views/login.ejs
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>서비스 접속 - XAVIS</title>
|
||||||
|
<link rel="stylesheet" href="/public/styles.css" />
|
||||||
|
<style>
|
||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px 16px;
|
||||||
|
background: linear-gradient(160deg, #f0f4ff 0%, #f3f4f7 45%, #eef2f7 100%);
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 12px 40px rgba(15, 23, 42, 0.1);
|
||||||
|
padding: 32px 28px 28px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
.login-card .logo-img {
|
||||||
|
max-width: 200px;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
}
|
||||||
|
.login-card h1 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
color: #111827;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.login-lead {
|
||||||
|
margin: 0 0 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #4b5563;
|
||||||
|
line-height: 1.6;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.login-steps {
|
||||||
|
margin: 0 0 22px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #374151;
|
||||||
|
line-height: 1.65;
|
||||||
|
}
|
||||||
|
.login-steps ol {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
}
|
||||||
|
.login-steps li {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.login-steps li:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.login-form label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.login-form input[type="email"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 11px 12px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.login-form input[type="email"]:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #2563eb;
|
||||||
|
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15);
|
||||||
|
}
|
||||||
|
.login-form .btn-verify {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #2563eb;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.login-form .btn-verify:hover {
|
||||||
|
background: #1d4ed8;
|
||||||
|
}
|
||||||
|
.login-form .btn-verify:disabled {
|
||||||
|
opacity: 0.65;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.login-hint {
|
||||||
|
margin-top: 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6b7280;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.login-msg {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
min-height: 1.2em;
|
||||||
|
}
|
||||||
|
.login-msg.ok {
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
.login-msg.err {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-page">
|
||||||
|
<div class="login-card">
|
||||||
|
<div class="login-logo-stack" style="display: flex; flex-direction: column; align-items: center; gap: 8px; margin-bottom: 12px">
|
||||||
|
<a
|
||||||
|
href="https://xavis.co.kr"
|
||||||
|
class="logo-link"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="XAVIS 회사 사이트(새 탭)"
|
||||||
|
style="margin-bottom: 0"
|
||||||
|
><img src="/public/images/xavis-logo.png" alt="XAVIS" class="logo-img logo-img-xavis"
|
||||||
|
/></a>
|
||||||
|
<img
|
||||||
|
src="/public/images/aiplatform-logo.png"
|
||||||
|
alt="AI PLATFORM"
|
||||||
|
class="logo-img"
|
||||||
|
style="cursor: default; margin-bottom: 0"
|
||||||
|
width="168"
|
||||||
|
height="auto"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h1>서비스 접속</h1>
|
||||||
|
<p class="login-lead">회사 이메일로 본인 확인 후 서비스를 이용할 수 있습니다.</p>
|
||||||
|
<div class="login-steps">
|
||||||
|
<ol>
|
||||||
|
<li>아래에 <strong>@xavis.co.kr</strong> 이메일을 입력하고 <strong>검증</strong>을 누릅니다.</li>
|
||||||
|
<li>해당 메일함으로 전송된 <strong>인증 링크</strong>를 엽니다.</li>
|
||||||
|
<li>인증이 완료되면 바로 서비스 화면으로 이동합니다.</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
<form class="login-form" id="opsLoginForm" novalidate>
|
||||||
|
<input type="hidden" name="returnTo" id="returnTo" value="<%= typeof returnTo !== 'undefined' ? returnTo : '/learning' %>" />
|
||||||
|
<label for="opsEmail">회사 이메일</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="opsEmail"
|
||||||
|
name="email"
|
||||||
|
required
|
||||||
|
autocomplete="email"
|
||||||
|
placeholder="name@xavis.co.kr"
|
||||||
|
inputmode="email"
|
||||||
|
/>
|
||||||
|
<button type="submit" class="btn-verify" id="opsVerifyBtn">검증</button>
|
||||||
|
<p class="login-msg" id="opsLoginMsg" role="status" aria-live="polite"></p>
|
||||||
|
<p class="login-hint">허용 도메인: @xavis.co.kr 만 가능합니다.</p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var form = document.getElementById("opsLoginForm");
|
||||||
|
var btn = document.getElementById("opsVerifyBtn");
|
||||||
|
var msg = document.getElementById("opsLoginMsg");
|
||||||
|
var emailEl = document.getElementById("opsEmail");
|
||||||
|
var returnToEl = document.getElementById("returnTo");
|
||||||
|
if (!form) return;
|
||||||
|
form.addEventListener("submit", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (msg) {
|
||||||
|
msg.textContent = "";
|
||||||
|
msg.className = "login-msg";
|
||||||
|
}
|
||||||
|
var email = (emailEl && emailEl.value) ? emailEl.value.trim() : "";
|
||||||
|
if (!email) {
|
||||||
|
if (msg) {
|
||||||
|
msg.textContent = "이메일을 입력해 주세요.";
|
||||||
|
msg.className = "login-msg err";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
fetch("/api/auth/request-link", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: email,
|
||||||
|
returnTo: (returnToEl && returnToEl.value) || "/learning",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.then(function (r) {
|
||||||
|
return r.json().then(function (j) {
|
||||||
|
return { ok: r.ok, j: j };
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(function (x) {
|
||||||
|
if (x.ok) {
|
||||||
|
if (msg) {
|
||||||
|
msg.textContent = x.j.message || "메일을 확인해 주세요.";
|
||||||
|
msg.className = "login-msg ok";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var err = (x.j && x.j.error) || "요청에 실패했습니다.";
|
||||||
|
if (msg) {
|
||||||
|
msg.textContent = err;
|
||||||
|
msg.className = "login-msg err";
|
||||||
|
}
|
||||||
|
if (err.indexOf("허용된 임직원") !== -1) {
|
||||||
|
alert("허용된 임직원이 아닙니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
if (msg) {
|
||||||
|
msg.textContent = "네트워크 오류가 발생했습니다.";
|
||||||
|
msg.className = "login-msg err";
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(function () {
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
780
views/meeting-minutes.ejs
Normal file
780
views/meeting-minutes.ejs
Normal file
@@ -0,0 +1,780 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>회의록 AI - XAVIS</title>
|
||||||
|
<link rel="stylesheet" href="/public/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body class="meeting-minutes-page">
|
||||||
|
<% 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); %>
|
||||||
|
<div class="app-shell">
|
||||||
|
<%- include('partials/nav', { activeMenu: 'ai-explore', adminMode: typeof adminMode !== 'undefined' ? adminMode : false }) %>
|
||||||
|
<div class="content-area">
|
||||||
|
<header class="topbar">
|
||||||
|
<h1>회의록 AI</h1>
|
||||||
|
<a class="top-action-link" href="/ai-explore">AI 목록</a>
|
||||||
|
</header>
|
||||||
|
<main class="container meeting-minutes-main">
|
||||||
|
<a href="/ai-explore" class="prompts-back" aria-label="AI 탐색으로 돌아가기">← AI</a>
|
||||||
|
<% var hasEmail = typeof meetingUserEmail !== 'undefined' && meetingUserEmail; %>
|
||||||
|
<% if (!hasEmail) { %>
|
||||||
|
<p class="chat-api-warning" style="margin-bottom: 16px">
|
||||||
|
이메일 인증(OPS <code>PROD</code>) 또는 DEV에서 <strong>관리자 모드</strong>, SUPER(데모)에서는 별도 로그인 없이 이용할 수 있습니다. (DEV 테스트: <code>MEETING_DEV_EMAIL</code>)
|
||||||
|
</p>
|
||||||
|
<% } %>
|
||||||
|
<div class="mm-layout">
|
||||||
|
<aside class="mm-sidebar panel">
|
||||||
|
<h2 class="mm-sidebar-title">내 회의록</h2>
|
||||||
|
<button type="button" class="btn-ghost mm-refresh" id="mmListRefresh" <%= hasEmail ? '' : 'disabled' %>>새로고침</button>
|
||||||
|
<ul class="mm-meeting-list" id="mmMeetingList" role="list"></ul>
|
||||||
|
<p class="mm-list-empty" id="mmListEmpty" hidden>저장된 회의록이 없습니다.</p>
|
||||||
|
</aside>
|
||||||
|
<div class="mm-workspace">
|
||||||
|
<section class="panel mm-prompt-panel mm-prompt-collapsed" id="mmPromptPanel">
|
||||||
|
<div class="mm-prompt-panel-head">
|
||||||
|
<h2 id="mmPromptHeading">출력 형식 (프롬프트)</h2>
|
||||||
|
<button type="button" class="mm-prompt-toggle" id="mmPromptToggle" aria-expanded="false" aria-controls="mmPromptBody" aria-labelledby="mmPromptHeading" title="펼치기·접기">
|
||||||
|
<span class="mm-prompt-toggle-icon" aria-hidden="true">▼</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mm-prompt-body" id="mmPromptBody" hidden>
|
||||||
|
<p class="subtitle">회의록에 포함할 항목을 선택하고, 추가 지시를 입력할 수 있습니다. 시스템 프롬프트에 액션 아이템(What·Who·When)과 회의 체크리스트(전·중·후) 정의가 포함되며, 체크리스트는 업무 체크리스트 AI 연동을 위해 항상 포함됩니다.</p>
|
||||||
|
<div class="mm-prompt-form" id="mmPromptForm">
|
||||||
|
<div class="mm-checkbox-row" role="group" aria-label="회의록에 포함할 항목">
|
||||||
|
<label class="mm-checkbox-item"><input type="checkbox" id="mmIncTitle" checked /> <span>제목 한 줄</span></label>
|
||||||
|
<label class="mm-checkbox-item"><input type="checkbox" id="mmIncAtt" checked /> <span>참석자</span></label>
|
||||||
|
<label class="mm-checkbox-item"><input type="checkbox" id="mmIncSum" checked /> <span>요약</span></label>
|
||||||
|
<label class="mm-checkbox-item"><input type="checkbox" id="mmIncAct" checked /> <span>Action Item</span></label>
|
||||||
|
<label class="mm-checkbox-item mm-checkbox-item-locked" title="업무 체크리스트 AI에서 활용되므로 항상 켜져 있습니다.">
|
||||||
|
<input type="checkbox" id="mmIncChk" checked disabled aria-checked="true" aria-disabled="true" />
|
||||||
|
<span>체크리스트 <span class="mm-checkbox-badge">필수</span></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="mm-custom-block">
|
||||||
|
<label class="mm-field-label" for="mmCustomInstr">추가 지시</label>
|
||||||
|
<textarea id="mmCustomInstr" class="mm-textarea mm-custom-instr-textarea" rows="9" placeholder=""><%= mmDefaultCustomInstructions %></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="mm-prompt-actions">
|
||||||
|
<button type="button" class="top-action" id="mmSavePrompt" <%= hasEmail ? '' : 'disabled' %>>프롬프트 저장</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="mm-tabs" role="tablist">
|
||||||
|
<button type="button" class="mm-tab is-active" id="mmTabText" data-tab="text" role="tab" aria-selected="true" aria-controls="mmPanelText">텍스트 입력</button>
|
||||||
|
<button type="button" class="mm-tab" id="mmTabAudio" data-tab="audio" role="tab" aria-selected="false" aria-controls="mmPanelAudio">음성 파일</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mm-tab-panel mm-field-stack" id="mmPanelText" role="tabpanel" aria-labelledby="mmTabText" aria-hidden="false">
|
||||||
|
<div class="mm-field mm-title-date-field">
|
||||||
|
<div class="mm-title-date-labels">
|
||||||
|
<span class="mm-field-label">제목</span>
|
||||||
|
<span class="mm-field-label">날짜</span>
|
||||||
|
</div>
|
||||||
|
<div class="mm-title-date-box">
|
||||||
|
<input type="text" id="mmTitleText" class="mm-input mm-title-input" placeholder="회의 제목" maxlength="500" <%= hasEmail ? '' : 'disabled' %> />
|
||||||
|
<div class="mm-date-wrap">
|
||||||
|
<input type="date" id="mmDateText" class="mm-date-native" value="<%= mmTodayIso %>" <%= hasEmail ? '' : 'disabled' %> />
|
||||||
|
<button type="button" class="mm-date-trigger" id="mmDateTextBtn" <%= hasEmail ? '' : 'disabled' %> aria-label="달력 열기">▼</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="mm-field">
|
||||||
|
<span class="mm-field-label">회의 원문</span>
|
||||||
|
<textarea id="mmSourceText" class="mm-textarea" rows="12" placeholder="회의 내용을 붙여 넣거나 입력하세요." <%= hasEmail ? '' : 'disabled' %>></textarea>
|
||||||
|
</label>
|
||||||
|
<label class="mm-field mm-field-narrow">
|
||||||
|
<span class="mm-field-label">회의록 생성 모델</span>
|
||||||
|
<select id="mmModelText" class="mm-select" <%= hasEmail ? '' : 'disabled' %>>
|
||||||
|
<option value="gpt-5-mini" selected>gpt-5-mini</option>
|
||||||
|
<option value="gpt-5.4">gpt-5.4</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="form-actions mm-form-actions">
|
||||||
|
<button type="button" class="top-action" id="mmGenText" <%= hasEmail ? '' : 'disabled' %>>회의록 생성</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mm-tab-panel mm-field-stack mm-audio-panel" id="mmPanelAudio" role="tabpanel" aria-labelledby="mmTabAudio" aria-hidden="true" hidden>
|
||||||
|
<div class="mm-audio-phase">
|
||||||
|
<h3 class="mm-section-heading">1. 음성 전사</h3>
|
||||||
|
<label class="mm-field">
|
||||||
|
<span class="mm-field-label">음성 파일</span>
|
||||||
|
<input type="file" id="mmAudioFile" class="mm-file-input" accept=".mp3,.mp4,.mpeg,.mpga,.m4a,.wav,.webm,.ogg,.flac" <%= hasEmail ? '' : 'disabled' %> />
|
||||||
|
</label>
|
||||||
|
<p class="mm-audio-hint">지원 포맷: mp3, m4a, wav 등. 파일당 최대 300MB입니다.</p>
|
||||||
|
<label class="mm-field mm-transcribe-model-only">
|
||||||
|
<span class="mm-field-label">전사 모델 (OpenAI)</span>
|
||||||
|
<select id="mmWhisperModel" class="mm-select" <%= hasEmail ? '' : 'disabled' %> title="음성→텍스트 API 모델">
|
||||||
|
<option value="gpt-4o-mini-transcribe" selected>gpt-4o-mini-transcribe (기본)</option>
|
||||||
|
<option value="gpt-4o-transcribe">gpt-4o-transcribe (고성능)</option>
|
||||||
|
</select>
|
||||||
|
<span class="mm-field-help">OpenAI 전사 API 공식 모델 ID와 동일합니다. 기본은 mini, 더 높은 인식 품질이 필요하면 gpt-4o-transcribe를 선택하세요.</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="mm-audio-phase">
|
||||||
|
<h3 class="mm-section-heading">2. 회의록 작성</h3>
|
||||||
|
<div class="mm-field mm-title-date-field">
|
||||||
|
<div class="mm-title-date-labels">
|
||||||
|
<span class="mm-field-label">제목</span>
|
||||||
|
<span class="mm-field-label">날짜</span>
|
||||||
|
</div>
|
||||||
|
<div class="mm-title-date-box">
|
||||||
|
<input type="text" id="mmTitleAudio" class="mm-input mm-title-input" placeholder="회의 제목" maxlength="500" <%= hasEmail ? '' : 'disabled' %> />
|
||||||
|
<div class="mm-date-wrap">
|
||||||
|
<input type="date" id="mmDateAudio" class="mm-date-native" value="<%= mmTodayIso %>" <%= hasEmail ? '' : 'disabled' %> />
|
||||||
|
<button type="button" class="mm-date-trigger" id="mmDateAudioBtn" <%= hasEmail ? '' : 'disabled' %> aria-label="달력 열기">▼</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="mm-field mm-field-narrow">
|
||||||
|
<span class="mm-field-label">회의록 생성 모델</span>
|
||||||
|
<select id="mmModelAudio" class="mm-select" <%= hasEmail ? '' : 'disabled' %>>
|
||||||
|
<option value="gpt-5-mini" selected>gpt-5-mini</option>
|
||||||
|
<option value="gpt-5.4">gpt-5.4</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p class="mm-audio-summary-hint">전사된 텍스트를 아래 프롬프트(출력 형식)에 맞게 요약·정리합니다.</p>
|
||||||
|
<div class="form-actions mm-form-actions">
|
||||||
|
<button type="button" class="top-action" id="mmGenAudio" <%= hasEmail ? '' : 'disabled' %>>전사 및 회의록 생성</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="mmGenProgress" class="mm-gen-progress" hidden role="status" aria-live="polite" aria-busy="false">
|
||||||
|
<div class="mm-gen-progress-track" aria-hidden="true">
|
||||||
|
<div class="mm-gen-progress-bar"></div>
|
||||||
|
</div>
|
||||||
|
<p class="mm-gen-progress-msg" id="mmGenProgressMsg">처리 중…</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="panel mm-result-panel" id="mmResultSection" hidden>
|
||||||
|
<div class="mm-result-head">
|
||||||
|
<h2 class="mm-result-title">생성 결과</h2>
|
||||||
|
<button type="button" class="top-action" id="mmSaveResult" disabled>저장</button>
|
||||||
|
</div>
|
||||||
|
<p class="mm-result-hint">
|
||||||
|
생성 결과에서 <strong>회의록</strong>을 수정한 뒤 <strong>저장</strong>하면 DB에 반영되며, 회의록 기준으로 액션 아이템·체크리스트가 업무 체크리스트 AI에 다시 연동됩니다. 음성 전사가 있는 회의는 <strong>음성 파일</strong> 탭을 선택하면 아래 <strong>전사 기록</strong>도 함께 수정할 수 있습니다. 텍스트 입력으로 만든 회의는 위쪽 <strong>회의 원문</strong>에서 원문을 고칩니다.
|
||||||
|
</p>
|
||||||
|
<div class="mm-result-split">
|
||||||
|
<div id="mmTranscriptPane" class="mm-result-transcript-pane" hidden aria-hidden="true">
|
||||||
|
<label class="mm-result-field">
|
||||||
|
<span class="mm-result-field-label" id="mmTranscriptLabel">전사 기록</span>
|
||||||
|
<textarea
|
||||||
|
id="mmTranscriptBody"
|
||||||
|
class="mm-result-body mm-result-textarea mm-result-textarea-half mm-transcript-textarea"
|
||||||
|
rows="14"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder="음성 전사 텍스트가 여기에 표시됩니다."
|
||||||
|
></textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="mm-result-field">
|
||||||
|
<div class="mm-minutes-header">
|
||||||
|
<span class="mm-result-field-label">회의록</span>
|
||||||
|
<div class="mm-minutes-actions" id="mmMinutesActionsView" role="toolbar" aria-label="회의록 보기">
|
||||||
|
<button type="button" class="btn-ghost mm-minutes-edit" id="mmMinutesEdit">마크다운 편집</button>
|
||||||
|
</div>
|
||||||
|
<div class="mm-minutes-actions" id="mmMinutesActionsEdit" role="toolbar" aria-label="회의록 편집" hidden>
|
||||||
|
<button type="button" class="top-action mm-minutes-apply" id="mmMinutesApply">저장</button>
|
||||||
|
<button type="button" class="btn-ghost mm-minutes-cancel" id="mmMinutesCancel">취소</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mm-minutes-editor-wrap">
|
||||||
|
<div
|
||||||
|
id="mmMinutesRendered"
|
||||||
|
class="mm-minutes-rendered mm-minutes-rendered-empty"
|
||||||
|
role="region"
|
||||||
|
aria-label="회의록 (마크다운 렌더링)"
|
||||||
|
tabindex="0"
|
||||||
|
>
|
||||||
|
회의록이 없습니다. 마크다운 편집으로 내용을 입력하세요.
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
id="mmResultBody"
|
||||||
|
class="mm-result-body mm-result-textarea mm-result-textarea-half mm-minutes-source"
|
||||||
|
rows="14"
|
||||||
|
spellcheck="false"
|
||||||
|
hidden
|
||||||
|
placeholder="마크다운으로 회의록을 편집합니다. 「저장」으로 뷰로 돌아가거나 「취소」로 편집 전 내용으로 되돌립니다."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="/vendor/marked/marked.umd.js"></script>
|
||||||
|
<script src="/vendor/dompurify/purify.min.js"></script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var hasEmail = <%= hasEmail ? 'true' : 'false' %>;
|
||||||
|
if (!hasEmail) return;
|
||||||
|
|
||||||
|
var listEl = document.getElementById('mmMeetingList');
|
||||||
|
var emptyEl = document.getElementById('mmListEmpty');
|
||||||
|
var resultSection = document.getElementById('mmResultSection');
|
||||||
|
var resultBody = document.getElementById('mmResultBody');
|
||||||
|
var minutesRenderedEl = document.getElementById('mmMinutesRendered');
|
||||||
|
var minutesActionsView = document.getElementById('mmMinutesActionsView');
|
||||||
|
var minutesActionsEdit = document.getElementById('mmMinutesActionsEdit');
|
||||||
|
var minutesEditBtn = document.getElementById('mmMinutesEdit');
|
||||||
|
var minutesApplyBtn = document.getElementById('mmMinutesApply');
|
||||||
|
var minutesCancelBtn = document.getElementById('mmMinutesCancel');
|
||||||
|
/** 편집 모드 진입 시점의 회의록 원문(취소 시 복원) */
|
||||||
|
var minutesEditSnapshot = '';
|
||||||
|
var transcriptBody = document.getElementById('mmTranscriptBody');
|
||||||
|
var transcriptLabel = document.getElementById('mmTranscriptLabel');
|
||||||
|
/** true: 음성 전사(transcript_text) 편집, false: 텍스트 회의 원문(source_text) 편집 */
|
||||||
|
var lastMeetingTranscriptIsAudio = false;
|
||||||
|
var saveResultBtn = document.getElementById('mmSaveResult');
|
||||||
|
var genProgressEl = document.getElementById('mmGenProgress');
|
||||||
|
var genProgressMsg = document.getElementById('mmGenProgressMsg');
|
||||||
|
var currentMeetingId = null;
|
||||||
|
|
||||||
|
function escapeHtmlMm(s) {
|
||||||
|
var div = document.createElement('div');
|
||||||
|
div.textContent = s;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
var minutesMarkdownConfigured = false;
|
||||||
|
function configureMinutesMarkdown() {
|
||||||
|
if (minutesMarkdownConfigured) return;
|
||||||
|
minutesMarkdownConfigured = true;
|
||||||
|
if (typeof marked !== 'undefined') {
|
||||||
|
marked.setOptions({ async: false, breaks: true, gfm: true });
|
||||||
|
}
|
||||||
|
var domPurify = typeof window !== 'undefined' && window.DOMPurify ? window.DOMPurify : typeof DOMPurify !== 'undefined' ? DOMPurify : null;
|
||||||
|
if (domPurify) {
|
||||||
|
domPurify.addHook('afterSanitizeAttributes', function (node) {
|
||||||
|
if (node.tagName === 'A' && node.hasAttribute('href')) {
|
||||||
|
var href = node.getAttribute('href');
|
||||||
|
try {
|
||||||
|
var u = new URL(href, window.location.href);
|
||||||
|
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
||||||
|
node.removeAttribute('href');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
node.removeAttribute('href');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
node.setAttribute('target', '_blank');
|
||||||
|
node.setAttribute('rel', 'noopener noreferrer');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function getDomPurify() {
|
||||||
|
return typeof window !== 'undefined' && window.DOMPurify
|
||||||
|
? window.DOMPurify
|
||||||
|
: typeof DOMPurify !== 'undefined'
|
||||||
|
? DOMPurify
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
function getMarkedParseFn() {
|
||||||
|
if (typeof marked === 'undefined') return null;
|
||||||
|
if (typeof marked.parse === 'function') return marked.parse.bind(marked);
|
||||||
|
if (typeof marked === 'function') return marked;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function renderMinutesMarkdown(text) {
|
||||||
|
configureMinutesMarkdown();
|
||||||
|
var src = String(text || '');
|
||||||
|
var parseFn = getMarkedParseFn();
|
||||||
|
var purify = getDomPurify();
|
||||||
|
if (!parseFn || !purify) {
|
||||||
|
return escapeHtmlMm(src).replace(/\n/g, '<br>');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
var raw = parseFn(src, { async: false });
|
||||||
|
if (raw != null && typeof raw.then === 'function') {
|
||||||
|
return escapeHtmlMm(src).replace(/\n/g, '<br>');
|
||||||
|
}
|
||||||
|
return purify.sanitize(String(raw || ''), { USE_PROFILES: { html: true } });
|
||||||
|
} catch (err) {
|
||||||
|
return escapeHtmlMm(src).replace(/\n/g, '<br>');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function refreshMinutesRendered() {
|
||||||
|
if (!minutesRenderedEl) return;
|
||||||
|
var raw = resultBody ? resultBody.value : '';
|
||||||
|
if (!String(raw).trim()) {
|
||||||
|
minutesRenderedEl.classList.add('mm-minutes-rendered-empty');
|
||||||
|
minutesRenderedEl.innerHTML = '회의록이 없습니다. 마크다운 편집으로 내용을 입력하세요.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
minutesRenderedEl.classList.remove('mm-minutes-rendered-empty');
|
||||||
|
minutesRenderedEl.innerHTML = renderMinutesMarkdown(raw);
|
||||||
|
}
|
||||||
|
function setMinutesToolbarMode(editing) {
|
||||||
|
if (minutesActionsView) minutesActionsView.hidden = !!editing;
|
||||||
|
if (minutesActionsEdit) minutesActionsEdit.hidden = !editing;
|
||||||
|
}
|
||||||
|
function setMinutesViewMode(showSource) {
|
||||||
|
if (!resultBody || !minutesRenderedEl) return;
|
||||||
|
if (!showSource) {
|
||||||
|
refreshMinutesRendered();
|
||||||
|
resultBody.hidden = true;
|
||||||
|
minutesRenderedEl.hidden = false;
|
||||||
|
setMinutesToolbarMode(false);
|
||||||
|
} else {
|
||||||
|
minutesRenderedEl.hidden = true;
|
||||||
|
resultBody.hidden = false;
|
||||||
|
setMinutesToolbarMode(true);
|
||||||
|
try {
|
||||||
|
resultBody.focus();
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (minutesEditBtn) {
|
||||||
|
minutesEditBtn.addEventListener('click', function () {
|
||||||
|
if (!resultBody) return;
|
||||||
|
minutesEditSnapshot = resultBody.value;
|
||||||
|
setMinutesViewMode(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (minutesApplyBtn) {
|
||||||
|
minutesApplyBtn.addEventListener('click', function () {
|
||||||
|
setMinutesViewMode(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (minutesCancelBtn) {
|
||||||
|
minutesCancelBtn.addEventListener('click', function () {
|
||||||
|
if (resultBody) resultBody.value = minutesEditSnapshot;
|
||||||
|
setMinutesViewMode(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 음성 파일 탭일 때만 생성 결과의 전사 기록 영역 표시(텍스트 입력은 회의 원문과 중복이므로 숨김) */
|
||||||
|
function applyTranscriptPaneVisibility() {
|
||||||
|
var pane = document.getElementById('mmTranscriptPane');
|
||||||
|
var audioTab = document.getElementById('mmTabAudio');
|
||||||
|
if (!pane || !audioTab) return;
|
||||||
|
var show = audioTab.classList.contains('is-active');
|
||||||
|
pane.hidden = !show;
|
||||||
|
pane.setAttribute('aria-hidden', show ? 'false' : 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCurrentMeetingId(id) {
|
||||||
|
currentMeetingId = id || null;
|
||||||
|
if (saveResultBtn) saveResultBtn.disabled = !currentMeetingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMeetingGenerating(on, msg) {
|
||||||
|
if (genProgressEl) {
|
||||||
|
genProgressEl.hidden = !on;
|
||||||
|
genProgressEl.setAttribute('aria-busy', on ? 'true' : 'false');
|
||||||
|
if (on) genProgressEl.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}
|
||||||
|
if (genProgressMsg && msg) genProgressMsg.textContent = msg;
|
||||||
|
var gText = document.getElementById('mmGenText');
|
||||||
|
var gAudio = document.getElementById('mmGenAudio');
|
||||||
|
if (gText) gText.disabled = !!on;
|
||||||
|
if (gAudio) gAudio.disabled = !!on;
|
||||||
|
if (saveResultBtn) saveResultBtn.disabled = !!on || !currentMeetingId;
|
||||||
|
if (resultBody) resultBody.disabled = !!on;
|
||||||
|
if (transcriptBody) transcriptBody.disabled = !!on;
|
||||||
|
if (minutesEditBtn) minutesEditBtn.disabled = !!on;
|
||||||
|
if (minutesApplyBtn) minutesApplyBtn.disabled = !!on;
|
||||||
|
if (minutesCancelBtn) minutesCancelBtn.disabled = !!on;
|
||||||
|
if (on && resultBody && minutesRenderedEl) {
|
||||||
|
if (!resultBody.hidden) {
|
||||||
|
refreshMinutesRendered();
|
||||||
|
setMinutesViewMode(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function api(path, opts) {
|
||||||
|
return fetch(path, Object.assign({ credentials: 'same-origin' }, opts || {})).then(function (r) {
|
||||||
|
var ct = (r.headers.get('content-type') || '').toLowerCase();
|
||||||
|
if (ct.indexOf('application/json') === -1) {
|
||||||
|
return r.text().then(function (t) {
|
||||||
|
var msg = (t || '').replace(/\s+/g, ' ').trim().slice(0, 200);
|
||||||
|
throw new Error(msg || 'HTTP ' + r.status + ' ' + (r.statusText || ''));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return r.json().then(function (j) {
|
||||||
|
if (!r.ok) throw new Error(j.error || r.statusText);
|
||||||
|
return j;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var MM_DEFAULT_CUSTOM_INSTRUCTIONS = <%- JSON.stringify(mmDefaultCustomInstructions) %>;
|
||||||
|
|
||||||
|
function loadPrompt() {
|
||||||
|
return api('/api/meeting-minutes/prompt').then(function (d) {
|
||||||
|
var p = d.prompt || {};
|
||||||
|
document.getElementById('mmIncTitle').checked = p.includeTitleLine !== false;
|
||||||
|
document.getElementById('mmIncAtt').checked = p.includeAttendees !== false;
|
||||||
|
document.getElementById('mmIncSum').checked = p.includeSummary !== false;
|
||||||
|
document.getElementById('mmIncAct').checked = p.includeActionItems !== false;
|
||||||
|
document.getElementById('mmIncChk').checked = true;
|
||||||
|
var saved = (p.customInstructions && String(p.customInstructions).trim()) || '';
|
||||||
|
document.getElementById('mmCustomInstr').value = saved || MM_DEFAULT_CUSTOM_INSTRUCTIONS;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMeetings() {
|
||||||
|
return api('/api/meeting-minutes/meetings').then(function (d) {
|
||||||
|
var list = d.meetings || [];
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
if (!list.length) {
|
||||||
|
emptyEl.hidden = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emptyEl.hidden = true;
|
||||||
|
list.forEach(function (m) {
|
||||||
|
var li = document.createElement('li');
|
||||||
|
li.className = 'mm-meeting-item';
|
||||||
|
var t = document.createElement('button');
|
||||||
|
t.type = 'button';
|
||||||
|
t.className = 'mm-meeting-item-btn';
|
||||||
|
var title = (m.title || '제목 없음').slice(0, 60);
|
||||||
|
var md = m.meetingDate ? String(m.meetingDate).slice(0, 10) : '';
|
||||||
|
var mdKo = '';
|
||||||
|
if (md && md.length >= 10) {
|
||||||
|
var p = md.split('-');
|
||||||
|
if (p.length === 3) mdKo = p[0] + '. ' + p[1] + '. ' + p[2] + '.';
|
||||||
|
}
|
||||||
|
var dt = m.createdAt ? new Date(m.createdAt).toLocaleString('ko-KR') : '';
|
||||||
|
t.textContent = title + (mdKo ? ' · ' + mdKo : '') + (dt ? ' · ' + dt : '');
|
||||||
|
t.addEventListener('click', function () {
|
||||||
|
showMeeting(m.id);
|
||||||
|
});
|
||||||
|
var del = document.createElement('button');
|
||||||
|
del.type = 'button';
|
||||||
|
del.className = 'mm-meeting-del';
|
||||||
|
del.setAttribute('aria-label', '삭제');
|
||||||
|
del.textContent = '×';
|
||||||
|
del.addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!confirm('이 회의록을 삭제할까요?')) return;
|
||||||
|
api('/api/meeting-minutes/meetings/' + encodeURIComponent(m.id), { method: 'DELETE' }).then(loadMeetings);
|
||||||
|
});
|
||||||
|
li.appendChild(t);
|
||||||
|
li.appendChild(del);
|
||||||
|
listEl.appendChild(li);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMeeting(id) {
|
||||||
|
api('/api/meeting-minutes/meetings/' + encodeURIComponent(id)).then(function (d) {
|
||||||
|
var m = d.meeting;
|
||||||
|
resultSection.hidden = false;
|
||||||
|
setCurrentMeetingId(m.id);
|
||||||
|
if (transcriptLabel) {
|
||||||
|
transcriptLabel.textContent =
|
||||||
|
m.transcriptText && String(m.transcriptText).trim() ? '전사 기록' : '회의 원문';
|
||||||
|
}
|
||||||
|
lastMeetingTranscriptIsAudio = !!(m.transcriptText && String(m.transcriptText).trim());
|
||||||
|
if (transcriptBody) {
|
||||||
|
if (lastMeetingTranscriptIsAudio) {
|
||||||
|
transcriptBody.value = m.transcriptText != null ? String(m.transcriptText) : '';
|
||||||
|
} else {
|
||||||
|
transcriptBody.value = m.sourceText != null ? String(m.sourceText) : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resultBody.value = m.generatedMinutes != null ? String(m.generatedMinutes) : '';
|
||||||
|
setMinutesViewMode(false);
|
||||||
|
applyTranscriptPaneVisibility();
|
||||||
|
var title = m.title || '';
|
||||||
|
document.getElementById('mmTitleText').value = title;
|
||||||
|
document.getElementById('mmTitleAudio').value = title;
|
||||||
|
var md = m.meetingDate ? String(m.meetingDate).slice(0, 10) : '';
|
||||||
|
document.getElementById('mmDateText').value = md && md.length >= 10 ? md : '';
|
||||||
|
document.getElementById('mmDateAudio').value = md && md.length >= 10 ? md : '';
|
||||||
|
var src = '';
|
||||||
|
if (m.sourceText && String(m.sourceText).trim()) src = m.sourceText;
|
||||||
|
else if (m.transcriptText && String(m.transcriptText).trim()) src = m.transcriptText;
|
||||||
|
document.getElementById('mmSourceText').value = src;
|
||||||
|
if (m.chatModel) {
|
||||||
|
var mt = document.getElementById('mmModelText');
|
||||||
|
var ma = document.getElementById('mmModelAudio');
|
||||||
|
if (mt) {
|
||||||
|
for (var i = 0; i < mt.options.length; i++) {
|
||||||
|
if (mt.options[i].value === m.chatModel) {
|
||||||
|
mt.selectedIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ma) {
|
||||||
|
for (var j = 0; j < ma.options.length; j++) {
|
||||||
|
if (ma.options[j].value === m.chatModel) {
|
||||||
|
ma.selectedIndex = j;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (m.transcriptionModel) {
|
||||||
|
var wEl = document.getElementById('mmWhisperModel');
|
||||||
|
if (wEl) {
|
||||||
|
for (var k = 0; k < wEl.options.length; k++) {
|
||||||
|
if (wEl.options[k].value === m.transcriptionModel) {
|
||||||
|
wEl.selectedIndex = k;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('mmSavePrompt').addEventListener('click', function () {
|
||||||
|
var body = {
|
||||||
|
includeTitleLine: document.getElementById('mmIncTitle').checked,
|
||||||
|
includeAttendees: document.getElementById('mmIncAtt').checked,
|
||||||
|
includeSummary: document.getElementById('mmIncSum').checked,
|
||||||
|
includeActionItems: document.getElementById('mmIncAct').checked,
|
||||||
|
includeChecklist: true,
|
||||||
|
customInstructions: document.getElementById('mmCustomInstr').value
|
||||||
|
};
|
||||||
|
api('/api/meeting-minutes/prompt', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
}).then(function () {
|
||||||
|
alert('저장되었습니다.');
|
||||||
|
}).catch(function (e) {
|
||||||
|
alert(e.message || '저장 실패');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('mmSaveResult').addEventListener('click', function () {
|
||||||
|
if (!currentMeetingId) return;
|
||||||
|
setMeetingGenerating(true, '저장 중…');
|
||||||
|
var savePayload = { generatedMinutes: resultBody.value };
|
||||||
|
if (lastMeetingTranscriptIsAudio) {
|
||||||
|
savePayload.transcriptText = transcriptBody ? transcriptBody.value : '';
|
||||||
|
} else {
|
||||||
|
savePayload.sourceText = transcriptBody ? transcriptBody.value : '';
|
||||||
|
}
|
||||||
|
api('/api/meeting-minutes/meetings/' + encodeURIComponent(currentMeetingId) + '/save', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(savePayload)
|
||||||
|
})
|
||||||
|
.then(function (d) {
|
||||||
|
if (d.meeting) {
|
||||||
|
if (d.meeting.generatedMinutes != null) resultBody.value = d.meeting.generatedMinutes;
|
||||||
|
setMinutesViewMode(false);
|
||||||
|
if (transcriptBody) {
|
||||||
|
if (lastMeetingTranscriptIsAudio && d.meeting.transcriptText != null) {
|
||||||
|
transcriptBody.value = String(d.meeting.transcriptText);
|
||||||
|
}
|
||||||
|
if (!lastMeetingTranscriptIsAudio && d.meeting.sourceText != null) {
|
||||||
|
transcriptBody.value = String(d.meeting.sourceText);
|
||||||
|
var stEl = document.getElementById('mmSourceText');
|
||||||
|
if (stEl) stEl.value = String(d.meeting.sourceText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadMeetings();
|
||||||
|
var cs = d.checklistSync;
|
||||||
|
if (cs && cs.imported > 0) {
|
||||||
|
alert('저장되었습니다. 업무 체크리스트에 ' + cs.imported + '건이 반영되었습니다.');
|
||||||
|
} else if (cs && cs.extractError && !cs.disabled) {
|
||||||
|
alert('저장되었습니다. (체크리스트 자동 연동: ' + cs.extractError + ')');
|
||||||
|
} else {
|
||||||
|
alert('저장되었습니다.');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
alert(e.message || '저장 실패');
|
||||||
|
})
|
||||||
|
.finally(function () {
|
||||||
|
setMeetingGenerating(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('mmListRefresh').addEventListener('click', loadMeetings);
|
||||||
|
|
||||||
|
function wireMeetingDatePicker(btnId, inputId) {
|
||||||
|
var btn = document.getElementById(btnId);
|
||||||
|
var inp = document.getElementById(inputId);
|
||||||
|
if (!btn || !inp) return;
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
if (typeof inp.showPicker === 'function') inp.showPicker();
|
||||||
|
else inp.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
wireMeetingDatePicker('mmDateTextBtn', 'mmDateText');
|
||||||
|
wireMeetingDatePicker('mmDateAudioBtn', 'mmDateAudio');
|
||||||
|
|
||||||
|
document.querySelectorAll('.mm-tab').forEach(function (tab) {
|
||||||
|
tab.addEventListener('click', function () {
|
||||||
|
var name = tab.getAttribute('data-tab');
|
||||||
|
document.querySelectorAll('.mm-tab').forEach(function (t) {
|
||||||
|
t.classList.toggle('is-active', t === tab);
|
||||||
|
t.setAttribute('aria-selected', t === tab ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
var panelText = document.getElementById('mmPanelText');
|
||||||
|
var panelAudio = document.getElementById('mmPanelAudio');
|
||||||
|
panelText.hidden = name !== 'text';
|
||||||
|
panelAudio.hidden = name !== 'audio';
|
||||||
|
panelText.setAttribute('aria-hidden', name !== 'text' ? 'true' : 'false');
|
||||||
|
panelAudio.setAttribute('aria-hidden', name !== 'audio' ? 'true' : 'false');
|
||||||
|
applyTranscriptPaneVisibility();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('mmGenText').addEventListener('click', function () {
|
||||||
|
var title = (document.getElementById('mmTitleText').value || '').trim();
|
||||||
|
if (!title) {
|
||||||
|
alert('제목을 입력해 주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var sourceText = (document.getElementById('mmSourceText').value || '').trim();
|
||||||
|
if (!sourceText) {
|
||||||
|
alert('회의 원문을 입력해 주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMeetingGenerating(true, '회의 원문을 보내고 있습니다…');
|
||||||
|
api('/api/meeting-minutes/generate-text', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: title,
|
||||||
|
meetingDate: document.getElementById('mmDateText').value,
|
||||||
|
sourceText: sourceText,
|
||||||
|
model: document.getElementById('mmModelText').value
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(function (d) {
|
||||||
|
resultSection.hidden = false;
|
||||||
|
setCurrentMeetingId(d.meeting && d.meeting.id);
|
||||||
|
lastMeetingTranscriptIsAudio = false;
|
||||||
|
if (transcriptLabel) transcriptLabel.textContent = '회의 원문';
|
||||||
|
if (transcriptBody) transcriptBody.value = (d.meeting && d.meeting.sourceText) || '';
|
||||||
|
resultBody.value = (d.meeting && d.meeting.generatedMinutes) || '';
|
||||||
|
setMinutesViewMode(false);
|
||||||
|
applyTranscriptPaneVisibility();
|
||||||
|
loadMeetings();
|
||||||
|
var cs = d.checklistSync;
|
||||||
|
if (cs && cs.imported > 0) {
|
||||||
|
alert('업무 체크리스트에 ' + cs.imported + '건이 자동 반영되었습니다. (업무 체크리스트 AI에서 확인)');
|
||||||
|
} else if (cs && cs.extractError && !cs.disabled) {
|
||||||
|
console.warn('체크리스트 자동 추출:', cs.extractError);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
alert(e.message || '생성 실패');
|
||||||
|
})
|
||||||
|
.finally(function () {
|
||||||
|
setMeetingGenerating(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('mmGenAudio').addEventListener('click', function () {
|
||||||
|
var fileInput = document.getElementById('mmAudioFile');
|
||||||
|
if (!fileInput.files || !fileInput.files.length) {
|
||||||
|
alert('음성 파일을 선택해 주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var audioTitle = (document.getElementById('mmTitleAudio').value || '').trim();
|
||||||
|
if (!audioTitle) {
|
||||||
|
alert('제목을 입력해 주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.append('audio', fileInput.files[0]);
|
||||||
|
fd.append('title', audioTitle);
|
||||||
|
fd.append('meetingDate', document.getElementById('mmDateAudio').value);
|
||||||
|
fd.append('model', document.getElementById('mmModelAudio').value);
|
||||||
|
fd.append('whisperModel', document.getElementById('mmWhisperModel').value);
|
||||||
|
setMeetingGenerating(true, '음성 파일을 업로드하고 전사합니다. 길이에 따라 1분 이상 걸릴 수 있습니다…');
|
||||||
|
fetch('/api/meeting-minutes/generate-audio', { method: 'POST', body: fd, credentials: 'same-origin' })
|
||||||
|
.then(function (r) {
|
||||||
|
return r.json().then(function (j) {
|
||||||
|
if (!r.ok) throw new Error(j.error || r.statusText);
|
||||||
|
return j;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(function (d) {
|
||||||
|
resultSection.hidden = false;
|
||||||
|
setCurrentMeetingId(d.meeting && d.meeting.id);
|
||||||
|
lastMeetingTranscriptIsAudio = true;
|
||||||
|
if (transcriptLabel) transcriptLabel.textContent = '전사 기록';
|
||||||
|
if (transcriptBody) transcriptBody.value = (d.meeting && d.meeting.transcriptText) || '';
|
||||||
|
resultBody.value = (d.meeting && d.meeting.generatedMinutes) || '';
|
||||||
|
setMinutesViewMode(false);
|
||||||
|
applyTranscriptPaneVisibility();
|
||||||
|
fileInput.value = '';
|
||||||
|
loadMeetings();
|
||||||
|
var cs = d.checklistSync;
|
||||||
|
if (cs && cs.imported > 0) {
|
||||||
|
alert('업무 체크리스트에 ' + cs.imported + '건이 자동 반영되었습니다. (업무 체크리스트 AI에서 확인)');
|
||||||
|
} else if (cs && cs.extractError && !cs.disabled) {
|
||||||
|
console.warn('체크리스트 자동 추출:', cs.extractError);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
alert(e.message || '생성 실패');
|
||||||
|
})
|
||||||
|
.finally(function () {
|
||||||
|
setMeetingGenerating(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
applyTranscriptPaneVisibility();
|
||||||
|
loadPrompt().then(loadMeetings).catch(function () {});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var panel = document.getElementById('mmPromptPanel');
|
||||||
|
var btn = document.getElementById('mmPromptToggle');
|
||||||
|
var body = document.getElementById('mmPromptBody');
|
||||||
|
if (!panel || !btn || !body) return;
|
||||||
|
function applyOpen(open) {
|
||||||
|
if (open) {
|
||||||
|
panel.classList.remove('mm-prompt-collapsed');
|
||||||
|
body.hidden = false;
|
||||||
|
btn.setAttribute('aria-expanded', 'true');
|
||||||
|
} else {
|
||||||
|
panel.classList.add('mm-prompt-collapsed');
|
||||||
|
body.hidden = true;
|
||||||
|
btn.setAttribute('aria-expanded', 'false');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (localStorage.getItem('meetingMinutesPromptOpen') === '1') {
|
||||||
|
applyOpen(true);
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var nowCollapsed = panel.classList.toggle('mm-prompt-collapsed');
|
||||||
|
body.hidden = nowCollapsed;
|
||||||
|
btn.setAttribute('aria-expanded', String(!nowCollapsed));
|
||||||
|
try {
|
||||||
|
localStorage.setItem('meetingMinutesPromptOpen', nowCollapsed ? '0' : '1');
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
8
views/partials/admin-button.ejs
Normal file
8
views/partials/admin-button.ejs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<%
|
||||||
|
const label = typeof buttonLabel !== 'undefined' && buttonLabel ? buttonLabel : '관리자';
|
||||||
|
%>
|
||||||
|
<% if (typeof adminMode !== 'undefined' && adminMode) { %>
|
||||||
|
<a href="/admin" class="top-action-link"><%= label %></a>
|
||||||
|
<% } else { %>
|
||||||
|
<button type="button" class="top-action-link" onclick="openAdminTokenModal()"><%= label %></button>
|
||||||
|
<% } %>
|
||||||
86
views/partials/admin-token-modal.ejs
Normal file
86
views/partials/admin-token-modal.ejs
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
<div id="admin-token-modal" class="modal-overlay" role="dialog" aria-labelledby="admin-modal-title" aria-modal="true" hidden>
|
||||||
|
<div class="modal-backdrop" onclick="closeAdminTokenModal()"></div>
|
||||||
|
<div class="modal-content">
|
||||||
|
<h3 id="admin-modal-title">관리자 모드</h3>
|
||||||
|
<p class="modal-desc">강의 등록을 위해 관리자 토큰을 입력한 뒤 활성화해주세요.</p>
|
||||||
|
<p id="admin-token-error" class="admin-error" style="display:none; margin-bottom:12px">입력한 토큰이 올바르지 않습니다. 다시 확인해주세요.</p>
|
||||||
|
<form id="admin-token-form" class="admin-token-form">
|
||||||
|
<input type="password" id="admin-token-input" name="token" placeholder="관리자 토큰" required autocomplete="off" />
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="submit" id="admin-token-submit">활성화</button>
|
||||||
|
<button type="button" class="ghost" onclick="closeAdminTokenModal()">닫기</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function moveAdminModalToBody() {
|
||||||
|
function run() {
|
||||||
|
var m = document.getElementById("admin-token-modal");
|
||||||
|
if (m && m.parentNode && m.parentNode !== document.body) {
|
||||||
|
document.body.appendChild(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", run);
|
||||||
|
} else {
|
||||||
|
run();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
function openAdminTokenModal() {
|
||||||
|
var m = document.getElementById("admin-token-modal");
|
||||||
|
m.hidden = false;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
document.getElementById("admin-token-error").style.display = "none";
|
||||||
|
document.getElementById("admin-token-input").value = "";
|
||||||
|
}
|
||||||
|
function closeAdminTokenModal() {
|
||||||
|
document.getElementById("admin-token-modal").hidden = true;
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
}
|
||||||
|
function initAdminTokenForm() {
|
||||||
|
var form = document.getElementById("admin-token-form");
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener("submit", function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var input = document.getElementById("admin-token-input");
|
||||||
|
var token = (input && input.value || "").trim();
|
||||||
|
var errEl = document.getElementById("admin-token-error");
|
||||||
|
var btn = document.getElementById("admin-token-submit");
|
||||||
|
if (!token) {
|
||||||
|
if (errEl) errEl.style.display = "block";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
if (errEl) errEl.style.display = "none";
|
||||||
|
fetch("/api/admin/validate-token", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ token: token })
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data && data.valid) {
|
||||||
|
window.location.href = "/admin?token=" + encodeURIComponent(token);
|
||||||
|
} else {
|
||||||
|
if (errEl) errEl.style.display = "block";
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function() {
|
||||||
|
if (errEl) {
|
||||||
|
errEl.textContent = "토큰 검증 중 오류가 발생했습니다.";
|
||||||
|
errEl.style.display = "block";
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(function() {
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", initAdminTokenForm);
|
||||||
|
} else {
|
||||||
|
initAdminTokenForm();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
54
views/partials/lecture-card.ejs
Normal file
54
views/partials/lecture-card.ejs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<% var _externalUrl = (lecture.type === "link" || lecture.type === "news") && (lecture.newsUrl || "").trim(); %>
|
||||||
|
<article class="lecture-card">
|
||||||
|
<% if (_externalUrl) { %>
|
||||||
|
<a class="lecture-link lecture-link-external" href="<%= _externalUrl %>" target="_blank" rel="noopener noreferrer">
|
||||||
|
<% } else { %>
|
||||||
|
<a class="lecture-link" href="/lectures/<%= lecture.id %>">
|
||||||
|
<% } %>
|
||||||
|
<div class="thumb <%= lecture.type %>">
|
||||||
|
<% if (lecture.type === "ppt") { %>
|
||||||
|
<% if (lecture.thumbnailUrl) { %>
|
||||||
|
<img src="<%= lecture.thumbnailUrl %>" alt="<%= lecture.title %> 썸네일" class="thumb-image" />
|
||||||
|
<% } else { %>
|
||||||
|
<span class="thumb-fallback">썸네일 <%= lecture.thumbnailStatus || "pending" %></span>
|
||||||
|
<% } %>
|
||||||
|
<span class="thumb-kicker">PPT 프리뷰</span>
|
||||||
|
<% if (lecture.previewTitle && lecture.previewTitle !== "제목 없음") { %><strong><%= lecture.previewTitle %></strong><% } %>
|
||||||
|
<% } else if (lecture.type === "news") { %>
|
||||||
|
<% if (lecture.thumbnailUrl) { %>
|
||||||
|
<img src="<%= lecture.thumbnailUrl %>" alt="" class="thumb-image thumb-image-og" loading="lazy" referrerpolicy="no-referrer" />
|
||||||
|
<% } %>
|
||||||
|
<span class="thumb-kicker">뉴스</span>
|
||||||
|
<% if (!lecture.thumbnailUrl) { %><strong>외부 링크</strong><% } %>
|
||||||
|
<% } else if (lecture.type === "link") { %>
|
||||||
|
<% if (lecture.thumbnailUrl) { %>
|
||||||
|
<img src="<%= lecture.thumbnailUrl %>" alt="" class="thumb-image thumb-image-og" loading="lazy" referrerpolicy="no-referrer" />
|
||||||
|
<% } %>
|
||||||
|
<span class="thumb-kicker">웹 링크</span>
|
||||||
|
<% if (!lecture.thumbnailUrl) { %><strong>외부 페이지</strong><% } %>
|
||||||
|
<% } else if (lecture.type === "video") { %>
|
||||||
|
<span class="thumb-fallback thumb-fallback-video">▶</span>
|
||||||
|
<span class="thumb-kicker">동영상 파일</span>
|
||||||
|
<strong>업로드 영상</strong>
|
||||||
|
<% } else { %>
|
||||||
|
<% const ytThumb = typeof getYoutubeThumbnailUrl === 'function' ? getYoutubeThumbnailUrl(lecture.youtubeUrl) : null; %>
|
||||||
|
<% if (ytThumb) { %>
|
||||||
|
<img src="<%= ytThumb %>" alt="<%= lecture.title %> 썸네일" class="thumb-image thumb-image-youtube" />
|
||||||
|
<% } %>
|
||||||
|
<span class="thumb-kicker">YouTube</span>
|
||||||
|
<strong>영상 강의</strong>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<div class="badge <%= lecture.type %>">
|
||||||
|
<% if (lecture.type === "youtube") { %>YouTube<% } else if (lecture.type === "news") { %>뉴스<% } else if (lecture.type === "link") { %>링크<% } else if (lecture.type === "video") { %>동영상<% } else { %>PPT<% } %>
|
||||||
|
</div>
|
||||||
|
<h3><%= lecture.title %></h3>
|
||||||
|
<p><%= lecture.description || "설명이 없습니다." %></p>
|
||||||
|
<div class="tag-row">
|
||||||
|
<% (lecture.tags || []).forEach((oneTag) => { %>
|
||||||
|
<span class="tag-chip">#<%= oneTag %></span>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
<small><%= new Date(lecture.createdAt).toLocaleString("ko-KR") %></small>
|
||||||
|
</a>
|
||||||
|
</article>
|
||||||
3
views/partials/lecture-cards.ejs
Normal file
3
views/partials/lecture-cards.ejs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<% (lectures || []).forEach((lecture) => { %>
|
||||||
|
<%- include('lecture-card', { lecture }) %>
|
||||||
|
<% }) %>
|
||||||
120
views/partials/nav.ejs
Normal file
120
views/partials/nav.ejs
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<button type="button" class="nav-mobile-toggle" id="nav-mobile-toggle" aria-label="메뉴 열기" aria-expanded="false" aria-controls="nav-drawer">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path fill="currentColor" d="M3 6h18v2H3V6zm0 5h18v2H3v-2zm0 5h18v2H3v-2z"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="nav-drawer-backdrop" id="nav-drawer-backdrop" hidden></div>
|
||||||
|
<aside class="left-nav" id="nav-drawer">
|
||||||
|
<div class="nav-logo-section">
|
||||||
|
<a
|
||||||
|
href="https://xavis.co.kr"
|
||||||
|
class="logo-link logo-link-xavis"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label="XAVIS 회사 사이트(새 탭)"
|
||||||
|
>
|
||||||
|
<img src="/public/images/xavis-logo.png" alt="XAVIS" class="logo-img logo-img-xavis" />
|
||||||
|
</a>
|
||||||
|
<img
|
||||||
|
src="/public/images/aiplatform-logo.png"
|
||||||
|
alt="AI PLATFORM"
|
||||||
|
class="logo-img logo-img-aiplatform"
|
||||||
|
width="168"
|
||||||
|
height="auto"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
<div class="nav-logo-divider" role="presentation" aria-hidden="true"></div>
|
||||||
|
</div>
|
||||||
|
<a href="/chat" class="nav-item <%= activeMenu === 'chat' ? 'active' : '' %>">채팅</a>
|
||||||
|
<a href="/ai-explore" class="nav-item <%= activeMenu === 'ai-explore' ? 'active' : '' %>">AI</a>
|
||||||
|
<a href="/learning" class="nav-item <%= activeMenu === 'learning' ? 'active' : '' %>">학습센터</a>
|
||||||
|
<a href="/ax-apply" class="nav-item <%= activeMenu === 'ax-apply' ? 'active' : '' %>">과제신청</a>
|
||||||
|
<a href="/ai-cases" class="nav-item <%= activeMenu === 'ai-cases' ? 'active' : '' %>">성공사례</a>
|
||||||
|
<div class="nav-footer">
|
||||||
|
<% var _opsLoggedIn = typeof opsUserEmail !== 'undefined' && opsUserEmail; %>
|
||||||
|
<% if (_opsLoggedIn) { %>
|
||||||
|
<a href="/logout" class="nav-item nav-item-ops-logout" title="이메일 인증 세션 종료">로그아웃</a>
|
||||||
|
<% } else { %>
|
||||||
|
<% if (typeof adminMode !== 'undefined' && adminMode) { %>
|
||||||
|
<a href="/admin/users" class="nav-item <%= activeMenu === 'admin-users' ? 'active' : '' %>">사용자 현황관리</a>
|
||||||
|
<div class="nav-separator"></div>
|
||||||
|
<a href="/admin/logout" class="nav-item nav-item-ghost" title="관리자 세션 종료">로그오프</a>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="nav-separator"></div>
|
||||||
|
<button type="button" class="nav-item nav-item-ghost" onclick="openAdminTokenModal()" title="관리자 모드">관리자</button>
|
||||||
|
<% } %>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var toggle = document.getElementById("nav-mobile-toggle");
|
||||||
|
var nav = document.getElementById("nav-drawer");
|
||||||
|
var backdrop = document.getElementById("nav-drawer-backdrop");
|
||||||
|
if (!toggle || !nav || !backdrop) return;
|
||||||
|
|
||||||
|
function isMobileNav() {
|
||||||
|
return typeof window.matchMedia === "function" && window.matchMedia("(max-width: 900px)").matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDrawer() {
|
||||||
|
if (!isMobileNav()) return;
|
||||||
|
nav.classList.add("nav-drawer-open");
|
||||||
|
backdrop.removeAttribute("hidden");
|
||||||
|
document.body.classList.add("nav-mobile-open");
|
||||||
|
toggle.setAttribute("aria-expanded", "true");
|
||||||
|
toggle.setAttribute("aria-label", "메뉴 닫기");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDrawer() {
|
||||||
|
nav.classList.remove("nav-drawer-open");
|
||||||
|
backdrop.setAttribute("hidden", "");
|
||||||
|
document.body.classList.remove("nav-mobile-open");
|
||||||
|
toggle.setAttribute("aria-expanded", "false");
|
||||||
|
toggle.setAttribute("aria-label", "메뉴 열기");
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDrawer() {
|
||||||
|
if (nav.classList.contains("nav-drawer-open")) closeDrawer();
|
||||||
|
else openDrawer();
|
||||||
|
}
|
||||||
|
|
||||||
|
toggle.addEventListener("click", function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleDrawer();
|
||||||
|
});
|
||||||
|
backdrop.addEventListener("click", closeDrawer);
|
||||||
|
|
||||||
|
nav.querySelectorAll("a").forEach(function (el) {
|
||||||
|
el.addEventListener("click", function () {
|
||||||
|
if (isMobileNav()) closeDrawer();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
nav.querySelectorAll("button.nav-item").forEach(function (el) {
|
||||||
|
el.addEventListener("click", function () {
|
||||||
|
if (isMobileNav()) closeDrawer();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener("keydown", function (e) {
|
||||||
|
if (e.key === "Escape" && nav.classList.contains("nav-drawer-open")) closeDrawer();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof window.matchMedia === "function") {
|
||||||
|
window.matchMedia("(min-width: 901px)").addEventListener("change", function (ev) {
|
||||||
|
if (ev.matches) closeDrawer();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var sp = new URLSearchParams(window.location.search);
|
||||||
|
if (sp.get("verified") === "1") {
|
||||||
|
alert("인증되었습니다.");
|
||||||
|
var u = new URL(window.location.href);
|
||||||
|
u.searchParams.delete("verified");
|
||||||
|
history.replaceState({}, "", u.pathname + (u.search ? u.search : "") + u.hash);
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<%- include('admin-token-modal') %>
|
||||||
43
views/partials/success-story-card.ejs
Normal file
43
views/partials/success-story-card.ejs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<% var detailAllowed = typeof successStoryDetailAllowed !== 'undefined' ? successStoryDetailAllowed : true; %>
|
||||||
|
<article class="success-story-card<%= detailAllowed ? '' : ' success-story-card--locked' %>">
|
||||||
|
<% if (detailAllowed) { %>
|
||||||
|
<a class="success-story-link" href="/ai-cases/<%= story.slug %>">
|
||||||
|
<div class="success-thumb" aria-hidden="true">
|
||||||
|
<span class="success-thumb-icon">✦</span>
|
||||||
|
<span class="success-thumb-kicker"><%= story.department || "사내 사례" %></span>
|
||||||
|
</div>
|
||||||
|
<div class="success-badge"><%= story.department || "사내" %> · <%= story.author || "" %></div>
|
||||||
|
<h3><%= story.title %></h3>
|
||||||
|
<p class="success-excerpt"><%= story.excerpt || "" %></p>
|
||||||
|
<div class="tag-row">
|
||||||
|
<% (story.tags || []).forEach((oneTag) => { %>
|
||||||
|
<span class="tag-chip">#<%= oneTag %></span>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
<small class="success-meta">
|
||||||
|
<% if (story.publishedAt) { %><%= story.publishedAt %><% } %>
|
||||||
|
</small>
|
||||||
|
</a>
|
||||||
|
<% } else { %>
|
||||||
|
<div
|
||||||
|
class="success-story-link"
|
||||||
|
title="로그인 후 이용 가능합니다."
|
||||||
|
>
|
||||||
|
<div class="success-thumb" aria-hidden="true">
|
||||||
|
<span class="success-thumb-icon">✦</span>
|
||||||
|
<span class="success-thumb-kicker"><%= story.department || "사내 사례" %></span>
|
||||||
|
</div>
|
||||||
|
<div class="success-badge"><%= story.department || "사내" %> · <%= story.author || "" %></div>
|
||||||
|
<h3><%= story.title %></h3>
|
||||||
|
<p class="success-excerpt"><%= story.excerpt || "" %></p>
|
||||||
|
<div class="tag-row">
|
||||||
|
<% (story.tags || []).forEach((oneTag) => { %>
|
||||||
|
<span class="tag-chip">#<%= oneTag %></span>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
<small class="success-meta">
|
||||||
|
<% if (story.publishedAt) { %><%= story.publishedAt %><% } %>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</article>
|
||||||
481
views/task-checklist.ejs
Normal file
481
views/task-checklist.ejs
Normal file
@@ -0,0 +1,481 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>업무 체크리스트 AI - XAVIS</title>
|
||||||
|
<link rel="stylesheet" href="/public/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body class="task-checklist-page">
|
||||||
|
<div class="app-shell">
|
||||||
|
<%- include('partials/nav', { activeMenu: 'ai-explore', adminMode: typeof adminMode !== 'undefined' ? adminMode : false }) %>
|
||||||
|
<div class="content-area">
|
||||||
|
<header class="topbar">
|
||||||
|
<h1>업무 체크리스트 AI</h1>
|
||||||
|
<a class="top-action-link" href="/ai-explore">AI 목록</a>
|
||||||
|
</header>
|
||||||
|
<main class="container container-ai-full meeting-minutes-main">
|
||||||
|
<a href="/ai-explore" class="prompts-back" aria-label="AI 탐색으로 돌아가기">← AI</a>
|
||||||
|
<% var hasEmail = typeof meetingUserEmail !== 'undefined' && meetingUserEmail; %>
|
||||||
|
<% if (!hasEmail) { %>
|
||||||
|
<p class="chat-api-warning" style="margin-bottom: 16px">
|
||||||
|
이메일 인증(OPS <code>PROD</code>) 또는 DEV에서 <strong>관리자 모드</strong>, SUPER(데모)에서는 별도 로그인 없이 이용할 수 있습니다.
|
||||||
|
</p>
|
||||||
|
<% } else { %>
|
||||||
|
<p class="subtitle tcl-lead">
|
||||||
|
<strong>회의록 AI</strong>에서 저장한 내용을 바탕으로 할 일이 모입니다. 회의록의 <strong>액션 아이템(번호별)</strong>과 <strong>회의 체크리스트</strong>가 모두 항목으로 반영됩니다. <strong>진행상황</strong> 또는 <strong>회의록</strong>을 바꿀 때마다 선택한 범위에서 자동으로 가져온 뒤 목록을 맞춥니다. 항목에 마우스를 올리면 해당 <strong>회의 제목·일자·요약</strong>을 볼 수 있습니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section class="panel tcl-toolbar">
|
||||||
|
<div class="tcl-toolbar-row">
|
||||||
|
<label class="tcl-filter">
|
||||||
|
<span class="tcl-filter-label">진행상황</span>
|
||||||
|
<select id="tclFilter" class="mm-select">
|
||||||
|
<option value="all" selected>전체</option>
|
||||||
|
<option value="open">진행 중</option>
|
||||||
|
<option value="done">완료</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="tcl-filter">
|
||||||
|
<span class="tcl-filter-label">회의록</span>
|
||||||
|
<select id="tclMeetingPick" class="mm-select" title="목록 필터·가져오기 범위 (기본: 전체 회의)">
|
||||||
|
<option value="__all__" selected>전체 (모든 회의)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="tcl-sort-bar" role="group" aria-label="목록 정렬">
|
||||||
|
<span class="tcl-sort-label">정렬</span>
|
||||||
|
<button type="button" class="tcl-sort-btn" id="tclSortDate" data-sort="date">날짜순</button>
|
||||||
|
<button type="button" class="tcl-sort-btn" id="tclSortAlpha" data-sort="alpha">글자순</button>
|
||||||
|
<button type="button" class="tcl-sort-btn tcl-sort-btn-active" id="tclSortCompleted" data-sort="completed">완료여부순</button>
|
||||||
|
</div>
|
||||||
|
<h2 class="tcl-section-title">업무 체크리스트</h2>
|
||||||
|
<p class="tcl-empty" id="tclEmpty" hidden>표시할 항목이 없습니다.</p>
|
||||||
|
<ul class="tcl-list" id="tclList" role="list"></ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="tclEditModal" class="tcl-modal" hidden>
|
||||||
|
<div class="tcl-modal-backdrop" id="tclEditBackdrop"></div>
|
||||||
|
<div class="tcl-modal-panel" role="dialog" aria-modal="true" aria-labelledby="tclEditModalTitle">
|
||||||
|
<h3 id="tclEditModalTitle" class="tcl-modal-title">체크리스트 항목 수정</h3>
|
||||||
|
<label class="tcl-modal-field">
|
||||||
|
<span class="mm-field-label">제목</span>
|
||||||
|
<input type="text" id="tclEditTitle" class="mm-input" maxlength="2000" />
|
||||||
|
</label>
|
||||||
|
<label class="tcl-modal-field">
|
||||||
|
<span class="mm-field-label">내용</span>
|
||||||
|
<textarea id="tclEditDetail" class="mm-textarea" rows="4" maxlength="8000" placeholder="할 일 제목에 대한 상세 내용"></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="tcl-modal-actions">
|
||||||
|
<button type="button" class="btn-ghost" id="tclEditCancel">취소</button>
|
||||||
|
<button type="button" class="top-action" id="tclEditSave">저장</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tclCompleteModal" class="tcl-modal" hidden>
|
||||||
|
<div class="tcl-modal-backdrop" id="tclCompleteBackdrop"></div>
|
||||||
|
<div class="tcl-modal-panel" role="dialog" aria-modal="true" aria-labelledby="tclCompleteModalTitle">
|
||||||
|
<h3 id="tclCompleteModalTitle" class="tcl-modal-title">완료 처리</h3>
|
||||||
|
<p class="tcl-complete-preview" id="tclCompletePreview"></p>
|
||||||
|
<label class="tcl-modal-field">
|
||||||
|
<span class="mm-field-label">처리 내용</span>
|
||||||
|
<textarea
|
||||||
|
id="tclCompleteNote"
|
||||||
|
class="mm-textarea"
|
||||||
|
rows="4"
|
||||||
|
maxlength="8000"
|
||||||
|
placeholder="어떻게 처리했는지 간단히 적어 주세요."
|
||||||
|
></textarea>
|
||||||
|
</label>
|
||||||
|
<div class="tcl-modal-actions">
|
||||||
|
<button type="button" class="btn-ghost" id="tclCompleteCancel">취소</button>
|
||||||
|
<button type="button" class="top-action" id="tclCompleteSave">저장</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% if (hasEmail) { %>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var filterEl = document.getElementById('tclFilter');
|
||||||
|
var meetingPick = document.getElementById('tclMeetingPick');
|
||||||
|
var listEl = document.getElementById('tclList');
|
||||||
|
var emptyEl = document.getElementById('tclEmpty');
|
||||||
|
var editModal = document.getElementById('tclEditModal');
|
||||||
|
var editTitle = document.getElementById('tclEditTitle');
|
||||||
|
var editDetail = document.getElementById('tclEditDetail');
|
||||||
|
var editingId = null;
|
||||||
|
var completeModal = document.getElementById('tclCompleteModal');
|
||||||
|
var completeNoteEl = document.getElementById('tclCompleteNote');
|
||||||
|
var completePreviewEl = document.getElementById('tclCompletePreview');
|
||||||
|
var completePendingId = null;
|
||||||
|
var sortMode = 'completed';
|
||||||
|
|
||||||
|
function api(path, opts) {
|
||||||
|
return fetch(path, Object.assign({ credentials: 'same-origin' }, opts || {})).then(function (r) {
|
||||||
|
return r.json().then(function (j) {
|
||||||
|
if (!r.ok) throw new Error(j.error || r.statusText);
|
||||||
|
return j;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMeetingsDropdown() {
|
||||||
|
var prev = meetingPick.value;
|
||||||
|
return api('/api/meeting-minutes/meetings').then(function (d) {
|
||||||
|
var list = d.meetings || [];
|
||||||
|
meetingPick.innerHTML = '';
|
||||||
|
var optAll = document.createElement('option');
|
||||||
|
optAll.value = '__all__';
|
||||||
|
optAll.textContent = '전체 (모든 회의)';
|
||||||
|
meetingPick.appendChild(optAll);
|
||||||
|
list.forEach(function (m) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = m.id;
|
||||||
|
opt.textContent = (m.title || '제목 없음').slice(0, 80);
|
||||||
|
meetingPick.appendChild(opt);
|
||||||
|
});
|
||||||
|
if (prev) {
|
||||||
|
meetingPick.value = prev;
|
||||||
|
if (meetingPick.value !== prev) meetingPick.value = '__all__';
|
||||||
|
} else {
|
||||||
|
meetingPick.value = '__all__';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(it) {
|
||||||
|
editingId = it.id;
|
||||||
|
editTitle.value = it.title || '';
|
||||||
|
editDetail.value = it.detail || '';
|
||||||
|
editModal.hidden = false;
|
||||||
|
editTitle.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEdit() {
|
||||||
|
editingId = null;
|
||||||
|
editModal.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCompleteModal(it) {
|
||||||
|
completePendingId = it.id;
|
||||||
|
completePreviewEl.textContent = it.title || '';
|
||||||
|
// 이전에 저장한 처리 내용 유지(완료 취소 후 재완료 시에도 동일)
|
||||||
|
completeNoteEl.value = it.completionNote ? String(it.completionNote) : '';
|
||||||
|
completeModal.hidden = false;
|
||||||
|
completeNoteEl.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCompleteModal() {
|
||||||
|
completePendingId = null;
|
||||||
|
completeModal.hidden = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dateSortKey(it) {
|
||||||
|
var s = it.meetingDate && String(it.meetingDate).slice(0, 10);
|
||||||
|
if (!s || !/^\d{4}-\d{2}-\d{2}$/.test(s)) return '\uffff';
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleSortKey(it) {
|
||||||
|
return (it.title || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortItems(items, mode) {
|
||||||
|
var arr = items.slice();
|
||||||
|
if (mode === 'date') {
|
||||||
|
arr.sort(function (a, b) {
|
||||||
|
var d = dateSortKey(a).localeCompare(dateSortKey(b));
|
||||||
|
if (d !== 0) return d;
|
||||||
|
return titleSortKey(a).localeCompare(titleSortKey(b), 'ko');
|
||||||
|
});
|
||||||
|
} else if (mode === 'alpha') {
|
||||||
|
arr.sort(function (a, b) {
|
||||||
|
return titleSortKey(a).localeCompare(titleSortKey(b), 'ko');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
arr.sort(function (a, b) {
|
||||||
|
var da = a.completed ? 0 : 1;
|
||||||
|
var db = b.completed ? 0 : 1;
|
||||||
|
if (da !== db) return da - db;
|
||||||
|
var cmpD = dateSortKey(a).localeCompare(dateSortKey(b));
|
||||||
|
if (cmpD !== 0) return cmpD;
|
||||||
|
return titleSortKey(a).localeCompare(titleSortKey(b), 'ko');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSortButtonsActive() {
|
||||||
|
document.querySelectorAll('.tcl-sort-btn').forEach(function (btn) {
|
||||||
|
var on = btn.getAttribute('data-sort') === sortMode;
|
||||||
|
btn.classList.toggle('tcl-sort-btn-active', on);
|
||||||
|
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderItem(it) {
|
||||||
|
var li = document.createElement('li');
|
||||||
|
li.className = 'tcl-item' + (it.completed ? ' tcl-item-done' : '');
|
||||||
|
li.dataset.id = it.id;
|
||||||
|
|
||||||
|
var row = document.createElement('div');
|
||||||
|
row.className = 'tcl-item-row';
|
||||||
|
|
||||||
|
var cb = document.createElement('input');
|
||||||
|
cb.type = 'checkbox';
|
||||||
|
cb.className = 'tcl-checkbox';
|
||||||
|
cb.checked = !!it.completed;
|
||||||
|
cb.setAttribute('aria-label', '완료');
|
||||||
|
cb.addEventListener('change', function () {
|
||||||
|
if (!cb.checked) {
|
||||||
|
api('/api/task-checklist/items/' + encodeURIComponent(it.id), {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ completed: false })
|
||||||
|
})
|
||||||
|
.then(function () {
|
||||||
|
loadItems();
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
alert(e.message || '저장 실패');
|
||||||
|
cb.checked = !cb.checked;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cb.checked = false;
|
||||||
|
openCompleteModal(it);
|
||||||
|
});
|
||||||
|
|
||||||
|
var body = document.createElement('div');
|
||||||
|
body.className = 'tcl-item-body';
|
||||||
|
|
||||||
|
var hasMeetingMeta = it.meetingId && (it.meetingTitle || it.meetingDate || it.meetingSummary);
|
||||||
|
|
||||||
|
if (hasMeetingMeta) {
|
||||||
|
var wrap = document.createElement('div');
|
||||||
|
wrap.className = 'tcl-tooltip-wrap';
|
||||||
|
wrap.setAttribute('tabindex', '0');
|
||||||
|
var tipId = 'tcl-tip-' + it.id;
|
||||||
|
var titleEl = document.createElement('div');
|
||||||
|
titleEl.className = 'tcl-item-title';
|
||||||
|
titleEl.textContent = it.title || '';
|
||||||
|
wrap.appendChild(titleEl);
|
||||||
|
|
||||||
|
var pop = document.createElement('div');
|
||||||
|
pop.className = 'tcl-popup';
|
||||||
|
pop.id = tipId;
|
||||||
|
pop.setAttribute('role', 'tooltip');
|
||||||
|
wrap.setAttribute('aria-describedby', tipId);
|
||||||
|
|
||||||
|
function addRow(label, text) {
|
||||||
|
if (!text) return;
|
||||||
|
var row = document.createElement('div');
|
||||||
|
row.className = 'tcl-popup-block';
|
||||||
|
var lb = document.createElement('div');
|
||||||
|
lb.className = 'tcl-popup-label';
|
||||||
|
lb.textContent = label;
|
||||||
|
var val = document.createElement('div');
|
||||||
|
val.className = 'tcl-popup-value';
|
||||||
|
val.textContent = text;
|
||||||
|
row.appendChild(lb);
|
||||||
|
row.appendChild(val);
|
||||||
|
pop.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
addRow('회의 제목', it.meetingTitle || '');
|
||||||
|
addRow('회의 일자', it.meetingDate || '—');
|
||||||
|
if (it.meetingSummary && String(it.meetingSummary).trim()) {
|
||||||
|
addRow('회의 요약', String(it.meetingSummary).trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
wrap.appendChild(pop);
|
||||||
|
body.appendChild(wrap);
|
||||||
|
} else {
|
||||||
|
var titlePlain = document.createElement('div');
|
||||||
|
titlePlain.className = 'tcl-item-title';
|
||||||
|
titlePlain.textContent = it.title || '';
|
||||||
|
body.appendChild(titlePlain);
|
||||||
|
}
|
||||||
|
|
||||||
|
var meta = [];
|
||||||
|
if (it.assignee) meta.push('담당: ' + it.assignee);
|
||||||
|
if (it.dueNote) meta.push('기한: ' + it.dueNote);
|
||||||
|
if (it.detail) meta.push(it.detail);
|
||||||
|
if (it.completionNote) {
|
||||||
|
meta.push((it.completed ? '처리 내용: ' : '이전 완료 처리: ') + it.completionNote);
|
||||||
|
}
|
||||||
|
if (meta.length) {
|
||||||
|
var sub = document.createElement('div');
|
||||||
|
sub.className = 'tcl-item-meta';
|
||||||
|
sub.textContent = meta.join(' · ');
|
||||||
|
body.appendChild(sub);
|
||||||
|
}
|
||||||
|
|
||||||
|
var actions = document.createElement('div');
|
||||||
|
actions.className = 'tcl-item-actions';
|
||||||
|
|
||||||
|
var btnEdit = document.createElement('button');
|
||||||
|
btnEdit.type = 'button';
|
||||||
|
btnEdit.className = 'tcl-btn-outline';
|
||||||
|
btnEdit.textContent = '수정';
|
||||||
|
btnEdit.addEventListener('click', function () {
|
||||||
|
openEdit(it);
|
||||||
|
});
|
||||||
|
|
||||||
|
var btnDel = document.createElement('button');
|
||||||
|
btnDel.type = 'button';
|
||||||
|
btnDel.className = 'tcl-btn-outline tcl-btn-outline-danger';
|
||||||
|
btnDel.textContent = '삭제';
|
||||||
|
btnDel.addEventListener('click', function () {
|
||||||
|
if (!confirm('이 항목을 삭제할까요?')) return;
|
||||||
|
api('/api/task-checklist/items/' + encodeURIComponent(it.id), { method: 'DELETE' })
|
||||||
|
.then(loadItems)
|
||||||
|
.catch(function (e) {
|
||||||
|
alert(e.message || '삭제 실패');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
actions.appendChild(btnEdit);
|
||||||
|
actions.appendChild(btnDel);
|
||||||
|
|
||||||
|
row.appendChild(cb);
|
||||||
|
row.appendChild(body);
|
||||||
|
row.appendChild(actions);
|
||||||
|
li.appendChild(row);
|
||||||
|
return li;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadItems() {
|
||||||
|
var v = filterEl.value;
|
||||||
|
var parts = [];
|
||||||
|
if (v === 'open') parts.push('completed=false');
|
||||||
|
else if (v === 'done') parts.push('completed=true');
|
||||||
|
var mid = meetingPick.value;
|
||||||
|
if (mid && mid !== '__all__') parts.push('meetingId=' + encodeURIComponent(mid));
|
||||||
|
var q = parts.length ? '?' + parts.join('&') : '';
|
||||||
|
return api('/api/task-checklist/items' + q).then(function (d) {
|
||||||
|
var items = sortItems(d.items || [], sortMode);
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
emptyEl.hidden = items.length > 0;
|
||||||
|
items.forEach(function (it) {
|
||||||
|
listEl.appendChild(renderItem(it));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function importUrlsForScope() {
|
||||||
|
var mid = meetingPick.value;
|
||||||
|
return {
|
||||||
|
checklist:
|
||||||
|
mid === '__all__'
|
||||||
|
? '/api/task-checklist/import-all'
|
||||||
|
: '/api/task-checklist/import/' + encodeURIComponent(mid),
|
||||||
|
actions:
|
||||||
|
mid === '__all__'
|
||||||
|
? '/api/task-checklist/import-all?mode=actions'
|
||||||
|
: '/api/task-checklist/import/' + encodeURIComponent(mid) + '?mode=actions',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 선택한 회의록 범위에서 체크리스트·액션 가져오기(병렬). 실패는 콘솔만. */
|
||||||
|
function importChecklistAndActions() {
|
||||||
|
var u = importUrlsForScope();
|
||||||
|
return Promise.allSettled([
|
||||||
|
api(u.checklist, { method: 'POST' }),
|
||||||
|
api(u.actions, { method: 'POST' }),
|
||||||
|
]).then(function (results) {
|
||||||
|
if (results[0].status === 'rejected') {
|
||||||
|
console.warn('체크리스트 가져오기:', results[0].reason && results[0].reason.message);
|
||||||
|
}
|
||||||
|
if (results[1].status === 'rejected') {
|
||||||
|
console.warn('액션 가져오기:', results[1].reason && results[1].reason.message);
|
||||||
|
}
|
||||||
|
return loadItems();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
filterEl.addEventListener('change', importChecklistAndActions);
|
||||||
|
meetingPick.addEventListener('change', importChecklistAndActions);
|
||||||
|
|
||||||
|
document.querySelectorAll('.tcl-sort-btn').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var m = btn.getAttribute('data-sort');
|
||||||
|
if (!m || m === sortMode) return;
|
||||||
|
sortMode = m;
|
||||||
|
updateSortButtonsActive();
|
||||||
|
loadItems();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
updateSortButtonsActive();
|
||||||
|
|
||||||
|
document.getElementById('tclEditSave').addEventListener('click', function () {
|
||||||
|
if (!editingId) return;
|
||||||
|
var title = editTitle.value.trim();
|
||||||
|
if (!title) {
|
||||||
|
alert('제목을 입력해 주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
api('/api/task-checklist/items/' + encodeURIComponent(editingId), {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ title: title, detail: editDetail.value.trim() || null })
|
||||||
|
})
|
||||||
|
.then(function () {
|
||||||
|
closeEdit();
|
||||||
|
loadItems();
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
alert(e.message || '저장 실패');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('tclEditCancel').addEventListener('click', closeEdit);
|
||||||
|
document.getElementById('tclEditBackdrop').addEventListener('click', closeEdit);
|
||||||
|
|
||||||
|
document.getElementById('tclCompleteSave').addEventListener('click', function () {
|
||||||
|
if (!completePendingId) return;
|
||||||
|
var note = completeNoteEl.value.trim();
|
||||||
|
if (!note) {
|
||||||
|
alert('처리 내용을 입력해 주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
api('/api/task-checklist/items/' + encodeURIComponent(completePendingId), {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ completed: true, completionNote: note })
|
||||||
|
})
|
||||||
|
.then(function () {
|
||||||
|
closeCompleteModal();
|
||||||
|
loadItems();
|
||||||
|
})
|
||||||
|
.catch(function (e) {
|
||||||
|
alert(e.message || '저장 실패');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.getElementById('tclCompleteCancel').addEventListener('click', closeCompleteModal);
|
||||||
|
document.getElementById('tclCompleteBackdrop').addEventListener('click', closeCompleteModal);
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key !== 'Escape') return;
|
||||||
|
if (!completeModal.hidden) {
|
||||||
|
closeCompleteModal();
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!editModal.hidden) closeEdit();
|
||||||
|
});
|
||||||
|
|
||||||
|
loadMeetingsDropdown().then(importChecklistAndActions);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<% } %>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user