diff --git a/.env.example b/.env.example index f60417b..0bffa73 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,7 @@ OPS_STATE=PROD # DEV: 개발 | PROD: 운영(임직원 이메일 로그인) | S PORT=8030 # HTTP 수신 주소 (기본 0.0.0.0 = 모든 인터페이스, 로컬만이면 127.0.0.1) HOST=0.0.0.0 -ADMIN_TOKEN=xavis-admin +ADMIN_TOKEN=ncue-admin PAGE_SIZE=9 # 1=PostgreSQL 단일 소스, 0=data/lectures.json 사용 ENABLE_POSTGRES=1 @@ -14,9 +14,19 @@ DB_DATABASE=ai_web_platform DB_USERNAME=xavis DB_PASSWORD=wkqltm@@00492 +# PostgreSQL 백업 (scripts/pg-backup.sh · README 「PostgreSQL 백업 및 복원」) +PG_BACKUP_DIR=/home/xavis/workspace/backup/ai_platform +PG_BACKUP_RETENTION_DAYS=30 +PG_BACKUP_SCOPE=all +PG_BACKUP_GLOBALS=1 +PG_BACKUP_SUPERUSER=postgres +PG_BACKUP_SUPERUSER_PASSWORD= + # DB 연결이 없거나 실패하면 회의록 AI는 data/meeting-ai.json에 저장됩니다(로컬 개발에 유용). # 회의 음성 업로드 최대 크기(MB, 기본 300). OpenAI 전사 API는 요청당 약 25MB이므로 초과분은 서버에서 ffmpeg로 분할 후 전사합니다. MEETING_AUDIO_MAX_MB=300 +# prepare-audio → stream-audio JOB 메타(data/meeting-audio-job-meta) TTL(ms). 서버 코드에서 최소·최대 클램프. +# MEETING_AUDIO_JOB_TTL_MS=3600000 ENABLE_PPT_THUMBNAIL=1 ENABLE_VIDEO_THUMBNAIL=1 @@ -30,14 +40,18 @@ THUMBNAIL_EVENT_PAGE_SIZE=50 [인증 메일 정보] BASE_URL=https://ai.xavis.co.kr # 메일 속 링크에 사용 -AUTH_SECRET=xavis-admin # 세션 서명 (ADMIN_TOKEN 폴백 가능) +AUTH_SECRET=ncue-admin # 세션 서명 (ADMIN_TOKEN 폴백 가능) +# OPS(@ncue.net) 이메일 로그인 세션: 기본 0 = 만기 없음(서명상). 브라우저 쿠키는 약 10년마다 갱신 필요할 수 있음 +# OPS_SESSION_TTL_DAYS=0 +# N일 고정 만료(Asia/Seoul 달력)로 바꾸려면: OPS_SESSION_TTL_DAYS=15 +# OPS_SESSION_TZ=Asia/Seoul # 메일 발송 (선택, 없으면 콘솔에 링크만) SMTP_HOST=gw.xavis.co.kr SMTP_PORT=25 SMTP_SECURE=0 -SMTP_USER=dsyoon -SMTP_PASS=!xavis5004 -SMTP_FROM=dsyoon@xavis.co.kr +SMTP_USER=spark_ai +SMTP_PASS=!xavis2026 +SMTP_FROM=spark_ai@ncue.net [채팅 기능 정보] @@ -69,11 +83,15 @@ GENAI_API_KEY= # MEETING_EMPLOYEE_NAMES_FILE=./data/meeting-employee-names.txt # 0 이면 전사→명단 퍼지 매칭 블록 비활성화 # MEETING_NAME_NORMALIZATION=1 -# 관리자 토큰일 때 회의록 AI 등에 쓸 가상 사용자 이메일 (미설정 시 dev@xavis.co.kr) -EETING_DEV_EMAIL=dsyoon@xavis.co.kr -# 선택: Whisper 전사 모델 (기본 whisper-1) -OPENAI_WHISPER_MODEL=gpt-4o-mini-transcribe -MEETING_DEV_EMAIL=dsyoon@xavis.co.kr +# 관리자 토큰일 때 회의록 AI 등에 쓸 가상 사용자 이메일 (미설정 시 dev@ncue.net) +EETING_DEV_EMAIL=spark_ai@ncue.net +# 선택: OpenAI 회의록 음성 전사 모델 (미설정 시 앱 기본 gpt-4o-transcribe) +OPENAI_WHISPER_MODEL=gpt-4o-transcribe +MEETING_DEV_EMAIL=spark_ai@ncue.net [경영성과 대시보드] -DASHBOARD_MENU_ALLOWED_EMAILS=hmjin@xavis.co.kr,dsyoon@xavis.co.kr +DASHBOARD_MENU_ALLOWED_EMAILS=hmjin@ncue.net,dsyoon@ncue.net +# 가이드봇·WM 메뉴 허용 OPS 이메일(쉼표 구분). 관리자(adminMode)는 항상 허용. +# GUIDE_BOT_MENU_ALLOWED_EMAILS=dsyoon@ncue.net +# 업무 체크리스트 AI 카드·API 허용 OPS 이메일(기본 dsyoon@ncue.net, 관리자 예외 없음). +# TASK_CHECKLIST_ALLOWED_EMAILS=dsyoon@ncue.net diff --git a/.env.ncue b/.env.ncue index 4c1f99b..9fb2f27 100644 --- a/.env.ncue +++ b/.env.ncue @@ -4,7 +4,7 @@ OPS_STATE=DEV # DEV: 개발 | PROD: 운영(임직원 이메일 로그인) | SU PORT=8030 # HTTP 수신 주소 (기본 0.0.0.0 = 모든 인터페이스, 로컬만이면 127.0.0.1) HOST=0.0.0.0 -ADMIN_TOKEN=xavis-admin +ADMIN_TOKEN=ncue-admin PAGE_SIZE=9 # 1=PostgreSQL 단일 소스, 0=data/lectures.json 사용 ENABLE_POSTGRES=1 @@ -63,8 +63,8 @@ GENAI_API_KEY= [회의록 기능 정보] -# 관리자 토큰일 때 회의록 AI 등에 쓸 가상 사용자 이메일 (미설정 시 dev@xavis.co.kr) +# 관리자 토큰일 때 회의록 AI 등에 쓸 가상 사용자 이메일 (미설정 시 dev@ncue.net) EETING_DEV_EMAIL=dsyoon@ncue.net -# 선택: Whisper 전사 모델 (기본 whisper-1) -OPENAI_WHISPER_MODEL=gpt-4o-mini-transcribe +# 선택: OpenAI 회의록 음성 전사 모델 (미설정 시 앱 기본 gpt-4o-transcribe와 동일 권장) +OPENAI_WHISPER_MODEL=gpt-4o-transcribe MEETING_DEV_EMAIL=dsyoon@ncue.net diff --git a/.gitignore b/.gitignore index 13a6444..ad4bd5d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ .DS_Store +.cursor/ # 환경 비밀값 — 저장소에는 .env.example 만 공유 *.log @@ -7,14 +8,13 @@ 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 -data/mgmt-perf-last-state.json +# 런타임 데이터 (로컬·서버 전용, Git 제외) — 기본 템플릿은 config/ 참고 +data/ +!data/.gitkeep + +# AI 활용 사례 등 업로드된 정적 리소스 +public/resources/ai-success/ +public/files/ + +# PostgreSQL 논리 백업 산출물 (scripts/pg-backup.sh) +backups/ diff --git a/README.md b/README.md index 5b5b47c..7a57413 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ 유튜브 링크와 PowerPoint(`.pptx`) 강의를 등록하고, 목록에서 클릭해 바로 시청할 수 있는 사내 학습센터 예제입니다. +**소스 저장소(Git):** [https://git.xavis.co.kr/AI_Innovation_Team/ai_platform](https://git.xavis.co.kr/AI_Innovation_Team/ai_platform) + --- ## 접속 방법 @@ -19,7 +21,7 @@ - **학습센터 뷰어** (`/learning`): 강의 검색/필터 + 카드 목록 (일반 사용자) - **관리자** (`/admin`): 강의 등록(YouTube/PPT/웹 링크 등), 썸네일·AI 추가 등 통합 관리 - **강의 상세**: 카드 클릭 시 유튜브 재생 또는 PPT 뷰어 -- **기타 메뉴**: 채팅(`/chat`), AI(`/ai-explore`), **프롬프트 라이브러리**(`/ai-explore/prompts`), **AI 성공 사례**(`/ai-cases`), **대시보드**(`/dashboard`, 성공 사례 아래 메뉴), AX 과제 신청(`/ax-apply`) +- **기타 메뉴**: 회사규정(`/chat` 리다이렉트), WM(`/wm` 리다이렉트), AI(`/ai-explore`), **프롬프트**(`/ai-explore/prompts`, 좌측 전역 메뉴), **AI 활용 사례**(`/ai-cases`), **대시보드**(`/dashboard`, AI 활용 사례 아래 메뉴), AX 과제 신청(`/ax-apply`) - **관리자 이벤트 로그**: `http://localhost:8030/admin/thumbnail-events?token={ADMIN_TOKEN}` ### 접속이 안 될 때 (트러블슈팅) @@ -35,19 +37,26 @@ 5. **바인드 주소** 기본은 모든 네트워크 인터페이스(`0.0.0.0`)에서 수신합니다. 로컬 루프백만 쓰려면 `HOST=127.0.0.1 npm start`를 사용할 수 있습니다. +### 배포 서버: `git pull`이 `data/` 런타임 파일 때문에 막힐 때 + +원격이 `data/`(예: `data/ai-success-stories.json`)를 Git 추적에서 제외하는 쪽으로 바뀌는 동안, 서버에 **수정해 둔** 같은 경로가 있으면 *Your local changes to the following files would be overwritten by merge* 메시지와 함께 `git pull`이 중단될 수 있습니다. + +- **권장:** 프로젝트 루트에서 `bash scripts/safe-git-pull.sh`를 실행합니다. 백업 → 로컬 변경만 Git 관점에서 정리 → `git pull` → 백업 복원 순서로, 서비스에 쓰는 JSON 내용을 유지합니다. `main`이 반영된 뒤에는 해당 파일이 **무시(ignore)** 대상이 되어, 이후에는 보통 `git pull`만으로 갱신됩니다. +- **수동:** 파일을 임시로 복사한 뒤 `git restore data/ai-success-stories.json`(구버전은 `git checkout -- data/ai-success-stories.json`) → `git pull` → 필요 시 다시 복사합니다. + --- ## 핵심 기능 - 학습센터 UI (좌측 메뉴 + 상단 헤더 + 강의 카드 레이아웃) -- **AI 탐색** (`/ai-explore`): 전체 너비 레이아웃, AI 서비스 카드(프롬프트·회의록 등). 검색창에 **「프롬프트」**가 포함된 채 검색(Enter) 시 프롬프트 라이브러리로 이동 -- **회의록 AI** (`/ai-explore/meeting-minutes`): 회의록 생성 시스템 프롬프트는 `lib/meeting-minutes.js`의 `buildMeetingMinutesSystemPrompt`에서 구성하며, **추가 지시**가 형식·섹션(체크리스트 포함 여부, 액션 표기 등)에 우선합니다. DB `meeting_ai_prompts.include_checklist`가 `true`일 때만 회의 체크리스트 강제 블록을 넣고, 기본값은 `false`입니다. `include_checklist`가 `false`이면 생성 직후 `prepareMeetingMinutesForApi`에서 `## 회의 체크리스트` 등 블록을 **후처리로 제거**합니다(모델이 습관적으로 넣은 경우 대비). 기존 DB에 `include_checklist = true`가 남아 있으면 `UPDATE meeting_ai_prompts SET include_checklist = false`로 끄거나, 화면에서 **프롬프트 저장**으로 덮어씁니다. 기본 **추가 지시**는 `views/meeting-minutes.ejs`의 `mmDefaultCustomInstructions`입니다. +- **AI 탐색** (`/ai-explore`): 전체 너비 레이아웃, AI 서비스 카드(회의록·체크리스트 등; **프롬프트 라이브러리**는 좌측 **프롬프트** 메뉴에서 진입) +- **회의록 AI** (`/ai-explore/meeting-minutes`): 텍스트 입력 탭에서 **회의 원문** 옆 **`복사` 버튼**(생성 결과의 회의록·전사와 동일한 연한 녹색 스타일)으로 원문을 클립보드에 복사할 수 있습니다. 회의록 생성 시스템 프롬프트는 `lib/meeting-minutes.js`의 `buildMeetingMinutesSystemPrompt`에서 구성하며, 기본 구조 중 「2) 참석·언급 인원」은 **표 없이 한 줄 쉼표 목록**(원문 근거 인명·직함·조직명)으로 안내합니다. 저장된 사용자 **추가 지시**(비어 두면 해당 블록 없음)만 시스템 프롬프트 우선 순위 블록에 합류합니다. 화면 **음성 업로드** 생성 시 처리 상태를 **1 업로드 / 2 전사 / 3 번역**(전사 결과를 회의록 형식으로 LLM이 정리하는 단계)으로 나누어 진행 표시 및 SSE 패딩·하트비트로 장시간 작업 안내를 제공합니다. 음성 UI는 브라우저에서 두 단계로 동작합니다. **`POST /api/meeting-minutes/prepare-audio`**(multipart 필드: `audio`, `title`, `meetingDate`, `model`, `whisperModel` · `whisperModel` 생략 시 서버 기본 전사는 `gpt-4o-transcribe`, 필요 시 **`OPENAI_WHISPER_MODEL`**) 요청 시 **XHR `upload.onprogress`**로 업로드 바이트를 표시하고, 수신 즉시 **`{ jobId }`** JSON을 돌려줍니다. 이어 같은 세션으로 **`GET /api/meeting-minutes/stream-audio/:jobId`**를 `fetch`(ReadableStream)로 열어 `text/event-stream` 전사·회의록 SSE를 **증분 수신**합니다. **업로드와 SSE가 한 HTTP 요청으로 묶이면**(레거시 `POST …/generate-audio`) 브라우저·역프록시가 응답 본문을 버퍼링해 진행이 멈춘 것처럼 보일 수 있어, 위 분리 플로를 권장합니다. 작업 메타는 `data/meeting-audio-job-meta/`(실행 시 생성, 저장소에서는 `data/`가 무시)에 두며 TTL은 **`MEETING_AUDIO_JOB_TTL_MS`**(미설정 시 약 1시간, 상한·하한은 코드 참고). **로드밸런스 뒤에 Node 인스턴스가 여러 대**이면 이 `data/` 디렉터리(또는 job 메타 저장소)와 업로드 디렉터리가 **인스턴스 간 공유**되어야 같은 `jobId`에 대해 prepare와 stream 요청이 같은 파일·메타를 읽을 수 있습니다. 클라이언트에서는 `consumeSseFromFetch`가 이벤트마다 **`mmAudioStreamHandlers.onSseEvent`**를 호출합니다(`views/meeting-minutes.ejs`). 디버깅 시 같은 페이지 콘솔에서 `getMeetingMinutesAudioDiag()`를 호출하면 `uploadPhase`, `lastSseEvent`, 세그먼트 진행 등을 확인할 수 있습니다. 회의 결과 저장 시 `lib/parse-checklist-from-minutes.js`가 **액션 아이템 마크다운 표**(GFM 권장)와 번호 목록을 규칙으로 읽은 뒤 `extractChecklistStructured`(LLM) 결과와 서버에서 병합하여 업무 체크리스트로 반영합니다(`MEETING_AUTO_CHECKLIST=0` 등으로 끌 수 있음). 동일 처리 경로에서 `prepareMeetingMinutesForApi`가 **헤더·본문 열 수 불일치**(헤더 3열 대 데이터 4열 등)로 깨진 액션 표를 교정하고, 「담당」열에 들어 간 **발언자·저희·우리 팀** 등 통칭·역할 명칭 단독은 규칙 후처리 시 **미정**으로 바꿀 수 있습니다. DB `meeting_ai_prompts.include_checklist`가 `true`일 때만 회의 체크리스트 강제 블록을 넣고, 기본값은 `false`입니다. `include_checklist`가 `false`이면 생성 직후 `prepareMeetingMinutesForApi`에서 `## 회의 체크리스트` 등 블록을 **후처리로 제거**합니다(모델이 습관적으로 넣은 경우 대비). 기존 DB에 `include_checklist = true`가 남아 있으면 `UPDATE meeting_ai_prompts SET include_checklist = false`로 끄거나, 화면에서 **프롬프트 저장**으로 덮어씁니다. - **임직원 인명 정규화**: `data/meeting-employee-names.txt`(또는 `MEETING_EMPLOYEE_NAMES_FILE`)에 성명을 두고, `lib/meeting-employee-names.js`가 전사·원문에서 **이름으로 보이는 토큰만** 명단과 퍼지 매칭해, 회의록 LLM 요청 **사용자 메시지 상단**에 짧은「이번 원문/전사 한정 · 임직원 표기 통일」블록만 붙입니다. 전 직원 명단을 시스템 프롬프트에 넣지 않습니다. 끄려면 `MEETING_NAME_NORMALIZATION=0`. - **대시보드** (`/dashboard`): AI 탐색과 유사한 카드 그리드·검색으로 대시보드를 모아 표시. 첫 카드 **경영성과 대시보드**는 `/dashboard/business-performance`로 연결 -- **경영성과 대시보드** (`/dashboard/business-performance`): **위쪽 대시보드 조회**(Chart.js 인라인), **아래 엑셀 업로드**(`.xlsx`, 매출일보 시트) 순서. 본문은 AI 프롬프트 페이지와 동일하게 **`main.container.container-ai-full`**(전체 너비·좌우 24px)로 맞춤. 대시보드 상단 **연도·분기**로 `mgmt_perf_uploads`에 저장된 해당 기간 **최신 스냅샷**을 불러오며, 쿼리 **`?year=2026&quarter=1`** 또는 폼 조회와 동일. 해당 기간 업로드가 없으면 기본 JSON 샘플을 쓰고 안내 문구를 표시합니다. `public/mgmt-perf/dashboard.css`에 있던 **범용 `.container { background: white }`** 는 앱 페이지에서 `main.container`까지 적용되어 회색 본문이 가려졌으므로 제거하고, 흰 카드는 **`.mgmt-perf-embed .container`** 만 사용합니다. 업로드는 DB(`mgmt_perf_uploads` / `mgmt_perf_snapshots`) 또는 DB 미연결 시 `data/mgmt-perf-last-state.json`에 스냅샷 저장. 최근 업로드 행 **`DELETE /api/mgmt-perf/upload/:id`** 로 삭제(PG는 CASCADE, 파일 전용 모드는 `id=file`). 단독 임베드 페이지는 `/dashboard/business-performance/embed`(본문에 `body.mgmt-perf-standalone`으로 어두운 배경). Express에서 **`/mgmt-perf/*` → `public/mgmt-perf/`** 정적 제공이 등록되어 있어 `dashboard-app.js`·`chart.umd.min.js`(CDN 대신 동봉)가 항상 같은 오리진에서 로드됩니다. 업로드 시 **한글 파일명**은 multer 기본(`defParamCharset` 생략 시 latin1)으로 온 `originalname`을 **`lib/decode-upload-filename.js`**의 `decodeUploadFilename`으로 보정합니다(`decodeURIComponent(escape(...))` 우선, 이어서 `Buffer` latin1→utf8). Busboy에 `defParamCharset: 'utf8'`를 켜면 이중 디코딩으로 깨질 수 있어 두지 않습니다. 탭 전환·차트 렌더는 **ASCII 섹션 id**(`mgmt-sec-sales` 등)와 `state.currentSection`으로 동기화합니다. 리버스 프록시 사용 시 업로드 실패하면 **`client_max_body_size`**(예: 64m)와 **`/api/`·`/mgmt-perf/` → Node** 전달 여부를 확인. 엑셀 집계 치환은 `npm install`로 `xlsx` 설치 후 서버 재시작. -- **경영성과 데이터 확인**: 브라우저에서 `GET /api/mgmt-perf/status`(JSON)로 최근 스냅샷의 `payloadKeys`, `_uploadMeta`(행 수 등)를 확인할 수 있습니다. **현재 구현**은 엑셀에서 **매출일보 행 수·시트명만** `payload._uploadMeta`에 넣고, **차트 수치는 기본 시드 JSON**(`data/mgmt-perf-default-payload.json`)을 씁니다. 5,000행이어도 차트가 엑셀 집계와 일치하려면 **별도 집계·매핑 로직**이 필요합니다. +- **경영성과 대시보드** (`/dashboard/business-performance`): **위쪽 대시보드 조회**(Chart.js 인라인), **아래 엑셀 업로드**(`.xlsx`, 매출일보 시트) 순서. 본문은 AI 프롬프트 페이지와 동일하게 **`main.container.container-ai-full`**(전체 너비·좌우 24px)로 맞춤. 대시보드 상단 **연도·분기**로 `mgmt_perf_uploads`에 저장된 해당 기간 **최신 스냅샷**을 불러오며, 쿼리 **`?year=2026&quarter=1`** 또는 폼 조회와 동일. 해당 기간 업로드가 없으면 기본 JSON 샘플을 쓰고 안내 문구를 표시합니다. `public/mgmt-perf/dashboard.css`에 있던 **범용 `.container { background: white }`** 는 앱 페이지에서 `main.container`까지 적용되어 회색 본문이 가려졌으므로 제거하고, 흰 카드는 **`.mgmt-perf-embed .container`** 만 사용합니다. 업로드는 DB(`mgmt_perf_uploads` / `mgmt_perf_snapshots`) 또는 DB 미연결 시 `data/mgmt-perf-last-state.json`에 스냅샷 저장. 최근 업로드 행 **`DELETE /api/mgmt-perf/upload/:id`** 로 삭제(PG는 CASCADE, 파일 전용 모드는 `id=file`). 단독 임베드 페이지는 `/dashboard/business-performance/embed`(본문에 `body.mgmt-perf-standalone`으로 어두운 배경). Express에서 **`/mgmt-perf/*` → `public/mgmt-perf/`** 정적 제공이 등록되어 있어 `dashboard-app.js`·`chart.umd.min.js`(CDN 대신 동봉)가 항상 같은 오리진에서 로드됩니다. 업로드 시 **한글 파일명**은 multer 기본(`defParamCharset` 생략 시 latin1)으로 온 `originalname`을 **`lib/decode-upload-filename.js`**의 `decodeUploadFilename`으로 보정합니다(`decodeURIComponent(escape(...))` 우선, 이어서 `Buffer` latin1→utf8). Busboy에 `defParamCharset: 'utf8'`를 켜면 이중 디코딩으로 깨질 수 있어 두지 않습니다. 탭 전환·차트 렌더는 **ASCII 섹션 id**(`mgmt-sec-sales` 등)와 `state.currentSection`으로 동기화합니다. 리버스 프록시로 **Apache2**를 쓰는 경우 업로드 실패·413이면 VirtualHost **`LimitRequestBody`**(예: 바이트 단위로 `deploy/apache-ai.ncue.net-ssl.conf.example`와 맞출 것)와 **`/api/`·`/mgmt-perf/` → Node** `ProxyPass` 전달 여부를 확인. (Nginx를 쓰는 환경이면 **`client_max_body_size`** 등을 해당 서버 설정에 맞춥니다.) 엑셀 집계 치환은 `npm install`로 `xlsx` 설치 후 서버 재시작. +- **경영성과 데이터 확인**: 브라우저에서 `GET /api/mgmt-perf/status`(JSON)로 최근 스냅샷의 `payloadKeys`, `_uploadMeta`(행 수 등)를 확인할 수 있습니다. **현재 구현**은 엑셀에서 **매출일보 행 수·시트명만** `payload._uploadMeta`에 넣고, **차트 수치는 기본 시드 JSON**(`config/mgmt-perf-default-payload.json`)을 씁니다. 5,000행이어도 차트가 엑셀 집계와 일치하려면 **별도 집계·매핑 로직**이 필요합니다. - **대시보드 메뉴 접근**: `.env`의 `DASHBOARD_MENU_ALLOWED_EMAILS`에 **쉼표로 구분한 OPS 로그인 이메일**만 좌측 **대시보드** 메뉴·`/dashboard`·경영성과 API가 보입니다. 목록이 비어 있으면 누구에게도 표시되지 않습니다. 로컬(DEV)에서 관리자 토큰만 쓰는 경우 `DASHBOARD_MENU_DEV_USE_MEETING_EMAIL=1`과 `MEETING_DEV_EMAIL`을 허용 목록과 맞추면 대조됩니다. -- **프롬프트 라이브러리** (`/ai-explore/prompts`): 업무별 기본 프롬프트 카드 선택·미리보기·클립보드 복사 (`data/company-prompts.json`). **좌측 메뉴(채팅·AI·AI 성공 사례·학습센터·AX 과제 신청)는 관리자 여부와 관계없이 접근 가능**(강의 삭제·관리자 대시보드 등 일부 기능은 관리자 모드에서만) +- **프롬프트 라이브러리** (`/ai-explore/prompts`): 좌측 **프롬프트** 메뉴로 진입 · 업무별 기본 프롬프트 카드 선택·미리보기·클립보드 복사 (`config/company-prompts.json`) · **공유하기** 탭의 본문은 **원문 보기** / **마크다운 보기** 토글(클라이언트 `marked` + `DOMPurify`) · **워크플로** 탭(①~④ 입력·초안 합치기·AI로 다듬기). **좌측 메뉴(채팅·AI·프롬프트·AI 활용 사례·학습센터·AX 과제 신청)는 관리자 여부와 관계없이 접근 가능**(강의 삭제·관리자 대시보드 등 일부 기능은 관리자 모드에서만) - 검색/필터/페이지네이션 - 검색어(`q`) 기반 제목/설명/태그 필터 - 타입(`YouTube`, `PPT`) 필터 @@ -75,7 +84,7 @@ - **관리자 인증·바로가기(UI)** - **좌측 메뉴 하단 `관리자` / `관리자 off`**: 운영(OPS)에서 **이메일 인증**으로 로그인한 임직원에게도 표시됩니다. **`관리자`**는 모달에서 `ADMIN_TOKEN`을 입력해 검증한 뒤 `/admin`으로 이동합니다(`POST /api/admin/validate-token`). **`관리자 off`**는 관리자 쿠키를 지우고 학습센터 목록(`/learning`)으로 돌아갑니다(`GET /admin/logout`). 이메일 미로그인 환경에서도 동일합니다. **이미 관리자 세션**이면 중복이므로 `관리자` 항목은 숨기고, **사용자 현황관리** → 구분선 → **관리자 off** →(OPS일 때 구분선)→ **로그아웃** 순으로 표시됩니다. - **학습센터** (`/learning`): 관리자 쿠키가 있을 때 상단 오른쪽 **학습 등록**으로 통합 관리 화면(`/admin`)에 들어갈 수 있습니다. 이메일(OPS) 로그인과 동시에 있어도 버튼이 숨겨지지 않습니다. - - **AI 성공 사례** (`/ai-cases`): 관리자일 때 상단 **사례 등록·관리**로 편집 화면(`/ai-cases/write`)에 진입합니다(동일하게 OPS 로그인 중에도 표시). + - **AI 활용 사례** (`/ai-cases`): 관리자일 때 상단 **사례 등록·관리**로 편집 화면(`/ai-cases/write`)에 진입합니다(동일하게 OPS 로그인 중에도 표시). --- @@ -88,36 +97,44 @@ ai_platform/ ├─ .env # 환경 변수 (실제 값, .gitignore 대상) ├─ .env.example # 환경 변수 예시 템플릿 ├─ db/ -│ └─ schema.sql # PostgreSQL 스키마 (강의·회의록·경영성과 업로드 등, 기동 시 자동 적용) +│ └─ schema.sql # PostgreSQL 스키마 (강의·회의록·경영성과·프롬프트 라이브러리 좋아요/공유 등, 기동 시 자동 적용) ├─ scripts/ -│ └─ apply-schema.js # 수동 스키마 적용 스크립트 (npm run db:schema) +│ ├─ apply-schema.js # 수동 스키마 적용 (npm run db:schema) +│ ├─ pg-backup.sh # PostgreSQL 논리 백업 (cron·npm run db:backup) +│ ├─ pg-restore.sh # 백업 복원 (npm run db:restore -- …) +│ └─ lib/load-env.sh # 셸 스크립트용 .env 로더 ├─ public/ -│ └─ styles.css # 전역 스타일 +│ └─ styles.css # 전역 스타일(회의록 마크다운 뷰 `.mm-minutes-rendered` 표 셀 테두리 등) ├─ views/ │ ├─ partials/ │ │ ├─ nav.ejs # 좌측 공통 네비게이션(하단 관리자·토큰 모달 연동) │ │ └─ admin-token-modal.ejs # 관리자 토큰 입력 모달(`openAdminTokenModal`) │ ├─ learning-viewer.ejs # 학습센터 뷰어 (일반 사용자) │ ├─ learning-admin.ejs # 학습센터 관리 (업로드·삭제·썸네일) -│ ├─ chat.ejs # 채팅 -│ ├─ ai-explore.ejs # AI 탐색 (전체 너비, 프롬프트 카드·검색) +│ ├─ chat.ejs # OpenAI 기반 인앱 채팅 UI +│ ├─ ai-explore.ejs # AI 탐색 (전체 너비, 회의록 등 카드·검색; 프롬프트는 좌측 메뉴) │ ├─ dashboard.ejs # 대시보드 목록(카드·검색) │ ├─ dashboard-business-performance.ejs # 경영성과(업로드 + 인라인 Chart.js 조회) │ ├─ mgmt_perf_embed.ejs # 경영성과 차트 단독 페이지(직접 열람·임베드용) │ ├─ partials/mgmt_perf_dashboard_container.ejs │ ├─ ai-prompts.ejs # 프롬프트 라이브러리 (카드·미리보기·복사) -│ ├─ ai-cases.ejs # AI 성공 사례 목록(카드) -│ ├─ ai-case-detail.ejs # AI 성공 사례 상세(마크다운 또는 PDF·페이지 이미지, 1·2·3단 보기) -│ ├─ ai-cases-write.ejs # AI 성공 사례 관리자 등록·편집 +│ ├─ ai-cases.ejs # AI 활용 사례 목록(카드·`data/ai-success-stories.json` + PG `ai_use_case_submissions` 병합) +│ ├─ ai-use-case-submission-detail.ejs # 일반 제출 상세(`GET /ai-cases/submit/:uuid`) +│ ├─ ai-case-detail.ejs # AI 활용 사례 상세(마크다운 또는 PDF·페이지 이미지, 1·2·3단 보기) +│ ├─ ai-cases-compose.ejs # AI 활용 사례 일반 글쓰기(TOAST UI WYSIWYG 한 칸, 1~4번 STAR는 편집기 안 템플릿, 제출 시 4필드로 분리·`sanitize-use-case-body` 정제) +│ ├─ 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 # 썸네일 작업 이벤트 로그 +├─ config/ +│ ├─ company-prompts.json # AI 프롬프트 라이브러리 템플릿 (Git 추적) +│ └─ mgmt-perf-default-payload.json # 경영성과 대시보드 기본 차트 시드 (Git 추적) +├─ data/ # 런타임 JSON·업로드 메타 (Git 제외, `.gitkeep`만 추적) +│ ├─ lectures.json # 강의 DB (PG 미사용 시) +│ ├─ ai-success-stories.json / ai-success-stories/ +│ ├─ thumbnail-jobs.json # 썸네일 큐 스냅샷 +│ └─ thumbnail-events.json # 썸네일 이벤트 로그 ├─ uploads/ # 업로드된 .pptx 파일 저장 │ └─ thumbnails/ # 생성된 썸네일 이미지 └─ resources/ @@ -130,6 +147,7 @@ ai_platform/ |------|------| | **서버** | `server.js`가 Express 앱, 라우트, Multer 업로드, 썸네일 백그라운드 워커, PostgreSQL 연동을 모두 담당 | | **데이터 저장소** | `ENABLE_POSTGRES=1`이면 PostgreSQL `lectures` 테이블이 단일 소스, `0`이면 `data/lectures.json` 사용 | +| **Git과 데이터** | `data/`, `public/resources/ai-success/`, `public/files/`는 **.gitignore**로 제외(런타임·업로드 전용). `config/`의 `company-prompts.json`·`mgmt-perf-default-payload.json`만 기본 템플릿으로 추적 | | **썸네일** | 비동기 큐 처리, `data/thumbnail-jobs.json`으로 영속화, `data/thumbnail-events.json`에 이벤트 기록 | | **뷰** | EJS 템플릿으로 메인/유튜브/PPT/관리자 페이지 렌더링 | @@ -235,8 +253,8 @@ PPT 썸네일·슬라이드 이미지는 **macOS**에서는 `qlmanage`가 우선 ``` 6. **방화벽·리버스 프록시** - - 외부에 직접 `8030`을 열지 않고 **Nginx/Apache**로 TLS·프록시하는 구성이 일반적입니다. - - **Apache2** 예시(모듈·VirtualHost)는 [docs/DEPLOYMENT-xavis.ncue.net.md](docs/DEPLOYMENT-xavis.ncue.net.md)를 참고하세요. + - 외부에 직접 `8030`을 열지 않고 **Apache2**(또는 동급 리버스 프록시)로 TLS·역방향 프록시하는 구성이 일반적입니다. + - **Apache2** 예시(모듈·VirtualHost·`LimitRequestBody`·`ProxyTimeout`)는 [docs/DEPLOYMENT-xavis.ncue.net.md](docs/DEPLOYMENT-xavis.ncue.net.md) 및 `deploy/apache-ai.ncue.net-ssl.conf.example`를 참고하세요. - `ufw` 사용 시: `sudo ufw allow 80,443/tcp` 후 앱은 로컬에서만 듣게 하거나, 프록시 뒤에 둡니다. 7. **데이터 디렉터리 권한** @@ -248,7 +266,7 @@ PPT 썸네일·슬라이드 이미지는 **macOS**에서는 `qlmanage`가 우선 - 터미널 또는 `pm2 logs ai_platform`에서 **에러 없이** `Server started` 로그 확인 - 브라우저로 메인·`/learning`·관리자(토큰)까지 동작 확인 -- PostgreSQL 사용 시 `npm run db:schema`는 **최초 1회**(또는 스키마 변경 시); 백업·마이그레이션 정책은 운영 환경에 맞게 별도 수립 +- PostgreSQL 사용 시 `npm run db:schema`는 **최초 1회**(또는 스키마 변경 시); **일일 DB 백업**은 아래 「PostgreSQL 백업 및 복원」의 cron 절을 따릅니다. --- @@ -394,7 +412,7 @@ Windows에서는 작업 관리자에서 `Node.js` 프로세스를 종료하거 ADMIN_TOKEN=my-secret PAGE_SIZE=12 npm start ``` -- `ADMIN_TOKEN` 미지정 시 기본값: `xavis-admin` +- `ADMIN_TOKEN` 미지정 시 기본값: `ncue-admin` - `PAGE_SIZE` 미지정 시 기본값: `8` - `.env` 파일이 있으면 `dotenv`로 자동 로드 - 브라우저에서는 좌측 메뉴 하단 **관리자** → 모달에 위 토큰을 입력해 세션 쿠키를 발급받는 방식으로 `/admin`에 진입할 수 있습니다(임직원 이메일 로그인 여부와 동일한 흐름). @@ -406,16 +424,256 @@ ADMIN_TOKEN=my-secret PAGE_SIZE=12 npm start - 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을 반환하고, 채팅 화면 상단에 안내 배너가 표시됩니다. +## PostgreSQL 백업 및 복원 + +운영 서버에서 **매일 cron**으로 PostgreSQL **서버의 연결 가능 DB 전체**를 백업하고, 장애·실수 삭제 시 복원하는 절차입니다. +기본(`PG_BACKUP_SCOPE=all`)은 템플릿 DB를 제외한 **모든 데이터베이스**를 DB마다 `{dbname}.dump` 파일로 저장합니다. 역할(계정)은 `PG_BACKUP_GLOBALS=1`과 슈퍼유저 비밀번호가 있으면 `00_globals.sql`로 함께 백업합니다. + +### 개요 + +| 항목 | 내용 | +|------|------| +| 백업 범위(기본) | PostgreSQL 서버의 **모든 DB** (`template0`/`template1` 제외) | +| 백업 방식 | DB별 `pg_dump -Fc` + (선택) `pg_dumpall --globals-only` | +| 스크립트 | `scripts/pg-backup.sh`, `scripts/pg-restore.sh` | +| npm | `npm run db:backup`, `npm run db:restore -- <옵션> <파일 또는 디렉터리>` | +| 설정 | 프로젝트 루트 `.env`의 `DB_*`, `PG_BACKUP_*` | +| 저장 위치(기본) | `/home/xavis/workspace/backup/ai_platform/YYYYMMDD/` | +| 최신 심볼릭 링크 | `/home/xavis/workspace/backup/ai_platform/latest` → 가장 최근 백업 디렉터리 | + +백업 파일에는 **각 DB의 테이블·데이터·인덱스·시퀀스**와(옵션) **역할·권한**이 포함됩니다. Git·공개 저장소에 올리지 마세요. + +### 사전 요구사항 + +1. **PostgreSQL 클라이언트 도구** (`pg_dump`, `pg_restore`, `psql`)가 서버 PATH에 있어야 합니다. + - Ubuntu/Debian: `sudo apt install -y postgresql-client` + - macOS: `brew install libpq` 후 PATH에 `pg_dump` 추가 +2. **`.env`**에 `DB_HOST`, `DB_PORT`, `DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD`가 앱과 동일하게 설정되어 있어야 합니다. +3. **전체 DB 백업**을 위해서는 `.env`에 `PG_BACKUP_SUPERUSER_PASSWORD`(postgres 등 슈퍼유저)를 설정하는 것을 권장합니다. 없으면 `DB_USERNAME`이 접근 가능한 DB만 백업됩니다. +4. **디스크 여유**: (모든 DB 크기 합) × 보관 일수(30) × 1.2 이상 권장. + +### 환경 변수 (`.env`) + +| 변수 | 필수 | 기본값 | 설명 | +|------|------|--------|------| +| `DB_HOST` | O | — | PostgreSQL 호스트 | +| `DB_PORT` | — | `5432` | 포트 | +| `DB_DATABASE` | O | — | 앱 DB 이름(복원·단일 백업 시 참고). `PG_BACKUP_SCOPE=single`일 때만 백업 대상 | +| `DB_USERNAME` | O | — | 앱 DB 계정 | +| `DB_PASSWORD` | O | — | 앱 DB 비밀번호 | +| `PG_BACKUP_DIR` | — | `/home/xavis/workspace/backup/ai_platform` | 백업 루트 디렉터리 | +| `PG_BACKUP_RETENTION_DAYS` | — | `30` | 이 일수보다 **오래된 날짜 폴더**(YYYYMMDD) 삭제. `0`이면 삭제 안 함 | +| `PG_BACKUP_SCOPE` | — | `all` | `all`=서버의 모든 DB, `single`=`DB_DATABASE`만 | +| `PG_BACKUP_GLOBALS` | — | `1` | `1`이면 역할(계정)·권한 SQL(`00_globals.sql`)도 함께 백업 | +| `PG_BACKUP_SUPERUSER` | — | `postgres` | globals 백업·전체 DB dump 시 사용 | +| `PG_BACKUP_SUPERUSER_PASSWORD` | 전체 백업 권장 | — | 슈퍼유저 비밀번호 | + +**운영 서버 `.env` 예시 (백업 관련만):** + +```env +PG_BACKUP_DIR=/home/xavis/workspace/backup/ai_platform +PG_BACKUP_RETENTION_DAYS=30 +PG_BACKUP_SCOPE=all +PG_BACKUP_GLOBALS=1 +PG_BACKUP_SUPERUSER=postgres +PG_BACKUP_SUPERUSER_PASSWORD=실제_슈퍼유저_비밀번호 +``` + +`PG_BACKUP_SCOPE=single`로 바꾸면 예전처럼 `DB_DATABASE` 하나만 백업합니다. + +### 수동 백업 + +프로젝트 루트에서: + +```bash +npm run db:backup +# 또는 +bash scripts/pg-backup.sh +``` + +성공 시 예시 출력: + +```text +[2026-05-25T02:00:01+09:00] pg-backup start → /home/xavis/workspace/backup/ai_platform/20260525 (scope=all, retention 30 days) +[2026-05-25T02:00:02+09:00] globals saved: .../20260525/00_globals.sql +[2026-05-25T02:00:03+09:00] dumping database: ai_web_platform +[2026-05-25T02:00:15+09:00] dump saved: .../20260525/ai_web_platform.dump (12345678 bytes) +[2026-05-25T02:00:16+09:00] dumping database: postgres +[2026-05-25T02:00:17+09:00] dump saved: .../20260525/postgres.dump (456789 bytes) +[2026-05-25T02:00:17+09:00] retention: pruned 1 dir(s); keeping backups from 20260425 onward (30 days) +[2026-05-25T02:00:17+09:00] pg-backup done: 2 database(s), latest → .../latest +``` + +생성 파일: + +```text +/home/xavis/workspace/backup/ai_platform/ +├─ 20260525/ +│ ├─ 00_globals.sql # 역할·권한 (PG_BACKUP_GLOBALS=1) +│ ├─ 00_manifest.txt # 백업 DB 목록 +│ ├─ ai_web_platform.dump +│ ├─ postgres.dump +│ └─ … # 서버의 다른 DB마다 *.dump +└─ latest -> 20260525/ # 심볼릭 링크 (최대 30일치 폴더 유지) +``` + +### 매일 cron 등록 (운영 서버) + +1. 백업 디렉터리 생성 및 권한: + +```bash +sudo mkdir -p /home/xavis/workspace/backup/ai_platform +sudo chown "$(whoami):$(whoami)" /home/xavis/workspace/backup/ai_platform +# PM2/앱과 같은 사용자로 cron을 돌릴 경우 그 사용자로 chown +``` + +2. `.env`에 `PG_BACKUP_DIR=/home/xavis/workspace/backup/ai_platform` 설정. + +3. crontab 편집: + +```bash +crontab -e +``` + +4. **매일 새벽 2시** (프로젝트 경로는 배포 위치에 맞게 수정): + +```cron +0 2 * * * cd /home/xavis/workspace/ai_platform && /usr/bin/bash scripts/pg-backup.sh >> /var/log/pg-backup.log 2>&1 +``` + +5. 로그 로테이션(선택): `/var/log/pg-backup.log`가 커지지 않도록 `logrotate` 또는 주기적 truncate. + +**cron 점검** + +```bash +# 다음 실행 예약 확인 +crontab -l + +# 수동 1회 실행 후 로그 확인 +cd /home/xavis/workspace/ai_platform && bash scripts/pg-backup.sh +tail -20 /var/log/pg-backup.log + +# 최신 dump 목록 확인 +ls -lh /home/xavis/workspace/backup/ai_platform/latest/ +cat /home/xavis/workspace/backup/ai_platform/latest/00_manifest.txt +``` + +### 복원 + +`.dump` 파일은 **custom format** 전용입니다. plain SQL(`.sql`)이 아니면 `psql -f`가 아니라 `pg_restore`를 사용합니다. + +#### 1) 검증용 복원 — DB 하나 + +```bash +npm run db:restore -- --test --confirm /home/xavis/workspace/backup/ai_platform/latest/ai_web_platform.dump +``` + +#### 1-2) 검증용 복원 — 서버 DB 전체 + +```bash +npm run db:restore -- --all --globals --test --confirm /home/xavis/workspace/backup/ai_platform/latest/ +``` + +각 DB는 `{dbname}_restore_test` 이름으로 복원됩니다(예: `ai_web_platform_restore_test`). + +확인: + +```bash +psql -h "$DB_HOST" -U "$DB_USERNAME" -d ai_web_platform_restore_test -c "\dt" +psql -h "$DB_HOST" -U "$DB_USERNAME" -d ai_web_platform_restore_test \ + -c "SELECT relname, n_live_tup FROM pg_stat_user_tables ORDER BY n_live_tup DESC LIMIT 10;" +``` + +검증 후 테스트 DB 삭제: + +```bash +psql -h "$DB_HOST" -U postgres -c 'DROP DATABASE ai_web_platform_restore_test;' +``` + +(`DROP`은 슈퍼유저 또는 DB owner 권한 필요) + +#### 2) 운영 DB 복원 — DB 하나 (주의) + +```bash +pm2 stop webplatform # 앱 이름에 맞게 + +bash scripts/pg-restore.sh --clean --confirm /home/xavis/workspace/backup/ai_platform/latest/ai_web_platform.dump + +pm2 start webplatform +``` + +#### 2-2) 운영 DB 복원 — 서버 DB 전체 (주의) + +```bash +pm2 stop webplatform + +bash scripts/pg-restore.sh --all --globals --clean --confirm /home/xavis/workspace/backup/ai_platform/latest/ + +pm2 start webplatform +``` + +- `--clean`: 기존 객체를 삭제한 뒤 dump 내용으로 재생성 (`--if-exists` 포함). +- 운영 DB에 `--clean` 없이 복원하면 중복 객체 오류가 날 수 있습니다. +- `--confirm` 없이 실행하면 대화형 확인 프롬프트가 뜹니다. + +역할(계정)까지 함께 백업해 둔 경우(`00_globals.sql`): + +```bash +bash scripts/pg-restore.sh --globals --clean --confirm /home/xavis/workspace/backup/ai_platform/.../ai_web_platform.dump +``` + +`.env`에 `PG_BACKUP_SUPERUSER`, `PG_BACKUP_SUPERUSER_PASSWORD` 필요. + +#### 3) 완전히 새 DB로 복원 (드물게) + +DB 자체를 drop/create한 뒤 복원할 때는 DBA·슈퍼유저로: + +```bash +psql -h "$DB_HOST" -U postgres -c "DROP DATABASE IF EXISTS ai_web_platform;" +psql -h "$DB_HOST" -U postgres -c "CREATE DATABASE ai_web_platform OWNER xavis;" +bash scripts/pg-restore.sh --confirm /home/xavis/workspace/backup/ai_platform/.../ai_web_platform.dump +``` + +### 백업·복원 체크리스트 (월 1회 권장) + +| Check | 방법 | +|-------|------| +| cron 정상 실행 | `/var/log/pg-backup.log`에 전일 `pg-backup done` 기록 | +| dump 파일 크기 | `00_manifest.txt`·각 `.dump`가 0 byte가 아닌지 확인 | +| 복원 테스트 | `--all --test` 또는 단일 `--test`로 `_restore_test` DB 확인 | +| 디스크 | `df -h $PG_BACKUP_DIR` | +| 보관 정책 | `PG_BACKUP_RETENTION_DAYS`에 맞게 오래된 폴더 삭제되는지 | + +### 보안 + +- dump 파일은 **개인정보·업무 데이터 전체**를 담습니다. 권한 `600`/`700` 디렉터리, Git·Slack·메일 첨부 금지. +- 가능하면 백업 저장소를 **앱 서버와 분리**(NAS, 객체 스토리지)하고 주기적으로 오프사이트 복사. +- DB는 공인 IP 직접 노출 대신 **방화벽·VPN·SSH 터널** 사용(별도 보안 가이드 참고). + +### 문제 해결 + +| 증상 | 조치 | +|------|------| +| `pg_dump: command not found` | `postgresql-client` 설치 | +| `password authentication failed` | `.env`의 `DB_USERNAME`/`DB_PASSWORD` 확인 | +| `could not connect to server` | `DB_HOST`·방화벽·PostgreSQL `listen_addresses` | +| cron은 도는데 파일 없음 | cron의 `cd` 경로, `.env` 존재, 로그 파일 stderr 확인 | +| `pg_restore: error: role "xxx" does not exist` | `--no-owner --role=DB_USERNAME`(스크립트 기본) 또는 `--globals`로 역할 먼저 복원 | +| 복원 후 앱 오류 | `pm2 restart`, `npm run db:schema`는 **복원 dump에 스키마 포함** 시 보통 불필요 | + +### `data/` JSON과의 관계 + +`ENABLE_POSTGRES=1`이면 **강의·회의록·경영성과 등 핵심 데이터는 PostgreSQL**이 단일 소스입니다. +`data/ai-success-stories.json`, 업로드 파일(`uploads/`, `public/resources/`) 등 **파일 기반 데이터는 DB 백업에 포함되지 않습니다**. 운영 백업 정책에 파일 디렉터리 rsync/tar 백업을 **별도** 두는 것을 권장합니다. + +--- + +- 인앱 채팅 화면은 `/ai-explore/chat`에서 제공하며, 백엔드는 `POST /api/chat/stream` SSE로 응답을 생성합니다. +- 기본 제공 모델 별칭은 `gpt-5.4`, `gpt-5-mini`이며, 실제 API 모델 ID는 `.env`의 `OPENAI_MODEL_DEFAULT`, `OPENAI_MODEL_MINI`로 매핑합니다. +- `OPENAI_WEB_SEARCH=1`(기본)일 때 OpenAI Responses API의 웹 검색 도구를 사용해 출처 링크를 함께 내려줍니다. +- 필수 환경 변수는 `OPENAI_API_KEY`입니다. ### PPT 썸네일 및 슬라이드 이미지 생성 제어(선택) @@ -445,7 +703,7 @@ ENABLE_PPT_THUMBNAIL=1 npm start ```env PORT=8030 HOST=0.0.0.0 -ADMIN_TOKEN=xavis-admin +ADMIN_TOKEN=ncue-admin PAGE_SIZE=8 ENABLE_POSTGRES=1 DB_HOST=your-db-host @@ -466,6 +724,8 @@ THUMBNAIL_EVENT_PAGE_SIZE=50 OPENAI_API_KEY= # OPENAI_MODEL_DEFAULT=gpt-4o # OPENAI_MODEL_MINI=gpt-4o-mini +# OPS 이메일(@ncue.net) 로그인 세션: `OPS_SESSION_TTL_DAYS` 미설정·0·never = 만기 없음(이전 기본 15일). N(양의 정수)이면 로그인일+N일(서울 달력) 후 만료. +# OPS_SESSION_TTL_DAYS=0 ``` --- @@ -511,18 +771,38 @@ OPENAI_API_KEY= | `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 성공 사례 관리(관리자 토큰 필요) | +| `GET /chat` | 회사규정 NotebookLM 페이지로 리다이렉트 | +| `GET /wm` | WM NotebookLM 페이지로 리다이렉트 | +| `GET /ai-explore/chat` | OpenAI 연동 채팅 화면 | +| `GET /ai-explore/fscan` | AI 플랫폼 내 FSCAN 조사각 선정도우미 화면(내재화 iframe) | +| `GET /ai-explore` | AI 탐색 (회의록·체크리스트 등 카드; 프롬프트 라이브러리는 `/ai-explore/prompts` 또는 좌측 **프롬프트** 메뉴) | +| `GET /ai-explore/prompts` | 프롬프트 라이브러리 — 공식 템플릿(`config/company-prompts.json`)+팀 공유·좋아요(PostgreSQL `prompt_community_entries`·`prompt_likes`) · `?id=` 딥링크 | +| `GET /ai-cases` | AI 활용 사례 목록(검색·태그 필터) — **관리자 등록**(`ai-success-stories` 메타) + **일반 제출**(`ai_use_case_submissions`) 병합 | +| `GET /ai-cases/submit/:id` | 일반 제출 사례 상세(UUID) — **본인·관리자**에게 상단 **수정하기** 링크(`GET /ai-cases/compose?edit=`) | +| `GET /ai-cases/:slug` | AI 활용 사례 상세(메타·슬러그) | +| `GET /ai-cases/write` | AI 활용 사례 관리(관리자 토큰 필요) | | `POST/PUT/DELETE /api/ai-success-stories` | 사례 CRUD(관리자) · 본문은 `data/ai-success-stories/*.md` | +| `GET /ai-cases/compose?edit=:uuid` | 일반 제출 **수정** 화면(제목·4섹션·태그·첨부·썸네일) — 권한 없으면 403 | +| `POST /api/ai-use-case-submissions` | AI 활용 사례 **일반 제출(글쓰기)** — OPS 이메일 로그인·PostgreSQL, `ai_use_case_submissions` — 본문 4필드는 WYSIWYG HTML이며 `sanitize-html`로 안전한 태그만 저장, 글자 수는 **보이는 텍스트(태그 제외)** 합산 | +| `PUT /api/ai-use-case-submissions/:id` | 일반 제출 **갱신**(제출자 이메일과 로그인 이메일 일치 또는 관리자 모드). **썸네일**은 새 파일이면 교체, `removeThumbnail=1`이면 삭제(새 파일·삭제 둘 다 없으면 유지). **첨부**는 기존을 `removeAttachmentPaths`(JSON 경로 배열)로 뺀 뒤, 새로 업로드한 파일이 뒤에 **추가**(합계 최대 10개) | | `GET /ax-apply` | AX 과제 신청 | | `GET /lectures/:id` | 강의 상세 뷰어 (유튜브/PPT) | +**AI 활용 사례 목록이 줄어든 경우:** 카드 목록은 **`data/ai-success-stories.json`**만 본다. `git pull` 등으로 JSON만 예전 버전이 되면 `.md`·PDF는 서버에 남아 있어도 **1건만 보일 수 있다.** 서버에서 `data/ai-success-stories/*.md`는 있는데 JSON에 없으면 `node scripts/merge-orphan-ai-success-stories.js`로 고아 `.md`를 메타에 다시 붙인 뒤, 관리자 화면에서 **부서·저자·PDF 경로** 등을 점검한다. JSON·본문·`public/resources/ai-success/` PDF는 **배포 서버에서 백업**하거나 저장소에 포함하는 정책을 권장한다. + ### API +- `POST /api/prompts/likes/toggle` (OPS·DEV 이메일) + JSON `{ "kind": "official" | "community", "id": "" }` — 좋아요 토글. `{ ok, liked, likeCount }` + +- `POST /api/prompts/community` (OPS·DEV 이메일) + `multipart/form-data` — `title`, `body`, `description?`, `tag?`, `promptFiles`(0~5), `resultFiles`(0~5) · **작성자**는 요청한 OPS 로그인 이메일이 `prompt_community_entries.author_email`에 저장됨 · DB `prompt_attachments` / `result_sample_attachments` JSON · 파일당 20MB → `{ ok, id, createdAt, promptFiles, resultFiles }` · 라이브러리 카드에는 이메일의 `@` 앞부분(예: `spark_ai@ncue.net` → `spark_ai`)이 표시됨 + +- `DELETE /api/prompts/community/:id` (OPS·DEV 이메일) — 본인 글만 소프트 삭제 + +- `POST /api/prompts/polish-workflow` (OPS·DEV 이메일) + JSON `{ draft }` — `OPENAI_API_KEY`로 워크플로 초안을 다듬은 프롬프트 문자열 `{ text }` 반환 + - `GET /api/chat/config` 채팅용 OpenAI 키 설정 여부 `{ configured: boolean }` (키 문자열은 노출하지 않음) @@ -563,8 +843,12 @@ OPENAI_API_KEY= ## 최근 업데이트 (요약) -- **AI 탐색** (`/ai-explore`): 메인 콘텐츠를 뷰포트 전체 너비로 사용, **프롬프트** 서비스 카드를 첫 번째에 배치, 검색어에 **「프롬프트」**가 포함된 채 검색(Enter) 시 프롬프트 라이브러리로 이동. **좌측 전체 메뉴는 관리자 여부와 관계없이 동일하게 표시·접근 가능** -- **프롬프트 라이브러리** (`/ai-explore/prompts`): 시나리오 카드·미리보기·복사 UI, 템플릿 데이터는 `data/company-prompts.json`에서 로드 +- **PostgreSQL 백업·복원**: `scripts/pg-backup.sh` / `scripts/pg-restore.sh`, `npm run db:backup` · README 「PostgreSQL 백업 및 복원」에 cron·복원 절차 문서화 +- **채팅** (`/ai-explore/chat`): OpenAI SSE 기반 인앱 채팅으로 응답/출처를 표시 +- **AI 탐색 필터** (`/ai-explore`): 검색창 아래 타입 라디오 필터(일반/XScan/FScan)로 카드 목록 필터링 +- **FSCAN 도구 내재화**: `public/resources/fscan/fscan-selector-v1.html` 정적 페이지를 `/ai-explore/fscan`에서 iframe으로 감싸 AI 플랫폼 레이아웃(좌측 메뉴/뒤로 이동) 안에서 사용 +- **AI 탐색** (`/ai-explore`): 메인 콘텐츠를 뷰포트 전체 너비로 사용(회의록·체크리스트 등). **프롬프트 라이브러리**는 AI 목록이 아닌 좌측 **프롬프트** 메뉴(`/ai-explore/prompts`)로 진입. **좌측 전체 메뉴는 관리자 여부와 관계없이 동일하게 표시·접근 가능** +- **프롬프트 라이브러리** (`/ai-explore/prompts`): 시나리오 카드·미리보기·복사 UI, 템플릿 데이터는 `config/company-prompts.json`에서 로드 - **서버 수신 주소**: 기본 `HOST=0.0.0.0`(로컬 `localhost` 접속에 사용). 필요 시 `HOST=127.0.0.1`로 제한 가능 - **문서**: `localhost` 접속 불가 시 확인할 항목을 아래 「접속이 안 될 때 (트러블슈팅)」 절에 정리 diff --git a/data/company-prompts.json b/config/company-prompts.json similarity index 100% rename from data/company-prompts.json rename to config/company-prompts.json diff --git a/data/mgmt-perf-default-payload.json b/config/mgmt-perf-default-payload.json similarity index 100% rename from data/mgmt-perf-default-payload.json rename to config/mgmt-perf-default-payload.json diff --git a/data/ai-success-stories.json b/data/ai-success-stories.json deleted file mode 100644 index f4b5b1a..0000000 --- a/data/ai-success-stories.json +++ /dev/null @@ -1,22 +0,0 @@ -[ - { - "id": "story-1774520941288", - "slug": "jojung-sook-hr-claude-cowork", - "title": "인사총무팀 AI 적용 사례", - "excerpt": "인사총무팀 조정숙 과장의 AI 업무 혁신 이야기. Claude Cowork 도입, 법인세 인사자료 8시간→4시간, 데이터 오류율 감소.", - "author": "조정숙", - "department": "인사총무팀", - "publishedAt": "2026-03-25", - "tags": [ - "인사총무", - "Claude Cowork", - "법인세", - "OT", - "엑셀" - ], - "contentFile": "jojung-sook-hr-claude-cowork.md", - "pdfUrl": "/public/resources/ai-success/1774520936441-c8e17615-dad1-4a08-9a26-f6303b666058.pdf", - "createdAt": "2026-03-26T10:29:01.288Z", - "updatedAt": "2026-03-26T10:29:01.288Z" - } -] \ No newline at end of file diff --git a/data/ai-success-stories/jojung-sook-hr-claude-cowork.md b/data/ai-success-stories/jojung-sook-hr-claude-cowork.md deleted file mode 100644 index e69de29..0000000 diff --git a/data/ax-assignments.json b/data/ax-assignments.json deleted file mode 100644 index 8396adf..0000000 --- a/data/ax-assignments.json +++ /dev/null @@ -1,86 +0,0 @@ -[ - { - "id": "2371e4d4-ac7b-4ae1-bde2-1f9ff67e9d15", - "department": "테스트부서", - "name": "홍길동", - "employeeId": "", - "position": "", - "phone": "", - "email": "", - "workProcessDescription": "테스트", - "painPoint": "테스트", - "currentTimeSpent": "30분", - "errorRateBefore": "5%", - "collaborationDepts": "", - "reasonToSolve": "테스트", - "aiExpectation": "테스트", - "outputType": "테스트", - "automationLevel": "", - "dataReadiness": "", - "dataLocation": "", - "personalInfo": "", - "dataQuality": "", - "dataCount": "", - "dataTypes": null, - "timeReduction": "", - "errorReduction": "", - "volumeIncrease": "", - "costReduction": "", - "responseTime": "", - "otherMetrics": "", - "annualSavings": "", - "laborReplacement": "", - "revenueIncrease": "", - "otherEffects": "", - "qualitativeEffects": null, - "techStack": null, - "risks": null, - "riskDetail": "", - "participationPledge": true, - "status": "신청", - "createdAt": "2026-03-18T05:11:13.793Z", - "updatedAt": "2026-03-18T05:11:13.793Z" - }, - { - "id": "a9d59801-1f84-4c0d-ad7e-e8db28fcd003", - "department": "테스트부서", - "name": "홍길동", - "employeeId": "", - "position": "", - "phone": "", - "email": "", - "workProcessDescription": "테스트", - "painPoint": "테스트", - "currentTimeSpent": "30분", - "errorRateBefore": "5%", - "collaborationDepts": "", - "reasonToSolve": "테스트", - "aiExpectation": "테스트", - "outputType": "테스트", - "automationLevel": "", - "dataReadiness": "", - "dataLocation": "", - "personalInfo": "", - "dataQuality": "", - "dataCount": "", - "dataTypes": null, - "timeReduction": "", - "errorReduction": "", - "volumeIncrease": "", - "costReduction": "", - "responseTime": "", - "otherMetrics": "", - "annualSavings": "", - "laborReplacement": "", - "revenueIncrease": "", - "otherEffects": "", - "qualitativeEffects": null, - "techStack": null, - "risks": null, - "riskDetail": "", - "participationPledge": true, - "status": "신청", - "createdAt": "2026-03-18T05:05:04.467Z", - "updatedAt": "2026-03-18T05:05:04.467Z" - } -] \ No newline at end of file diff --git a/data/check-queue.html b/data/check-queue.html deleted file mode 100644 index 1e33e1f..0000000 --- a/data/check-queue.html +++ /dev/null @@ -1,255 +0,0 @@ - - - - - - 학습센터 - - - -
- - -
-
-

학습센터

- 강의 등록하기 -
- -
-
-

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

-

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

-
- -
-

강의 검색/필터

-
- - - - - - - -
- - 초기화 -
-
-
- -
-

관리자 모드

- - -

관리자 모드 활성화됨

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

유튜브 강의 등록

-
- - - - - -
-
- -
-

PowerPoint 강의 등록

-
- - - - - -
-
- -
-
-

등록된 강의

- 총 2건 -
- - -
- - - - - -
- - -
-
-
-
- - diff --git a/data/lectures.json b/data/lectures.json deleted file mode 100644 index 1237beb..0000000 --- a/data/lectures.json +++ /dev/null @@ -1,36 +0,0 @@ -[ - { - "id": "4a937c1e-98cb-402f-abd4-497120212974", - "type": "ppt", - "title": "claude cowork 가이드", - "description": "초기 샘플 PPT 강의", - "fileName": "1773454993461-578d68bd-9486-4ee2-bcea-46d038571a2e.pptx", - "originalName": "claude cowork 가이드.pptx", - "createdAt": "2026-03-14T02:23:13.463Z", - "tags": [], - "previewTitle": "제목 없음", - "slideCount": 12, - "thumbnailUrl": null, - "thumbnailStatus": "failed", - "thumbnailRetryCount": 3, - "thumbnailError": "썸네일 생성 도구 실행 실패 또는 미설치", - "thumbnailUpdatedAt": "2026-03-14T02:55:55.126Z" - }, - { - "id": "51ee515c-fff0-4156-97ac-09ec0b412345", - "type": "ppt", - "title": "생성형AI, LLM, 에이전틱 AI 이해하기", - "description": "초기 샘플 PPT 강의", - "fileName": "1773454993463-3f6f09f5-0d48-4f0f-9a15-2b61adce1ba9.pptx", - "originalName": "생성형AI, LLM, 에이전틱 AI 이해하기.pptx", - "createdAt": "2026-03-14T02:23:13.466Z", - "tags": [], - "previewTitle": "제목 없음", - "slideCount": 1, - "thumbnailUrl": null, - "thumbnailStatus": "failed", - "thumbnailRetryCount": 3, - "thumbnailError": "썸네일 생성 도구 실행 실패 또는 미설치", - "thumbnailUpdatedAt": "2026-03-14T02:55:55.141Z" - } -] \ No newline at end of file diff --git a/data/meeting-employee-names.txt b/data/meeting-employee-names.txt deleted file mode 100644 index e687543..0000000 --- a/data/meeting-employee-names.txt +++ /dev/null @@ -1,7 +0,0 @@ -# 임직원 성명 (한 줄에 한 이름, 또는 한 줄에 쉼표로 여러 이름) -# 스프레드시트에서 붙여 넣어도 됩니다. # 으로 시작하는 줄은 무시됩니다. -# 회의록 생성 시 전사에 등장한 토큰만 이 목록과 대조해「표기 통일」블록이 LLM에 전달됩니다. - -김창열 -이소은 -현아 diff --git a/data/thumbnail-jobs.json b/data/thumbnail-jobs.json deleted file mode 100644 index 0637a08..0000000 --- a/data/thumbnail-jobs.json +++ /dev/null @@ -1 +0,0 @@ -[] \ No newline at end of file diff --git a/db/schema.sql b/db/schema.sql index e16b1a2..d51baca 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -106,11 +106,11 @@ BEFORE UPDATE ON ax_assignments FOR EACH ROW EXECUTE FUNCTION set_ax_assignments_updated_at(); --- OPS 이메일(@xavis.co.kr) 매직 링크 인증 — 이벤트 감사 로그 +-- OPS 이메일(@ncue.net) 매직 링크 인증 — 이벤트 감사 로그 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')), + event_type VARCHAR(40) NOT NULL CHECK (event_type IN ('magic_link_requested', 'login_success', 'logout', 'sessions_revoked')), ip_address VARCHAR(45), user_agent TEXT, return_to TEXT, @@ -126,11 +126,18 @@ 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 + login_count INTEGER NOT NULL DEFAULT 0, + sessions_revoked_at TIMESTAMPTZ ); CREATE INDEX IF NOT EXISTS idx_ops_email_users_last_login ON ops_email_users (last_login_at DESC); +ALTER TABLE ops_email_users ADD COLUMN IF NOT EXISTS sessions_revoked_at TIMESTAMPTZ; + +ALTER TABLE ops_email_auth_events DROP CONSTRAINT IF EXISTS ops_email_auth_events_event_type_check; +ALTER TABLE ops_email_auth_events ADD CONSTRAINT ops_email_auth_events_event_type_check + CHECK (event_type IN ('magic_link_requested', 'login_success', 'logout', 'sessions_revoked')); + -- 회의록 AI: 이메일(OPS 세션) 기반 사용자·프롬프트·회의 저장 CREATE TABLE IF NOT EXISTS meeting_ai_users ( email VARCHAR(320) PRIMARY KEY, @@ -295,3 +302,124 @@ CREATE TABLE IF NOT EXISTS mgmt_perf_snapshots ( ); CREATE INDEX IF NOT EXISTS idx_mgmt_perf_snapshots_upload ON mgmt_perf_snapshots (upload_id); + +-- AI 활용 사례: 일반 사용자 제출(글쓰기) — 본문 4섹션·썸네일·첨부, 제출자 이메일·시각 +CREATE TABLE IF NOT EXISTS ai_use_case_submissions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + submitter_email VARCHAR(320) NOT NULL, + title TEXT NOT NULL, + situation TEXT NOT NULL DEFAULT '', + task_goal TEXT NOT NULL DEFAULT '', + action_taken TEXT NOT NULL DEFAULT '', + result_outcome TEXT NOT NULL DEFAULT '', + tags TEXT[] NOT NULL DEFAULT '{}', + thumbnail_relative_path TEXT, + thumbnail_files JSONB NOT NULL DEFAULT '[]', + attachment_files JSONB NOT NULL DEFAULT '[]', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_ai_use_case_submissions_created ON ai_use_case_submissions (created_at DESC); +CREATE INDEX IF NOT EXISTS idx_ai_use_case_submissions_submitter ON ai_use_case_submissions (submitter_email, created_at DESC); + +CREATE OR REPLACE FUNCTION set_ai_use_case_submissions_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_ai_use_case_submissions_updated_at ON ai_use_case_submissions; +CREATE TRIGGER trg_ai_use_case_submissions_updated_at +BEFORE UPDATE ON ai_use_case_submissions +FOR EACH ROW +EXECUTE FUNCTION set_ai_use_case_submissions_updated_at(); + +-- 썸네일 다중 업로드(최대 5): 기존 DB 마이그레이션 +ALTER TABLE ai_use_case_submissions + ADD COLUMN IF NOT EXISTS thumbnail_files JSONB NOT NULL DEFAULT '[]'; + +UPDATE ai_use_case_submissions +SET thumbnail_files = jsonb_build_array( + jsonb_build_object( + 'originalName', COALESCE(NULLIF(regexp_replace(thumbnail_relative_path, '^.+/', ''), ''), 'thumbnail'), + 'relativePath', thumbnail_relative_path + ) +) +WHERE thumbnail_relative_path IS NOT NULL + AND btrim(thumbnail_relative_path) <> '' + AND (thumbnail_files IS NULL OR thumbnail_files = '[]'::jsonb); + +-- AI 활용 사례 제출: 페이지뷰 조회수(제출자 본인 로그인 조회 제외) · 좋아요 +ALTER TABLE ai_use_case_submissions + ADD COLUMN IF NOT EXISTS view_count INTEGER NOT NULL DEFAULT 0; + +-- (legacy) 초기 unique 조회 추적용 — 현재 집계는 view_count 페이지뷰만 사용 +CREATE TABLE IF NOT EXISTS ai_use_case_views ( + submission_id UUID NOT NULL REFERENCES ai_use_case_submissions(id) ON DELETE CASCADE, + viewer_email VARCHAR(320) NOT NULL, + viewed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (submission_id, viewer_email) +); + +CREATE INDEX IF NOT EXISTS idx_ai_use_case_views_submission ON ai_use_case_views (submission_id); + +CREATE TABLE IF NOT EXISTS ai_use_case_submission_likes ( + submission_id UUID NOT NULL REFERENCES ai_use_case_submissions(id) ON DELETE CASCADE, + user_email VARCHAR(320) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (submission_id, user_email) +); + +CREATE INDEX IF NOT EXISTS idx_ai_use_case_submission_likes_submission + ON ai_use_case_submission_likes (submission_id); + +-- 프롬프트 라이브러리: 공식 템플릿(company-prompts.json id) + 커뮤니티 공유 글에 대한 좋아요 +CREATE TABLE IF NOT EXISTS prompt_likes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_email VARCHAR(320) NOT NULL, + target_kind VARCHAR(20) NOT NULL CHECK (target_kind IN ('official', 'community')), + target_id VARCHAR(200) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_prompt_likes_user_target UNIQUE (user_email, target_kind, target_id) +); + +CREATE INDEX IF NOT EXISTS idx_prompt_likes_target ON prompt_likes (target_kind, target_id); +CREATE INDEX IF NOT EXISTS idx_prompt_likes_user ON prompt_likes (user_email, created_at DESC); + +-- 임직원이 공유한 프롬프트(라이브러리 커뮤니티) — author_email: POST 시 세션(OPS) 이메일, UI는 @ 앞 로컬부로 표시 +CREATE TABLE IF NOT EXISTS prompt_community_entries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + author_email VARCHAR(320) NOT NULL, + title VARCHAR(500) NOT NULL, + description TEXT NOT NULL DEFAULT '', + body TEXT NOT NULL, + tag VARCHAR(100) NOT NULL DEFAULT '기타', + is_published BOOLEAN NOT NULL DEFAULT true, + is_deleted BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_prompt_community_author ON prompt_community_entries (author_email, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_prompt_community_created ON prompt_community_entries (created_at DESC) WHERE is_deleted = false AND is_published = true; + +CREATE OR REPLACE FUNCTION set_prompt_community_entries_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_prompt_community_entries_updated_at ON prompt_community_entries; +CREATE TRIGGER trg_prompt_community_entries_updated_at +BEFORE UPDATE ON prompt_community_entries +FOR EACH ROW +EXECUTE FUNCTION set_prompt_community_entries_updated_at(); + +-- 팀 공유 프롬프트: 프롬프트 관련·결과(샘플) 첨부(JSON 배열, 경로는 /uploads/…) +ALTER TABLE prompt_community_entries ADD COLUMN IF NOT EXISTS prompt_attachments JSONB NOT NULL DEFAULT '[]'; +ALTER TABLE prompt_community_entries ADD COLUMN IF NOT EXISTS result_sample_attachments JSONB NOT NULL DEFAULT '[]'; diff --git a/deploy/apache-ai.ncue.net-ssl.conf.example b/deploy/apache-ai.ncue.net-ssl.conf.example index fa18d21..c796fdf 100644 --- a/deploy/apache-ai.ncue.net-ssl.conf.example +++ b/deploy/apache-ai.ncue.net-ssl.conf.example @@ -1,33 +1,19 @@ -# ============================================================================= -# ai.ncue.net — Apache 2.4+ SSL → Node webplatform (단일 백엔드) -# ============================================================================= -# 필요 모듈: ssl, proxy, proxy_http, headers -# sudo a2enmod ssl proxy proxy_http headers -# -# Node listen 주소·포트는 .env 의 HOST / PORT 와 맞출 것 (기본 127.0.0.1:8030). -# 아래 127.0.0.1:8030 은 예시이며, 실제 프로세스 포트로 바꾸면 됩니다. -# -# 왜 경로별 ProxyPass 를 나누지 않나? -# - 이 저장소의 server.js 가 /chat, /learning, /admin, /ai-explore 등 모든 경로를 한 프로세스에서 처리합니다. -# - 동일 포트(8030)로 여러 줄 나누면 유지보수만 불필요하게 복잡해지고, 8018/8030 혼선만 생깁니다. -# - 한 줄 ProxyPass / 로 충분합니다. -# -# Apache 2.4.31+ : ProxyAddHeaders On 이 X-Forwarded-* 를 백엔드에 넘깁니다(클라이언트 IP 추적 등). -# 그보다 오래된 Apache 면 ProxyAddHeaders 줄을 제거하고, 필요 시 X-Forwarded-For 수동 설정을 검토하세요. -# ============================================================================= - - ServerName ai.ncue.net + ServerName ai.xavis.co.kr ProxyPreserveHost On # 대용량 업로드(회의 음성 등, 앱 MEETING_AUDIO_MAX_MB 와 맞출 것) LimitRequestBody 314572800 + # 전사·LLM 등 장시간 응답. 회의록 음성 SSE는 수 분 이상 걸릴 수 있음. TimeOut 600 ProxyTimeout 600 + # 앱 응답의 X-Accel-Buffering: no 는 Nginx 전용. Apache mod_proxy_http 는 무시하므로, + # 스트림이 끊기면 TimeOut/ProxyTimeout 과 VirtualHost 레벨 mod_deflate(전역 압축 시 text/event-stream) 여부를 점검. + # HTTPS 프록시임을 Node/앱에 알림 (쿠키 Secure, 리다이렉트 URL 등) RequestHeader set X-Forwarded-Proto "https" @@ -36,14 +22,15 @@ ProxyAddHeaders On - ProxyPass / http://127.0.0.1:8030/ - ProxyPassReverse / http://127.0.0.1:8030/ + # 나머지 모든 요청 — Node.js Express (매직링크 인증 포함) + 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 + ErrorLog ${APACHE_LOG_DIR}/ai.xavis.co.kr_ssl_error.log + CustomLog ${APACHE_LOG_DIR}/ai.xavis.co.kr_ssl_access.log combined @@ -52,7 +39,7 @@ - SSLCertificateFile /etc/letsencrypt/live/ncue.net-0001/fullchain.pem - SSLCertificateKeyFile /etc/letsencrypt/live/ncue.net-0001/privkey.pem + SSLCertificateFile /etc/letsencrypt/live/ai.xavis.co.kr/fullchain.pem + SSLCertificateKeyFile /etc/letsencrypt/live/ai.xavis.co.kr/privkey.pem diff --git a/docs/DEPLOYMENT-xavis.ncue.net.md b/docs/DEPLOYMENT-xavis.ncue.net.md index e536584..5d19c65 100644 --- a/docs/DEPLOYMENT-xavis.ncue.net.md +++ b/docs/DEPLOYMENT-xavis.ncue.net.md @@ -44,7 +44,18 @@ cd /var/www/webplatform npm install --production ``` -### 2.4 환경 변수 설정 +### 2.4 코드 갱신(`git pull`) 직후 (503·앱 기동 실패 시) + +`package.json`에 새 npm 패키지가 추가되면, **pull만 하고 `npm install`을 하지 않은 상태로 PM2(또는 node)를 재시작**하면 `Cannot find module`로 프로세스가 바로 종료됩니다. Apache는 업스트림이 없다고 **503 Service Unavailable**을 반환할 수 있습니다. + +```bash +cd /var/www/webplatform # 실제 배포 경로 +git pull +npm install --production +pm2 restart webplatform # PM2로 실행 중인 앱 이름에 맞게 +``` + +### 2.5 환경 변수 설정 ```bash # .env 파일이 없다면 .env.example 복사 @@ -237,3 +248,11 @@ sudo ufw reload ### 썸네일/슬라이드 이미지 미생성 - LibreOffice, poppler-utils 설치 여부 확인 - PM2 로그 확인: `pm2 logs webplatform` + +### 업로드 413 Request Entity Too Large (Apache) +- `VirtualHost`에 **`LimitRequestBody`**가 작게 잡혀 있지 않은지 확인합니다. 회의 음성·엑셀 등은 바이트 단위로 크게 필요합니다 (`deploy/apache-ai.ncue.net-ssl.conf.example`의 예시 참고). + +### 회의록 음성(SSE)·진행 막대가 한동안 안 움직임 +- 노드 처리 전체가 길므로 같은 VirtualHost에서 **`TimeOut`** / **`ProxyTimeout`** 이 충분한지 확인합니다. +- 브라우저로 직접 `http://127.0.0.1:8030`(또는 앱 바인 포트)에 붙였을 때는 정상인데 Apache 경유만 이상하면, 프록시 앞단 **`mod_deflate`** 등 출력 필터가 `text/event-stream` 응답을 버퍼링하지 않는지 점검합니다. +- 앱이 붙이는 **`X-Accel-Buffering: no`** 는 **Nginx 전용**이며, **Apache에서는 동작하지 않습니다**(무시됨). diff --git a/docs/XAVIS-AI-Platform-메뉴안내-일반사용자.pptx b/docs/XAVIS-AI-Platform-메뉴안내-일반사용자.pptx new file mode 100644 index 0000000..250a69f Binary files /dev/null and b/docs/XAVIS-AI-Platform-메뉴안내-일반사용자.pptx differ diff --git a/docs/ai-platform-menu-guide-for-ppt.md b/docs/ai-platform-menu-guide-for-ppt.md new file mode 100644 index 0000000..e0a4429 --- /dev/null +++ b/docs/ai-platform-menu-guide-for-ppt.md @@ -0,0 +1,424 @@ +# XAVIS AI Platform — 메뉴별 PPT 안내 자료 + +> **용도**: https://ai.xavis.co.kr/ 정식 오픈 교육·안내 PPT 제작용 +> **PPT 파일**: [`XAVIS-AI-Platform-메뉴안내.pptx`](./XAVIS-AI-Platform-메뉴안내.pptx) (화면 캡처 포함) +> **캡처 원본**: [`ppt-screenshots/`](./ppt-screenshots/) +> **재생성**: `OPS_STATE=PROD npm start` 실행 후 `node scripts/capture-menu-screenshots.mjs` → `python scripts/build-menu-guide-ppt.py` +> **구성**: 메뉴 1개 = 슬라이드 1~2장 권장 (제목 슬라이드 + 상세 슬라이드) +> **접속**: `@ncue.net` 이메일 매직 링크 인증 후 이용 + +--- + +## 슬라이드 0. 표지 / 서비스 개요 + +**제목**: XAVIS AI Platform 메뉴 안내 + +**한 줄 소개** +- 사내 AI 도구·학습·과제·활용 사례를 한곳에서 이용하는 임직원 전용 포털 + +**접속** +- URL: https://ai.xavis.co.kr/ +- 최초 1회: 회사 메일 입력 → 인증 메일 링크 클릭 (15분 유효) + +**좌측 메뉴 구성** +| 순서 | 메뉴 | 한 줄 설명 | +|------|------|------------| +| 1 | 회사규정 | 사내 규정·지침 AI 검색(NotebookLM) | +| 2 | WM | WM 관련 자료 AI 검색(NotebookLM) | +| 3 | AI | 회의록·채팅·FScan 등 AI 서비스 허브 | +| 4 | 프롬프트 | 업무용 프롬프트 템플릿·팀 공유 | +| 5 | 학습센터 | YouTube·PPT·동영상 강의 시청 | +| 6 | 과제신청 | AX(AI Transformation) 과제 신청 | +| 7 | AI 활용 사례 | 현장 AI 도입 사례 열람·작성 | +| 8 | 대시보드 | 허용 계정 전용 경영 지표(선택) | + +--- + +## 슬라이드 1. 서비스 접속 (로그인) + +**경로**: `/login` (미인증 시 자동 이동) + +**화면 설명** +- XAVIS·AI Platform 로고와 「서비스 접속」 카드형 로그인 화면 +- `@ncue.net` 이메일만 허용 + +**이용 방법** +1. 회사 이메일 입력 +2. **[검증]** 클릭 → 메일함 확인 +3. **[인증 완료하기]** 링크 클릭 → 학습센터 등 원하는 화면으로 이동 + +**활용 팁** +- 인증 링크가 만료되면 같은 화면에서 다시 [검증]을 누르면 됩니다. +- 다른 PC·브라우저에서는 다시 인증이 필요할 수 있습니다. + +**유의사항** +- 개인 메일·외부 도메인은 사용할 수 없습니다. +- 로그아웃: 좌측 하단 **로그아웃** + +--- + +## 슬라이드 2. 회사규정 + +**경로**: 좌측 **회사규정** → Google NotebookLM (새 탭) + +**화면 설명** +- AI Platform 레이아웃을 벗어나 **NotebookLM** 회사규정 노트북으로 이동 +- 업로드된 사내 규정·지침 자료를 AI가 검색·요약·질의응답 + +**주요 기능** +- 규정 문구 검색, 요약, 관련 조항 질문 +- 회의·업무 전 규정 확인용 Q&A + +**활용 팁** +- 「○○ 절차가 뭐야?」「휴가 신청 규정 알려줘」처럼 **구체적 질문**이 정확도가 높습니다. +- AI 답변은 **원문 규정·다우오피스 공식 문서**와 반드시 대조하세요. + +**대안** +- NotebookLM 접속이 어려우면 다우오피스 전자결재·규정 게시판을 우선 확인 + +--- + +## 슬라이드 3. WM + +**경로**: 좌측 **WM** → Google NotebookLM WM 노트북 (새 탭) + +**화면 설명** +- WM(Work Management 등 사내 WM 체계) 관련 자료를 AI로 검색·질의 + +**주요 기능** +- WM 프로세스·용어·가이드 질의 +- 업무 방법·템플릿 관련 Q&A + +**활용 팁** +- WM 업무를 처음 맡았을 때 용어·절차를 빠르게 파악하는 용도로 적합합니다. + +**유의사항** +- 회사규정과 동일하게, AI 답변은 공식 매뉴얼·담당 부서 확인을 병행하세요. + +--- + +## 슬라이드 4. AI (메인 허브) + +**경로**: `/ai-explore` + +**화면 설명** +- 「지식 시험이나 지식 보강… 맞춤형 AI를 탐색」 안내 문구 +- 검색창 + 타입 필터(전체 / 일반 / XScan / FScan) +- AI 서비스 **카드 그리드** — 클릭하면 각 도구로 이동 + +**포함 서비스 (카드)** +| 카드 | 이동 경로 | 분류 | +|------|-----------|------| +| 회의록 AI | `/ai-explore/meeting-minutes` | 일반 | +| (회의록 기반) 업무 체크리스트 AI | `/ai-explore/task-checklist` | 일반 | +| 일반 채팅 | `/ai-explore/chat` | 일반 | +| FSCAN 조사각 선정도우미 | `/ai-explore/fscan` | FScan | + +**활용 팁** +- 검색창에 「회의」「체크」「채팅」「FSCAN」 등 키워드로 카드 필터링 +- AI 관련 업무는 **이 화면을 시작점**으로 두면 메뉴를 빠르게 찾을 수 있습니다. + +--- + +## 슬라이드 5. 회의록 AI + +**경로**: AI → **회의록 AI** (`/ai-explore/meeting-minutes`) + +**화면 구성** +- **좌측**: 내 회의록 목록(저장·불러오기) +- **상단(접기 가능)**: 출력 형식(프롬프트) — 추가 지시 입력·저장 +- **탭 ① 텍스트 입력**: 회의 원문 붙여넣기 → 회의록 생성 +- **탭 ② 음성 파일**: mp3·m4a·wav 등 업로드 → 전사 → 회의록 생성 +- **하단**: 생성 결과(회의록·전사 기록) 편집·저장 + +**음성 처리 3단계** +1. **업로드** — 최대 300MB +2. **전사** — OpenAI 음성→텍스트 (gpt-4o-transcribe 등) +3. **번역(정리)** — 전사문을 사내 회의록 형식으로 LLM 작성 + +**텍스트 입력 흐름** +1. 제목·날짜 입력 +2. 회의 원문(메모·채팅 로그·타 도구 전사문) 붙여넣기 +3. 모델 선택(gpt-5-mini / gpt-5.4) → **회의록 생성** +4. 결과 수정 후 **저장** → 업무 체크리스트 AI와 연동 + +**회의록 기본 구조(시스템 프롬프트)** +- 회의 개요, 참석·언급 인원, 논의 요약, 결정 사항, 액션 아이템(표), 후속 체크리스트 등 + +**활용 팁 · 대안 (실무 시나리오)** + +| 상황 | 추천 | +|------|------| +| 회의 **음성 파일**이 있을 때 | **AI Platform 회의록 AI** 음성 탭 — 사내 계정·저장·체크리스트 연동 | +| Claude·ChatGPT에 음성 올려 회의록 작성 | 개인 구독·용량에 따라 가능. **저장·팀 공유·체크리스트 연동**은 AI Platform이 유리 | +| 전사만 필요할 때 | 회의록 AI 음성 탭 사용, 또는 **클로버노트(Clova Note)** 등 전사 앱 → 전사문을 **텍스트 입력** 탭에 붙여넣기 | +| 짧은 메모·채팅 로그만 있을 때 | **텍스트 입력** 탭이 가장 빠름 | +| 회의록 형식을 팀마다 맞추고 싶을 때 | 「출력 형식(프롬프트)」의 **추가 지시**에 팀 규칙 입력 후 저장 | + +**유의사항** +- 생성 결과는 반드시 **검토·수정** 후 저장·배포 +- 음성·전사에 **개인정보·대외비**가 포함되면 보안 규정 준수 +- 긴 회의는 처리에 **수 분** 걸릴 수 있음(진행 표시 확인) + +--- + +## 슬라이드 6. (회의록 기반) 업무 체크리스트 AI + +**경로**: AI → **업무 체크리스트 AI** (`/ai-explore/task-checklist`) + +**화면 설명** +- 회의록 AI에서 **저장한 회의**의 액션 아이템·체크리스트를 한 화면에서 관리 +- 진행상황(전체/진행 중/완료), 회의록별 필터, 정렬(날짜·글자·완료여부) + +**주요 기능** +- 회의록에서 자동 추출된 **할 일** 목록 표시 +- 항목 수정·완료 처리(처리 내용 메모) +- 회의 제목·일자·요약 툴팁으로 출처 확인 + +**이용 방법** +1. 먼저 **회의록 AI**에서 회의록 생성·**저장** +2. 체크리스트 AI에서 회의록 범위 선택 → 항목 자동 반영 +3. 완료 시 체크 + 처리 내용 기록 + +**활용 팁** +- 다우오피스·엑셀 할 일 목록과 병행할 때, **회의에서 나온 액션**은 이 화면에서 추적 +- 회의록을 수정·재저장하면 체크리스트 연동 내용이 갱신될 수 있음 + +**대안** +- 개인 To-do는 Outlook·다우오피스 일정, Notion 등과 병행 가능 +- **회의 연계 업무**는 AI Platform 체크리스트가 출처 추적에 유리 + +--- + +## 슬라이드 7. 일반 채팅 + +**경로**: AI → **일반 채팅** (`/ai-explore/chat`) + +**화면 설명** +- ChatGPT 기반 **인앱 채팅** (스트리밍 응답, 마크다운 표시) +- 「안녕하세요, 오늘 무엇을 도와드릴까요?」 웰come 화면 + +**주요 기능** +- 업무 질의, 문서 초안, 아이디어 브레인스토밍 +- (서버 설정 시) **웹 검색** 연동으로 최신 정보 보강 +- 대화 맥락 유지(세션 내) + +**이용 조건** +- **회사 이메일 인증(OPS 로그인)** 후 이용 + +**활용 팁 · 대안** + +| 용도 | 추천 | +|------|------| +| 빠른 업무 질의·초안 | **AI Platform 일반 채팅** — 별도 ChatGPT 구독 없이 사내 경로 | +| Claude·Gemini 선호 | 개인/팀 구독 도구 병행. **사내 기록·규정 연동**은 Platform 채팅·프롬프트 라이브러리 | +| 회사규정 확인 | **회사규정(NotebookLM)** 또는 채팅 + 반드시 원문 대조 | +| 반복 업무 프롬프트 | **프롬프트** 메뉴에서 템플릿 복사 후 채팅에 붙여넣기 | + +**유의사항** +- 하단 안내: 「AI 답변은 실수할 수 있습니다. 중요한 정보는 원문 규정과 함께 확인」 +- 기밀·개인정보 입력 자제 + +--- + +## 슬라이드 8. FSCAN 조사각 선정도우미 + +**경로**: AI → **FSCAN 조사각 선정도우미** (`/ai-explore/fscan`) + +**화면 설명** +- 검사 대상물 **치수(H/W)** 입력 기반 FSCAN 시리즈 **모델 선정** 도구 +- AI Platform 레이아웃 안에 도구 화면(iframe) 임베드 + +**주요 기능** +- H(높이)·W(폭) 등 조건 입력 → 적합 FSCAN 모델 추천 +- 영업·기술 검토 시 **빠른 1차 선정**용 + +**활용 팁** +- 최종 스펙·계약 조건은 **공식 카탈로그·기술팀** 확인 필수 +- XScan 관련 다른 도구는 AI 허브 **타입 필터: XScan** 으로 추후 확장 카드 확인 + +**대상 사용자** +- FSCAN 제품·검사 설계 관련 영업, 기술, PM + +--- + +## 슬라이드 9. 프롬프트 (프롬프트 라이브러리) + +**경로**: `/ai-explore/prompts` + +**화면 설명** +- 「공식 템플릿을 복사하거나, 팀이 공유한 프롬프트를 고르고…」 +- 4개 탭: **라이브러리** / **워크플로→프롬프트** / **공유하기** / **내가 올린 것** + +**라이브러리 탭** +- 좌측: 시나리오 카드(회의 요약, 이메일 초안, 보고서 목차, OKR, 코드 리뷰, CS 응대, JD, 리스크 메모 등) +- 우측: 미리보기 + **클립보드에 복사** +- 필터: 공식/팀 공유, 인기·최신, 검색 + +**워크플로 탭** +- ①~④ 단계 입력 → 초안 합치기 → (OpenAI 연동 시) AI로 다듬기 +- 본인 업무에 맞는 **맞춤 지시문** 작성 + +**공유하기 탭** +- 팀에 프롬프트·참고 파일 공개(로그인 필요) +- 민감·개인정보·기밀 미포함 주의 + +**이용 방법** +1. 카드 선택 → 미리보기 확인 +2. `[ ]` 플레이스홀더를 상황에 맞게 수정 +3. ChatGPT·Claude·회의록 AI 등 원하는 도구에 붙여넣기 + +**활용 팁** +- 매번 같은 형식의 메일·보고·회의 요약 → **공식 템플릿**부터 시작 +- 팀에서 검증된 프롬프트 → **공유하기**로 축적 + +--- + +## 슬라이드 10. 학습센터 + +**경로**: `/learning` (루트 `/` 도 동일 목적지) + +**화면 설명** +- 「최신 컨텐츠로 학습하고, 바로 업무에 적용하세요」 +- 강의 **검색·필터** + **카드 목록** + 페이지 이동 + +**콘텐츠 유형** +| 타입 | 시청 방식 | +|------|-----------| +| YouTube | 페이지 내 영상 재생 | +| PPT/PDF | 슬라이드 이미지 뷰어(1·2·3단 보기) | +| 동영상 파일 | 업로드 mp4 등 재생 | +| 웹 링크 | 외부 URL 연결 | +| 뉴스 URL | 뉴스 링크 | + +**카테고리 예** +- AX 사고 전환, AI 툴 활용, AI Agent, 바이브 코딩 등 + +**이용 방법** +1. 검색어·타입·태그·카테고리로 필터 +2. 카드 클릭 → 상세에서 학습 + +**활용 팁** +- Claude·ChatGPT·Cursor 등 **도구별 학습**은 검색창에 도구명 입력(예: claude, 클로드) +- 신규 입사·AX 교육 시 **AX 사고 전환** 카테고리부터 순차 학습 + +**관리자(선택)** +- 관리자 토큰 인증 시 **학습 등록**(`/admin`)으로 강의 추가 가능 + +--- + +## 슬라이드 11. 과제신청 (AX 과제 신청) + +**경로**: `/ax-apply` + +**화면 설명** +- **신청된 과제 목록**(부서·이름·이메일로 조회) +- **AX 과제 신청서** 온라인 작성 + Word 양식 다운로드 + +**신청서 주요 섹션** +1. 기본 정보(부서, 이름, 연락처 등) +2. 업무·Pain Point·AI 기대 효과 +3. 데이터·자동화 수준·보안 관련 +4. 정량·정성 기대 효과 +5. 신청서 파일 첨부(선택) + +**이용 방법** +1. `(신청서 다운로드)`로 Word 양식 참고 +2. 웹 폼 작성 → 제출 +3. **조회** 버튼으로 본인 신청 내역 확인·수정 + +**활용 팁** +- 「AI로 무엇을 자동화하고 싶은지」를 **구체적 업무 프로세스**로 적을수록 검토에 유리 +- 유사 사례는 **AI 활용 사례** 메뉴에서 참고 + +**유의사항** +- 모든 항목 **성실 작성** (화면 안내 문구) +- 개인정보·고객 데이터 처리 계획은 보안·Compliance 관점에서 명시 + +--- + +## 슬라이드 12. AI 활용 사례 + +**경로**: `/ai-cases` + +**화면 설명** +- 「현장에서 검증된 AI 업무 혁신 이야기」 +- 카드 그리드 + 검색·태그 필터 +- 카드 클릭 → 상세 본문 + +**주요 기능** +- 부서별 **도입 과정·성과** 사례 열람 +- 로그인 사용자 **글쓰기**(`/ai-cases/compose`) — STAR 형식(1.Situation ~ 4.Result) +- 활용 AI 툴 태그(Claude, Slack 등) 입력 + +**이용 방법 (열람)** +1. 검색·태그로 관심 사례 찾기 +2. 카드 클릭 → 상세 읽기 + +**이용 방법 (작성)** +1. 상단 **글쓰기** → STAR 본문·썸네일·활용 AI 입력 +2. 제출 → 목록에 반영(승인·노출 정책은 운영 기준 따름) + +**활용 팁** +- AX 과제 기획 전 **유사 부서 사례** 검색 +- 본인 성공 사례 → 팀 공유·과제신청 근거 자료로 활용 + +--- + +## 슬라이드 13. 대시보드 (허용 계정) + +**경로**: `/dashboard` (메뉴는 **허용 이메일**에게만 표시) + +**화면 설명** +- 경영·업무 지표 대시보드 **허브**(검색 + 카드) +- 현재 카드: **경영성과 대시보드** + +**경영성과 대시보드** (`/dashboard/business-performance`) +- **상단**: 연도·분기 선택 → Chart.js 기반 KPI·차트 조회 +- **하단**: 매출일보 등 **엑셀(.xlsx) 업로드** → 스냅샷 저장·조회 +- (해당 기간 데이터 없으면 샘플 데이터 + 안내 문구) + +**대상** +- `.env`에 등록된 `DASHBOARD_MENU_ALLOWED_EMAILS` 등 **사전 허용 계정** + +**활용 팁** +- 일반 임직원에게는 메뉴가 보이지 않을 수 있음 — **경영·관리 목적** 전용 +- 엑셀 업로드 후 차트가 기대와 다르면 AI혁신팀·담당 부서에 문의 + +--- + +## 슬라이드 14. (부록) 관리자·로그아웃 + +**관리자 모드** (일반 사용자 참고용) +- 좌측 하단 **관리자** → 관리자 토큰 입력 +- 강의 등록·삭제, AI 활용 사례 관리, 사용자 현황 등 + +**로그아웃** +- 좌측 하단 **로그아웃** → 이메일 세션 종료 → 로그인 화면 + +--- + +## 슬라이드 15. (마무리) 업무별 추천 메뉴 Quick Reference + +| 업무 | 먼저 열 메뉴 | +|------|----------------| +| 회의록(음성) | AI → **회의록 AI** (또는 클로버노트 전사 → 텍스트 입력) | +| 회의록(메모만) | AI → **회의록 AI** 텍스트 입력 | +| 할 일 추적 | AI → **업무 체크리스트 AI** | +| 빠른 AI 질문 | AI → **일반 채팅** | +| 메일·보고 초안 | **프롬프트** → 템플릿 복사 | +| 사내 규정 | **회사규정**(NotebookLM) | +| AI 학습 | **학습센터** | +| AI 도입 과제 | **과제신청** + **AI 활용 사례** 참고 | +| FSCAN 모델 선정 | AI → **FSCAN 조사각 선정도우미** | + +**문의**: AI혁신팀 + +--- + +## PPT 제작 시 권장 사항 + +1. **메뉴당 2장**: ① 화면 캡처 + 한 줄 설명 ② 이용 방법·팁·대안 +2. **실제 스크린샷**: https://ai.xavis.co.kr/ 에서 로그인 후 각 경로 캡처 삽입 +3. **강조 색**: 인증(파랑), 주의(회색 박스), 대안(별도 불릿) +4. **발표 스크립트**: 각 슬라이드 노트에 「이 메뉴는 ○○할 때 쓴다」 한 문장 추가 diff --git a/docs/ppt-screenshots-user/ai-cases-compose.png b/docs/ppt-screenshots-user/ai-cases-compose.png new file mode 100644 index 0000000..135fb7b Binary files /dev/null and b/docs/ppt-screenshots-user/ai-cases-compose.png differ diff --git a/docs/ppt-screenshots-user/ai-cases.png b/docs/ppt-screenshots-user/ai-cases.png new file mode 100644 index 0000000..1aa8242 Binary files /dev/null and b/docs/ppt-screenshots-user/ai-cases.png differ diff --git a/docs/ppt-screenshots-user/ai-explore.png b/docs/ppt-screenshots-user/ai-explore.png new file mode 100644 index 0000000..e23725c Binary files /dev/null and b/docs/ppt-screenshots-user/ai-explore.png differ diff --git a/docs/ppt-screenshots-user/ax-apply.png b/docs/ppt-screenshots-user/ax-apply.png new file mode 100644 index 0000000..efb8845 Binary files /dev/null and b/docs/ppt-screenshots-user/ax-apply.png differ diff --git a/docs/ppt-screenshots-user/chat.png b/docs/ppt-screenshots-user/chat.png new file mode 100644 index 0000000..39e06ef Binary files /dev/null and b/docs/ppt-screenshots-user/chat.png differ diff --git a/docs/ppt-screenshots-user/company-policy.png b/docs/ppt-screenshots-user/company-policy.png new file mode 100644 index 0000000..a0a651a Binary files /dev/null and b/docs/ppt-screenshots-user/company-policy.png differ diff --git a/docs/ppt-screenshots-user/dashboard-business-performance.png b/docs/ppt-screenshots-user/dashboard-business-performance.png new file mode 100644 index 0000000..f1482e4 Binary files /dev/null and b/docs/ppt-screenshots-user/dashboard-business-performance.png differ diff --git a/docs/ppt-screenshots-user/dashboard.png b/docs/ppt-screenshots-user/dashboard.png new file mode 100644 index 0000000..3af694c Binary files /dev/null and b/docs/ppt-screenshots-user/dashboard.png differ diff --git a/docs/ppt-screenshots-user/fscan.png b/docs/ppt-screenshots-user/fscan.png new file mode 100644 index 0000000..37d376e Binary files /dev/null and b/docs/ppt-screenshots-user/fscan.png differ diff --git a/docs/ppt-screenshots-user/learning.png b/docs/ppt-screenshots-user/learning.png new file mode 100644 index 0000000..22c2182 Binary files /dev/null and b/docs/ppt-screenshots-user/learning.png differ diff --git a/docs/ppt-screenshots-user/login.png b/docs/ppt-screenshots-user/login.png new file mode 100644 index 0000000..64f7381 Binary files /dev/null and b/docs/ppt-screenshots-user/login.png differ diff --git a/docs/ppt-screenshots-user/meeting-minutes.png b/docs/ppt-screenshots-user/meeting-minutes.png new file mode 100644 index 0000000..d7ac5c6 Binary files /dev/null and b/docs/ppt-screenshots-user/meeting-minutes.png differ diff --git a/docs/ppt-screenshots-user/menu-overview.png b/docs/ppt-screenshots-user/menu-overview.png new file mode 100644 index 0000000..e23725c Binary files /dev/null and b/docs/ppt-screenshots-user/menu-overview.png differ diff --git a/docs/ppt-screenshots-user/prompts.png b/docs/ppt-screenshots-user/prompts.png new file mode 100644 index 0000000..8b2ef66 Binary files /dev/null and b/docs/ppt-screenshots-user/prompts.png differ diff --git a/docs/ppt-screenshots-user/task-checklist.png b/docs/ppt-screenshots-user/task-checklist.png new file mode 100644 index 0000000..4996569 Binary files /dev/null and b/docs/ppt-screenshots-user/task-checklist.png differ diff --git a/docs/ppt-screenshots-user/wm.png b/docs/ppt-screenshots-user/wm.png new file mode 100644 index 0000000..47f2181 Binary files /dev/null and b/docs/ppt-screenshots-user/wm.png differ diff --git a/docs/ppt-screenshots/ai-cases.png b/docs/ppt-screenshots/ai-cases.png new file mode 100644 index 0000000..310d82a Binary files /dev/null and b/docs/ppt-screenshots/ai-cases.png differ diff --git a/docs/ppt-screenshots/ai-explore.png b/docs/ppt-screenshots/ai-explore.png new file mode 100644 index 0000000..ff1e2cf Binary files /dev/null and b/docs/ppt-screenshots/ai-explore.png differ diff --git a/docs/ppt-screenshots/ax-apply.png b/docs/ppt-screenshots/ax-apply.png new file mode 100644 index 0000000..1b7282b Binary files /dev/null and b/docs/ppt-screenshots/ax-apply.png differ diff --git a/docs/ppt-screenshots/chat.png b/docs/ppt-screenshots/chat.png new file mode 100644 index 0000000..1cbd9dc Binary files /dev/null and b/docs/ppt-screenshots/chat.png differ diff --git a/docs/ppt-screenshots/company-policy.png b/docs/ppt-screenshots/company-policy.png new file mode 100644 index 0000000..4f6f595 Binary files /dev/null and b/docs/ppt-screenshots/company-policy.png differ diff --git a/docs/ppt-screenshots/dashboard-business-performance.png b/docs/ppt-screenshots/dashboard-business-performance.png new file mode 100644 index 0000000..447fdb4 Binary files /dev/null and b/docs/ppt-screenshots/dashboard-business-performance.png differ diff --git a/docs/ppt-screenshots/dashboard.png b/docs/ppt-screenshots/dashboard.png new file mode 100644 index 0000000..5a7569e Binary files /dev/null and b/docs/ppt-screenshots/dashboard.png differ diff --git a/docs/ppt-screenshots/fscan.png b/docs/ppt-screenshots/fscan.png new file mode 100644 index 0000000..19bb377 Binary files /dev/null and b/docs/ppt-screenshots/fscan.png differ diff --git a/docs/ppt-screenshots/learning.png b/docs/ppt-screenshots/learning.png new file mode 100644 index 0000000..4f6f595 Binary files /dev/null and b/docs/ppt-screenshots/learning.png differ diff --git a/docs/ppt-screenshots/login.png b/docs/ppt-screenshots/login.png new file mode 100644 index 0000000..3ced226 Binary files /dev/null and b/docs/ppt-screenshots/login.png differ diff --git a/docs/ppt-screenshots/meeting-minutes.png b/docs/ppt-screenshots/meeting-minutes.png new file mode 100644 index 0000000..78a91b2 Binary files /dev/null and b/docs/ppt-screenshots/meeting-minutes.png differ diff --git a/docs/ppt-screenshots/prompts.png b/docs/ppt-screenshots/prompts.png new file mode 100644 index 0000000..ce6dc0c Binary files /dev/null and b/docs/ppt-screenshots/prompts.png differ diff --git a/docs/ppt-screenshots/task-checklist.png b/docs/ppt-screenshots/task-checklist.png new file mode 100644 index 0000000..82abe06 Binary files /dev/null and b/docs/ppt-screenshots/task-checklist.png differ diff --git a/docs/ppt-screenshots/wm.png b/docs/ppt-screenshots/wm.png new file mode 100644 index 0000000..ff1e2cf Binary files /dev/null and b/docs/ppt-screenshots/wm.png differ diff --git a/lib/ai-use-case-submissions.js b/lib/ai-use-case-submissions.js new file mode 100644 index 0000000..2f28275 --- /dev/null +++ b/lib/ai-use-case-submissions.js @@ -0,0 +1,496 @@ +/** + * AI 활용 사례 — 사용자 제출(글쓰기) PostgreSQL 저장 + */ +const { v4: uuidv4 } = require("uuid"); +const { stripForCount } = require("./strip-for-count"); + +const PLACEHOLDERS = { + situation: + "고객센터에는 반복적인 문의(배송 일정, 환불 절차, 계정 비밀번호 초기화 등)가 하루 수백 건씩 접수되어 상담사의 업무가 과중되고, 응답 지연으로 고객 만족도가 낮아지고 있었습니다.", + task: + "반복 문의를 AI로 자동 처리하여 상담사의 업무 부담을 줄이고, 고객 응답 속도와 만족도를 높이는 것이 목표였습니다.", + action: + "FAQ 데이터와 기존 상담 이력을 기반으로 AI 챗봇을 구축했습니다.\n주요 질문 유형을 분류하고, 자연어 기반 답변이 가능하도록 학습시켰으며, 복잡한 문의는 상담사에게 자동 이관되도록 설계했습니다.", + result: + "전체 문의의 65%를 AI가 자동 처리하게 되었고, 평균 응답 시간이 10분에서 30초 이내로 단축되었습니다. 상담사들은 고난도 고객 대응에 집중할 수 있게 되었고, 고객 만족도는 20% 이상 향상되었습니다.", +}; + +const MAX_BODY_TOTAL = 10000; +const MAX_THUMB_BYTES = 5 * 1024 * 1024; +const MAX_THUMB_COUNT = 5; +const MAX_ATTACH_BYTES = 20 * 1024 * 1024; +const MAX_ATTACH_COUNT = 1; + +/** + * @param {object} f + * @returns {{ originalName: string, relativePath: string, size?: number } | null} + */ +function normalizeThumbFileEntry(f) { + if (!f || typeof f !== "object") return null; + const rp = String(f.relativePath || f.relative_path || "").trim(); + if (!rp) return null; + const orig = + String(f.originalName || f.original_name || rp.split("/").pop() || "thumbnail").trim() || + "thumbnail"; + const out = { originalName: orig, relativePath: rp }; + if (typeof f.size === "number" && f.size >= 0) out.size = f.size; + return out; +} + +/** + * DB row → 썸네일 파일 목록(legacy 단일 경로 포함) + * @param {object} row + * @returns {Array<{ originalName: string, relativePath: string, size?: number }>} + */ +function parseThumbnailFiles(row) { + let files = []; + try { + const raw = row && row.thumbnail_files; + const parsed = typeof raw === "string" ? JSON.parse(raw) : raw; + if (Array.isArray(parsed)) { + files = parsed.map(normalizeThumbFileEntry).filter(Boolean); + } + } catch { + files = []; + } + if (!files.length && row && row.thumbnail_relative_path) { + const rp = String(row.thumbnail_relative_path).trim(); + if (rp) { + files = [ + { + originalName: rp.split("/").pop() || "thumbnail", + relativePath: rp, + }, + ]; + } + } + return files; +} + +/** + * @param {Array<{ relativePath?: string }>} files + * @returns {string | null} + */ +function primaryThumbnailPath(files) { + if (!Array.isArray(files) || !files.length) return null; + const rp = String(files[0].relativePath || "").trim(); + return rp || null; +} + +function countBodyTotal(row) { + return ( + stripForCount(row.situation || "") + + stripForCount(row.task_goal || "") + + stripForCount(row.action_taken || "") + + stripForCount(row.result_outcome || "") + ); +} + +/** + * @param {import("pg").Pool} pgPool + * @param {object} p + * @param {string} [p.id] - 업로드 디렉터리명과 동일한 UUID(미입력 시 생성) + * @param {string} p.submitterEmail + * @param {string} p.title + * @param {string} p.situation + * @param {string} p.taskGoal + * @param {string} p.actionTaken + * @param {string} p.resultOutcome + * @param {string[]} p.tags + * @param {string | null} p.thumbnailRelativePath - 목록 카드용 대표(첫 번째) 경로 + * @param {Array<{ originalName: string, relativePath: string, size?: number }>} p.thumbnailFiles + * @param {Array<{ originalName: string, relativePath: string }>} p.attachmentFiles + */ +async function insertSubmission(pgPool, p) { + if (!pgPool) throw new Error("PostgreSQL이 필요합니다."); + const total = countBodyTotal({ + situation: p.situation, + task_goal: p.taskGoal, + action_taken: p.actionTaken, + result_outcome: p.resultOutcome, + }); + if (total > MAX_BODY_TOTAL) { + throw new Error(`본문(4개 합계)은 ${MAX_BODY_TOTAL}자 이하여야 합니다. (현재 ${total}자)`); + } + const thumbFiles = Array.isArray(p.thumbnailFiles) ? p.thumbnailFiles.map(normalizeThumbFileEntry).filter(Boolean) : []; + const primaryThumb = p.thumbnailRelativePath || primaryThumbnailPath(thumbFiles); + const id = p.id || uuidv4(); + const q = `INSERT INTO ai_use_case_submissions ( + id, submitter_email, title, situation, task_goal, action_taken, result_outcome, + tags, thumbnail_relative_path, thumbnail_files, attachment_files, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11::jsonb, NOW(), NOW())`; + await pgPool.query(q, [ + id, + p.submitterEmail, + p.title, + p.situation || "", + p.taskGoal || "", + p.actionTaken || "", + p.resultOutcome || "", + p.tags && p.tags.length ? p.tags : [], + primaryThumb || null, + JSON.stringify(thumbFiles), + JSON.stringify(p.attachmentFiles || []), + ]); + return { id }; +} + +const SUBMISSION_DEPT = "일반 제출"; +const EXCERPT_MAX = 220; + +/** + * @param {string} situation + * @param {string} task + * @param {string} action + * @param {string} result + * @param {number} [max] + */ +function buildExcerpt(situation, task, action, result, max) { + const cap = max == null ? EXCERPT_MAX : max; + const full = [situation, task, action, result] + .map((h) => stripForCount(h || "")) + .filter(Boolean) + .join(" ") + .replace(/\s+/g, " ") + .trim(); + if (!full) return ""; + if (full.length <= cap) return full; + return full.slice(0, cap) + "…"; +} + +/** + * /ai-cases 목록 카드용 + * @param {object} row — pg row + * @returns {object} + */ +function mapRowToListCard(row) { + const email = (row.submitter_email || "").trim(); + const author = email && email.indexOf("@") > 0 ? email.split("@")[0] : email || "제출자"; + const created = row.created_at ? new Date(row.created_at) : new Date(0); + const published = Number.isNaN(created.getTime()) ? "" : created.toISOString().slice(0, 10); + const cover = primaryThumbnailPath(parseThumbnailFiles(row)) || (row.thumbnail_relative_path || "").trim(); + return { + _source: "submission", + submissionId: String(row.id), + title: (row.title || "").trim() || "제목 없음", + excerpt: buildExcerpt(row.situation, row.task_goal, row.action_taken, row.result_outcome), + department: SUBMISSION_DEPT, + author, + submitterEmail: email, + tags: Array.isArray(row.tags) ? row.tags : [], + publishedAt: published, + _dateSort: created, + coverImageUrl: cover, + viewCount: Number(row.view_count) || 0, + likeCount: Number(row.like_count) || 0, + }; +} + +/** + * @param {import("pg").Pool} pgPool + * @param {number} [limit=500] + */ +async function listForPublicList(pgPool, limit) { + if (!pgPool) return []; + const lim = Math.min(Math.max(1, limit || 500), 1000); + const r = await pgPool.query( + `SELECT + s.id, + s.submitter_email, + s.title, + s.situation, + s.task_goal, + s.action_taken, + s.result_outcome, + s.tags, + s.thumbnail_relative_path, + s.thumbnail_files, + s.created_at, + COALESCE(s.view_count, 0)::int AS view_count, + (SELECT COUNT(*)::int FROM ai_use_case_submission_likes l WHERE l.submission_id = s.id) AS like_count + FROM ai_use_case_submissions s + ORDER BY s.created_at DESC + LIMIT $1`, + [lim] + ); + return (r.rows || []).map(mapRowToListCard); +} + +/** + * @param {import("pg").Pool} pgPool + * @param {string} id + */ +async function getSubmissionById(pgPool, id) { + if (!pgPool) return null; + const r = await pgPool.query( + `SELECT * FROM ai_use_case_submissions WHERE id = $1 LIMIT 1`, + [id] + ); + return r.rows && r.rows[0] ? r.rows[0] : null; +} + +/** + * 편집 권한: 제출자 본인 또는 관리자 + * @param {string} rowSubmitterEmail + * @param {string | null} requestEmail + * @param {boolean} isAdmin + */ +function canUserEditSubmission(rowSubmitterEmail, requestEmail, isAdmin) { + if (isAdmin) return true; + if (!requestEmail) return false; + const a = (rowSubmitterEmail || "").trim().toLowerCase(); + const b = String(requestEmail).trim().toLowerCase(); + return Boolean(a && b && a === b); +} + +/** + * 작성 화면 Toast 초기 HTML (DB에 저장된 4섹션 HTML을 그대로 감쌈) + * @param {string} situation + * @param {string} task + * @param {string} action + * @param {string} result + */ +function buildComposeEditorHtml(situation, task, action, result) { + const b = (x) => (x && String(x).trim() ? String(x) : "


"); + return ( + '
' + + '

1. Situation (배경)

' + + b(situation) + + "
" + + '

2. Task (과제/목표)

' + + b(task) + + "
" + + '

3. Action (행동)

' + + b(action) + + "
" + + '

4. Result (결과)

' + + b(result) + + "
" + + "
" + ); +} + +/** + * @param {import("pg").Pool} pgPool + * @param {object} p + * @param {string} p.id + * @param {string} p.title + * @param {string} p.situation + * @param {string} p.taskGoal + * @param {string} p.actionTaken + * @param {string} p.resultOutcome + * @param {string[]} p.tags + * @param {string} p.thumbnailFilesJson — 썸네일 배열 전체 JSON + * @param {string} p.attachmentFilesJson — json 문자열(배열 전체) + */ +async function updateSubmission(pgPool, p) { + if (!pgPool) throw new Error("PostgreSQL이 필요합니다."); + const total = countBodyTotal({ + situation: p.situation, + task_goal: p.taskGoal, + action_taken: p.actionTaken, + result_outcome: p.resultOutcome, + }); + if (total > MAX_BODY_TOTAL) { + throw new Error(`본문(4개 합계)은 ${MAX_BODY_TOTAL}자 이하여야 합니다. (현재 ${total}자)`); + } + let thumbFiles = []; + try { + const parsed = typeof p.thumbnailFilesJson === "string" ? JSON.parse(p.thumbnailFilesJson) : p.thumbnailFilesJson; + if (Array.isArray(parsed)) thumbFiles = parsed.map(normalizeThumbFileEntry).filter(Boolean); + } catch { + thumbFiles = []; + } + const primaryThumb = primaryThumbnailPath(thumbFiles); + await pgPool.query( + `UPDATE ai_use_case_submissions SET + title = $1, + situation = $2, + task_goal = $3, + action_taken = $4, + result_outcome = $5, + tags = $6, + thumbnail_relative_path = $7, + thumbnail_files = $8::jsonb, + attachment_files = $9::jsonb, + updated_at = NOW() + WHERE id = $10`, + [ + p.title, + p.situation || "", + p.taskGoal || "", + p.actionTaken || "", + p.resultOutcome || "", + p.tags && p.tags.length ? p.tags : [], + primaryThumb, + JSON.stringify(thumbFiles), + p.attachmentFilesJson || "[]", + p.id, + ] + ); + return { ok: true }; +} + +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * 상세 페이지 방문마다 view_count +1 (페이지뷰). 제출자 본인(로그인) 조회만 제외. + * @param {import("pg").Pool} pgPool + * @param {string} submissionId + * @param {string | null | undefined} viewerEmail + * @param {string | null | undefined} submitterEmail + * @returns {Promise} + */ +async function recordViewFromOther(pgPool, submissionId, viewerEmail, submitterEmail) { + if (!pgPool || !submissionId || !UUID_RE.test(submissionId)) return 0; + const viewer = String(viewerEmail || "") + .trim() + .toLowerCase(); + const submitter = String(submitterEmail || "") + .trim() + .toLowerCase(); + if (viewer && submitter && viewer === submitter) { + const cur = await pgPool.query( + `SELECT view_count FROM ai_use_case_submissions WHERE id = $1::uuid LIMIT 1`, + [submissionId] + ); + return cur.rows[0]?.view_count ?? 0; + } + await pgPool.query( + `UPDATE ai_use_case_submissions SET view_count = view_count + 1 WHERE id = $1::uuid`, + [submissionId] + ); + const r = await pgPool.query( + `SELECT view_count FROM ai_use_case_submissions WHERE id = $1::uuid LIMIT 1`, + [submissionId] + ); + return r.rows[0]?.view_count ?? 0; +} + +/** + * @param {import("pg").Pool} pgPool + * @param {string} submissionId + * @param {string | null | undefined} userEmail + * @returns {Promise<{ viewCount: number, likeCount: number, myLike: boolean }>} + */ +async function getSubmissionEngagement(pgPool, submissionId, userEmail) { + if (!pgPool || !submissionId || !UUID_RE.test(submissionId)) { + return { viewCount: 0, likeCount: 0, myLike: false }; + } + const stats = await pgPool.query( + `SELECT + COALESCE(s.view_count, 0)::int AS view_count, + (SELECT COUNT(*)::int FROM ai_use_case_submission_likes l WHERE l.submission_id = s.id) AS like_count + FROM ai_use_case_submissions s + WHERE s.id = $1::uuid + LIMIT 1`, + [submissionId] + ); + if (!stats.rowCount) { + return { viewCount: 0, likeCount: 0, myLike: false }; + } + const row = stats.rows[0]; + let myLike = false; + const email = String(userEmail || "") + .trim() + .toLowerCase(); + if (email) { + const mine = await pgPool.query( + `SELECT 1 FROM ai_use_case_submission_likes + WHERE submission_id = $1::uuid AND user_email = $2 + LIMIT 1`, + [submissionId, email] + ); + myLike = mine.rowCount > 0; + } + return { + viewCount: row.view_count || 0, + likeCount: row.like_count || 0, + myLike, + }; +} + +/** + * @param {import("pg").Pool} pgPool + * @param {string} submissionId + * @param {string} userEmail + * @returns {Promise<{ liked: boolean, likeCount: number }>} + */ +async function toggleSubmissionLike(pgPool, submissionId, userEmail) { + if (!pgPool) { + const err = new Error("DB를 사용할 수 없습니다."); + err.code = "NO_DB"; + throw err; + } + const id = String(submissionId || "").trim(); + if (!UUID_RE.test(id)) { + const err = new Error("잘못된 ID입니다."); + err.code = "VALIDATION"; + throw err; + } + const email = String(userEmail || "") + .trim() + .toLowerCase(); + if (!email) { + const err = new Error("로그인이 필요합니다."); + err.code = "UNAUTHORIZED"; + throw err; + } + const exists = await pgPool.query( + `SELECT 1 FROM ai_use_case_submissions WHERE id = $1::uuid LIMIT 1`, + [id] + ); + if (!exists.rowCount) { + const err = new Error("사례를 찾을 수 없습니다."); + err.code = "NOT_FOUND"; + throw err; + } + const del = await pgPool.query( + `DELETE FROM ai_use_case_submission_likes + WHERE submission_id = $1::uuid AND user_email = $2 + RETURNING submission_id`, + [id, email] + ); + if (del.rowCount) { + const cnt = await pgPool.query( + `SELECT COUNT(*)::int AS c FROM ai_use_case_submission_likes WHERE submission_id = $1::uuid`, + [id] + ); + return { liked: false, likeCount: cnt.rows[0]?.c || 0 }; + } + await pgPool.query( + `INSERT INTO ai_use_case_submission_likes (submission_id, user_email) VALUES ($1::uuid, $2)`, + [id, email] + ); + const cnt = await pgPool.query( + `SELECT COUNT(*)::int AS c FROM ai_use_case_submission_likes WHERE submission_id = $1::uuid`, + [id] + ); + return { liked: true, likeCount: cnt.rows[0]?.c || 0 }; +} + +module.exports = { + PLACEHOLDERS, + MAX_BODY_TOTAL, + MAX_THUMB_BYTES, + MAX_THUMB_COUNT, + MAX_ATTACH_BYTES, + MAX_ATTACH_COUNT, + countBodyTotal, + normalizeThumbFileEntry, + parseThumbnailFiles, + primaryThumbnailPath, + insertSubmission, + buildExcerpt, + mapRowToListCard, + listForPublicList, + getSubmissionById, + canUserEditSubmission, + buildComposeEditorHtml, + updateSubmission, + recordViewFromOther, + getSubmissionEngagement, + toggleSubmissionLike, + SUBMISSION_DEPT, +}; diff --git a/lib/meeting-minutes.js b/lib/meeting-minutes.js index 93074f0..d70709c 100644 --- a/lib/meeting-minutes.js +++ b/lib/meeting-minutes.js @@ -5,6 +5,7 @@ const fsSync = require("fs"); const os = require("os"); const path = require("path"); const meetingEmployeeNames = require("./meeting-employee-names"); +const parseCkFromMm = require("./parse-checklist-from-minutes"); const { execFile } = require("child_process"); const { promisify } = require("util"); const execFileAsync = promisify(execFile); @@ -27,7 +28,7 @@ function getGpt4oTranscribeSegmentSeconds() { } /** 환경변수 미지정 시 기본 전사 모델 */ -const DEFAULT_TRANSCRIPTION_MODEL = (process.env.OPENAI_WHISPER_MODEL || "gpt-4o-mini-transcribe").trim(); +const DEFAULT_TRANSCRIPTION_MODEL = (process.env.OPENAI_WHISPER_MODEL || "gpt-4o-transcribe").trim(); /** @deprecated 하위 호환 — DEFAULT_TRANSCRIPTION_MODEL 과 동일 */ const DEFAULT_WHISPER_MODEL = DEFAULT_TRANSCRIPTION_MODEL; @@ -49,19 +50,12 @@ function resolveTranscriptionApiModel(uiModel) { * DB에 저장된 옵션으로 시스템 프롬프트 구성 * @param {object} settings - meeting_ai_prompts 행(카멜 또는 스네이크) */ -/** 액션 아이템 — 형식은 사용자 추가 지시 우선(What/Who/When 강제 없음) */ -const ACTION_ITEMS_GUIDANCE_MINIMAL = [ - "【액션 아이템】", - "「Action Item」포함이 켜져 있으면 별도 마크다운 섹션(예: ## 액션 아이템 또는 ## Action Items)으로 후속 과제를 정리합니다.", - "**항목 표기(번호·부제목·영문 Who/When/What 라벨 사용 여부 등)는 아래「사용자 추가 지시」를 가장 우선합니다.** 추가 지시에 형식이 없으면 간단한 번호 목록·불릿으로, 원문·전사에서 확정된 일만 적습니다.", - "담당자·이름·기한은 **회의 원문·전사에 명시된 경우에만** 적습니다. 없거나 불명확하면 생략하거나 ‘미정’·‘TBD’로 표기하고, 원문에 없는 인물·담당을 추측하여 쓰지 마세요.", -]; - const EMPLOYEE_NAME_GUIDANCE_MINIMAL = [ "【인명·담당자】", "참석자·담당자 이름은 **원문·전사에 실제로 등장한 표기**를 따릅니다. 음성 인식 오류로 같은 사람이 문맥상 확실할 때만 철자를 다듬습니다.", "사용자 메시지 상단에「이번 원문/전사 한정 · 임직원 표기 통일」블록이 있으면, **그 안의 매핑만** 적용하고 다른 이름을 임의로 목록에서 끌어오지 마세요.", "전사에 없는 사람을 만들어내지 마세요.", + "발언자가 '우리', '우리 팀', '우리 회사', '저희 팀' 등으로 표현한 주체는 **회의 진행 요약·Q&A 등에서는** 원문 표현 그대로 유지하고, 문맥으로 특정 팀명·조직명을 추론하여 대체하지 마세요. (금지 예: '우리 팀(소프트웨어 운영팀)'처럼 괄호 보강) 단, 아래 【6) 액션 아이템】표의 **「담당」열**에서는 이 지칭을 쓸 수 없으며, 구체 이름·실제 조직·팀명이 없으면 **미정**만 둘 수 있습니다.", ]; /** 회의 체크리스트 — 정의·목적·전·중·후 + 업무 체크리스트 AI 연동 */ @@ -70,15 +64,11 @@ const MEETING_CHECKLIST_GUIDANCE_LINES = [ "정의: 회의가 원활하게 진행되고 목표를 달성할 수 있도록 사전에 준비하거나, 회의 후 검토해야 할 항목들을 목록화한 것입니다.", "목적: 준비 부족으로 인한 시간 낭비를 방지하고, 회의 전·중·후 전 과정을 구조화하여 효율을 높입니다.", "원문에서 도출 가능한 범위에서, 회의 전 준비·회의 중 진행 점검·회의 후 확인·후속 등을 [ ] 체크리스트 형태로 나열할 수 있습니다.", - "업무 체크리스트 AI 연동: 위 체크리스트 섹션(예: ## 후속 확인 체크리스트, ## 회의 체크리스트)을 반드시 포함하고, 각 항목은 완료 여부를 파악·표시할 수 있는 형태(예: [ ] 항목, 체크박스)로 작성하세요. ‘사용자 추가 지시’에 체크리스트 관련 문구가 없더라도 이 요구는 동일하게 적용됩니다.", + "업무 체크리스트 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 includeChecklist = settings.includeChecklist === true || settings.include_checklist === true; const custom = (settings.customInstructions && String(settings.customInstructions)) || @@ -86,71 +76,115 @@ function buildMeetingMinutesSystemPrompt(settings) { ""; const lines = [ - "당신은 사내 회의록을 정리하는 전문가입니다. 입력된 회의 원문(또는 음성 전사)을 바탕으로 읽기 쉬운 회의록을 한국어로 작성합니다.", - "【규칙 우선순위】이 메시지 아래에「사용자 추가 지시」가 있으면, 섹션 구성·액션 항목 표기·체크리스트 포함 여부 등은 **사용자 추가 지시를 최우선**으로 따릅니다. 일반 지시와 다르면 사용자 추가 지시가 우선합니다.", + "당신은 사내 회의록 작성 전문가입니다. 음성 녹취록, 대화체 텍스트, 또는 회의 메모를 입력받아 구조화된 공식 회의록을 한국어로 작성합니다.", ]; + + if (custom.trim()) { + lines.push("【규칙 우선순위】이 메시지 아래에「사용자 추가 지시」가 있으면, 섹션 구성·형식 등은 **사용자 추가 지시를 최우선**으로 따릅니다."); + } + EMPLOYEE_NAME_GUIDANCE_MINIMAL.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("- 음성 전사·회의 원문 전체를 회의록 본문에 다시 붙여 넣지 마세요. 원문/전사는 시스템에서 별도 필드(전사 기록·회의 원문)로 이미 보관됩니다."); - if (includeChecklist) { - lines.push( - "- ‘스크립트’, ‘스크랩트’(오타), ‘원문 전사’, ‘전사문’, ‘Verbatim’ 등 원문을 통째로 실어 나르는 제목의 섹션을 만들지 마세요. 요약·결정·액션·체크리스트만 회의록 본문에 포함하세요." - ); - } else { - lines.push( - "- ‘스크립트’, ‘스크랩트’(오타), ‘원문 전사’, ‘전사문’, ‘Verbatim’ 등 원문을 통째로 실어 나르는 제목의 섹션을 만들지 마세요. 요약·결정·액션 등 **사용자 추가 지시에 적은 섹션만** 포함하세요." - ); - lines.push( - "- 사용자 추가 지시에 「회의 체크리스트」「후속 확인 체크리스트」 등이 없으면, 그런 제목의 별도 체크리스트 섹션을 만들지 마세요." - ); - } - lines.push("- 회의 제목, 참석자, 요약, 결정 사항, 액션 아이템 등은 마크다운 제목(예: ## 회의 제목, ### 요약)으로 구분해 주세요."); - if (includeActionItems) { - lines.push(""); - ACTION_ITEMS_GUIDANCE_MINIMAL.forEach((line) => lines.push(line)); - } + lines.push("## 1) 회의 제목"); + lines.push("- 회의 내용을 대표하는 명확한 제목을 1줄로 작성합니다."); + lines.push("- 형식: [주제] + [목적/활동]"); + lines.push(""); + lines.push("## 2) 참석·언급 인원"); + lines.push("- **표는 쓰지 않습니다.** 제목 다음 줄 내용은 **단일 줄**로, 원문·전사에 등장한 **인물·직함 호칭·조직·팀명**만 **한국어 쉼표(,)** 로 이어 적습니다."); + lines.push("- 예시 형식(실제 이름·호칭·팀명은 반드시 **해당 회의 원문·전사**에서만 추출): `홍길동, 이영 팀장, 기획팀`."); + lines.push("- **포함 기준:** 실명(예: 홍길동) 또는 원문의 직함·역할 호칭(예: 대표님, 팀장) 또는 명확한 조직·팀명(예: 인사총무팀)이 나온 경우만 나열합니다."); + lines.push("- **제외 기준:** '발언자(미상)', '미상', '불명확', '알 수 없음' 등 이름·조직이 확인되지 않으면 빼세요."); + lines.push("- **금지:** 구분·비고 형식의 표, 「참석」「언급됨」 비고 표기, 마크다운 표 전체를 이 섹션에 쓰지 마세요."); + lines.push("- 동일 인물이 여러 호칭으로 불리더라도 하나의 대표 호칭으로만 적되, 불명하면 원문 표기를 우선합니다."); + lines.push(""); + lines.push("## 3) 회의 주제 및 요약"); + lines.push("- **핵심 주제:** 1줄 요약"); + lines.push("- **요약:** 3~5문장으로 회의 전체 흐름과 결론을 서술합니다."); + lines.push("- 구어체·중복 내용은 정제하여 공식 문어체로 변환합니다."); + lines.push(""); + lines.push("## 4) 회의 진행 흐름에 따른 주제 변화"); + lines.push("- 마크다운 표로 작성합니다: 순서 | 주제 | 내용 요약"); + lines.push("- 회의 시작부터 종료까지 시간 순서대로 주제 전환을 기록합니다."); + lines.push("- 각 주제는 2~3문장 이내로 요약합니다."); + lines.push(""); + lines.push("## 5) 회의 중 주요 질의응답"); + lines.push("- `Q. 질문 내용` / `A. 답변 내용` 형식으로 작성합니다."); + lines.push("- 명시적 질문이 없더라도 회의 중 제기된 핵심 쟁점을 Q&A 형태로 재구성합니다."); + lines.push("- 발언자 불명확 시 역할로 표기합니다. (예: 대표이사, 팀장)"); + lines.push(""); + lines.push("## 6) 액션 아이템"); + lines.push( + '- **반드시 GitHub Flavored 마크다운 표** 로만 작성합니다. 열 순서 고정 **4열: `#`(순번) | 담당 | 내용 | 기한**(순번은 1부터 정수만). 헤더·구분선·데이터 **모든 행**은 **반드시 맨 앞·맨 뒤 파이프 `|` 포함** 규격을 따릅니다(렌더·자동연동 신뢰성).' + ); + lines.push( + "- 헤더·구분선·데이터 각 행의 **열 개수(셀 분할 후 줄마다 같은 개수)·맨 앞과 맨 끝 `|` 존재**가 일치해야 합니다(**액션 아이템 표는 고정 네 열**: `#`(순번), 담당, 내용, 기한)." + ); + lines.push("【올바른 예시(형식 준수, 내용만 원문 반영)"); + lines.push("| # | 담당 | 내용 | 기한 |"); + lines.push("| --- | --- | --- | --- |"); + lines.push("| 1 | 인사팀 | 요청 자료 작성 | 다음 주 |"); + lines.push("| 2 | 김민수 | 관련 회신 확인 | 다음 주 |"); + lines.push("【예시 끝】"); + lines.push("- **담당 열(담당자):** 회의 원문·전사에 **성명 또는 실제 소속 조직·팀명으로 명확히 적힌 경우만** 기입합니다."); + lines.push( + "- **담당 열 금지:** 「발언자」「질문자」「저희」「우리 팀」「우리 회사」「우리」(단독 지칭)「본인」「해당 부서」처럼 특정인·실제 조직을 지목하지 않는 호칭은 담당 열에 두지 마십시오." + ); + lines.push( + '- **직무·역할 명칭 단독**(예: 「엔지니어」「PM」「디자이너」만 들어 있는 경우) 또는 인명 없이 역할 이름만 들어 있는 경우에는 「담당」에 두지 말고 **미정**(또는 TBD)을 씁니다.' + ); + lines.push( + '- 담당 주체가 원문만으로 특정되지 않으면 **내용·기한**은 채우고, **담당** 칸은 **미정** 또는 **TBD**만 허용합니다(빈 칸 금지).' + ); + lines.push( + '- 누가 수행해야 하는지는 **내용** 칸 서술에 보조할 수 있으나, **담당** 칸은 위 기준만 따릅니다.' + ); + lines.push("- **금지 형식**: 헤더는 3열(`| 담당 | 내용 | 기한`)인데 데이터 행만 4열인 경우, 헤더엔 순번 줄을 안 쓰고 데이터 줄 맨 앞에만 번호 넣기, 시작/끝 `|` 빠진 행 — 이런 경우 브라우저에서 표가 깨져 보입니다."); + lines.push("- 셀 내부에 `|` 문자가 들어가면 안 됩니다. 바꿀 표현으로 씁니다."); + lines.push("- 회의에서 언급된 모든 후속 과제를 빠짐없이 추출합니다."); + lines.push("- 기한이 명시되지 않은 경우 문맥 기반으로 추정하여 기재합니다. (예: 다음 주, 1개월 이내)"); + lines.push("- 담당자·기한이 원문에 전혀 없으면 '미정'·'TBD'로 표기합니다."); + if (includeChecklist) { lines.push(""); MEETING_CHECKLIST_GUIDANCE_LINES.forEach((line) => lines.push(line)); } + lines.push(""); - lines.push("【말미 섹션 금지】"); - if (includeChecklist) { - lines.push( - "- 회의 체크리스트·액션 아이템 이후에 ‘추가 메모’, ‘확인 필요 사항’, ‘추가 메모 / 확인 필요 사항’ 등 제목의 섹션을 두지 마세요. CSV·표 제안, 설문/스크립트 초안, ‘필요하시면’ 안내 등 말미 부가 안내도 포함하지 않습니다." - ); - lines.push( - "- 체크리스트 섹션을 마지막으로 두고, 그 아래에 시연·피드백 제출 방식(문서/슬랙/이메일) 회신, 액션 우선순위 재정렬·담당·기한 확정 안내, DRM·후보군 추가 작성 제안 같은 **운영/후속 안내 문단**을 붙이지 마세요." - ); - } else { - lines.push( - "- 액션 아이템·결정 사항 이후에 ‘추가 메모’, ‘확인 필요 사항’, ‘추가 메모 / 확인 필요 사항’ 등 제목의 섹션을 두지 마세요. CSV·표 제안, 설문/스크립트 초안, ‘필요하시면’ 안내 등 말미 부가 안내도 포함하지 않습니다." - ); - } + lines.push("【작성 규칙】"); + lines.push("1. 언어: 구어체 입력이라도 출력은 반드시 공식 문어체 존댓말로 변환합니다."); + lines.push("2. 정보 보완: 참석자·일시 등 누락 정보는 ※ 확인 필요 주석으로 표시합니다."); + lines.push("3. 맥락 추론: 발언 **의도·내용**이 불명확한 경우에 한해 전후 문맥으로 추론하되, 추론 시 (추정) 표기합니다. 단, 발언 **주체(사람·팀·조직)**는 추론하여 만들지 마세요. **단, 【6) 액션 아이템】의 「담당」열에서는** 이름·실제 조직·팀명이 없으면 반드시 **미정**(또는 TBD); 「발언자」「우리 팀」「저희」 같은 지칭은 **담당 열 금지**입니다. 회의 진행·Q&A 본문 등 다른 곳에서는 원문 호칭을 유지할 수 있습니다."); + lines.push("4. 중립성 유지: 특정 발언자에 유리하거나 불리한 방향으로 편집하지 않습니다."); + lines.push("5. 비고 처리: 원문에서 명확히 파악되지 않는 사항(발언자 미상, 참석자 불명, 담당자 미확인 등)은 해당 섹션 내부 또는 섹션 6 아래에 `※ 비고` 평문 각주로만 표기합니다. 반드시 `##` 제목의 별도 섹션으로 만들지 마세요."); lines.push( - "- ‘추가 권고’, ‘회의록 작성자의 제안’, 엑셀 담당자 배정 템플릿·추적 체크리스트 제안 등 **회의 본문과 무관한 조언·제안 섹션**을 두지 마세요." + '6. 표 형식: 4)·5)·6)·체크리스트 등 **표가 필요한 섹션**에서만 마크다운 표를 씁니다. 구분 줄은 각 열마다 `---`만(정렬 마커 `:---` 등 금지). **행마다 줄 선두·줄 말미에 반드시 `|` 를 둘 것.** **6) 액션 아이템 은 헤더 4열 고정 및 위 예시 규격 엄수.** **2) 참석·언급 인원은 표 없이 쉼표 목록 한 줄입니다.**' ); + + lines.push(""); + lines.push("【금지 사항】"); + lines.push("- 음성 전사·회의 원문 전체를 회의록 본문에 다시 붙여 넣지 마세요. 원문/전사는 시스템에서 별도 필드로 이미 보관됩니다."); + lines.push("- '스크립트', '스크랩트'(오타), '원문 전사', '전사문', 'Verbatim' 등 원문을 통째로 실어 나르는 섹션을 만들지 마세요."); + lines.push("- '추가 권고', '회의록 작성자의 제안', 엑셀 담당자 배정 템플릿·추적 체크리스트 제안 등 회의 본문과 무관한 조언·제안 섹션을 두지 마세요."); + lines.push("- 액션 아이템 이후에 `##` 제목의 추가 섹션(예: '추가 메모', '확인 필요 사항')을 두지 마세요. 불명확 사항은 위 규칙 5(※ 비고 평문 각주)로만 처리하세요."); + lines.push( + '- 액션 아이템 표 **「담당」열**에는 실명 또는 원문 기준 실제 조직·팀명 외 문자열 금지(「발언자」「저희」「우리 팀」「우리」 호칭 전용 포함).' + ); + lines.push("- (일반) 원문에 없는 인명·팀명을 문맥으로 추측하여 괄호로 채워 넣는 것(예: '우리 팀(운영팀)')은 회의 진행 요약 등에도 두지 마십시오."); + if (!includeChecklist) { + lines.push("- '## 회의 체크리스트', '## 후속 확인 체크리스트' 같은 체크리스트 전용 섹션을 만들지 마세요."); + } + if (custom.trim()) { lines.push(""); lines.push("사용자 추가 지시:"); lines.push(custom.trim()); } - if (!includeChecklist) { - lines.push(""); - lines.push( - "【출력에서 제외(최종)】`## 회의 체크리스트`, `## 후속 확인 체크리스트` 같은 체크리스트 전용 제목, 그 아래 `- [ ]`·불릿 목록, 회의 본문 끝의 괄호 메타 문장(예: 체크리스트를 마지막으로 작성)은 넣지 마세요." - ); - } + return lines.join("\n"); } - /** * 스크립트/스크랩트(오타) 등 원문 통째 반복 섹션의 제목인지 (마크다운 # 제목) * @param {string} title @@ -536,6 +570,286 @@ function stripAllMeetingChecklistSectionBlocks(markdown) { return md; } +/** + * 액션 표「담당」셀이 일반 호칭·지칭이면 업무 규격에 맞게 **미정**으로 바꿉니다. + */ +function sanitizeActionItemAssigneeCell(assigneeRaw) { + const vOrig = String(assigneeRaw ?? "") + .trim() + .replace(/\s+/g, " ") + .replace(/|/g, "|"); + if (!vOrig) return "미정"; + if (/^미정$/i.test(vOrig)) return "미정"; + if (/^TBD$/i.test(vOrig)) return "TBD"; + + /** @param {string} seg */ + const segmentIsForbiddenGeneric = (seg) => { + const v = seg.replace(/\([^)]*\)/g, "").trim(); + if (!v) return true; + return ( + /발언자|질문자|발언측/i.test(v) || + /^저희(\s+[팀회사])*$/u.test(v) || + /^우리\s*$/u.test(v) || + /^우리\s+(팀|회사)$/u.test(v) || + /^당사$/u.test(v) || + /^해당\s*부서$/u.test(v) || + /^본인$/u.test(v) || + /^미상$/u.test(v) || + /^담당자\s*미상$/u.test(v) || + /^직원$/u.test(v) || + /^(엔지니어|개발\s*담당자?|디자이너|기획자|PM|P\.M\.|QA|테스터|운영)$/iu.test(v) + ); + }; + + const segments = vOrig + .split(/[//]/) + .map((p) => p.trim()) + .filter(Boolean); + + if (segments.length === 0) return "미정"; + /** "인사팀 / 발언자"처럼 금지 조각이 섞여 있어도 담당 특정 불가로 통일합니다 */ + if (segments.some(segmentIsForbiddenGeneric)) return "미정"; + + return vOrig; +} + +function rebuildMarkdownTableRowFromCells(cells) { + const parts = cells.map((c) => String(c ?? "").trim()); + return "| " + parts.join(" | ") + " |"; +} + +/** + * 【6) 액션 아이템】 블록 안 표 데이터 행(첫 칸 순번 숫자)의 담당 열만 정제합니다. + * @param {string} markdown + * @returns {string} + */ +function sanitizeActionItemAssigneesInMarkdown(markdown) { + const lines = String(markdown || "").split(/\r?\n/); + /** @type {string[]} */ + const out = []; + let i = 0; + + const canonLn = (ln) => String(ln || "").trimEnd().replace(/|/g, "|"); + + while (i < lines.length) { + const raw = lines[i]; + const ts = raw.trim(); + + if (/^##\s+/.test(ts)) { + out.push(raw); + const isActionHeading = /액션\s*아이템/.test(ts) || /^##\s+Action\s+Items\b/i.test(ts); + if (!isActionHeading) { + i++; + continue; + } + + i++; + let secEnd = lines.length; + for (let k = i; k < lines.length; k++) { + const v = lines[k].trim(); + if (v && /^##\s+/.test(v)) { + secEnd = k; + break; + } + } + + let jj = i; + while (jj < secEnd) { + if (canonLn(lines[jj]).trim() === "") { + out.push(lines[jj]); + jj++; + continue; + } + const trimmed = canonLn(lines[jj]).trim(); + const pipeRow = + trimmed.startsWith("|") || (/^\d+\s*\|/.test(trimmed) && !trimmed.startsWith("|")); + if (!pipeRow) { + out.push(lines[jj]); + jj++; + continue; + } + if (parseCkFromMm.isTableSeparatorRow(trimmed)) { + out.push(lines[jj]); + jj++; + continue; + } + + const cells = parseCkFromMm.parseTableCells(lines[jj]); + if ( + cells.length >= 4 && + /^\d+$/.test(String(cells[0] || "").trim()) + ) { + const assigneeIx = 1; + const next = [...cells]; + next[assigneeIx] = sanitizeActionItemAssigneeCell(next[assigneeIx]); + out.push(rebuildMarkdownTableRowFromCells(next)); + jj++; + } else { + out.push(lines[jj]); + jj++; + } + } + i = secEnd; + continue; + } + + out.push(raw); + i++; + } + + return out.join("\n"); +} + +/** + * GFM 규격: 헤더·구분·데이터 열 개수 불일치로 깨진 액션 아이템 파이프블록을 교정합니다. + * `prepareMeetingMinutesForApi`에서 호출되어 저장·표시 전에 적용됩니다. + * + * @param {string[]} lines 전체 줄 + * @param {number} start 의심 표 첫 줄(헤더) + * @param {number} sectionEndExclusive 같은 절 안에서 다음 `##` 직전 인덱스 + * @returns {{ replacement: string[], nextIndex: number } | null} + */ +function tryNormalizeBrokenActionItemTable(lines, start, sectionEndExclusive) { + /** @param {string} ln */ + const canon = (ln) => String(ln || "").trimEnd().replace(/|/g, "|"); + + const headerTrim = canon(lines[start]).trim(); + if ( + !headerTrim.includes("|") || + !headerTrim.includes("담당") || + !headerTrim.includes("내용") || + !headerTrim.includes("기한") + ) { + return null; + } + + const hdrCells = parseCkFromMm.parseTableCells(lines[start]); + if (!hdrCells || hdrCells.length !== 3) return null; + + let j = start + 1; + /** @type {string[]} */ + const leadingBlankLines = []; + while (j < sectionEndExclusive) { + const u = canon(lines[j]).trim(); + if (u !== "") break; + leadingBlankLines.push(lines[j]); + j++; + } + + if (j < sectionEndExclusive) { + const maybeSep = canon(lines[j]).trim(); + if (parseCkFromMm.isTableSeparatorRow(maybeSep)) j++; + } + + if (j >= sectionEndExclusive) return null; + + const firstCells = parseCkFromMm.parseTableCells(lines[j]); + if ( + !firstCells || + firstCells.length < 4 || + !/^\d+$/.test(String(firstCells[0] || "").trim()) + ) { + return null; + } + + /** @type {string[]} */ + const rebuilt = []; + rebuilt.push("| # | 담당 | 내용 | 기한 |"); + rebuilt.push("| --- | --- | --- | --- |"); + + let p = j; + while (p < sectionEndExclusive) { + const cand = canon(lines[p]).trim(); + if (!cand) break; + if (cand.startsWith("※")) break; + + const cells = parseCkFromMm.parseTableCells(lines[p]); + if (!cells || cells.length < 4 || !/^\d+$/.test(String(cells[0] || "").trim())) break; + + const num = String(cells[0]).trim(); + const assignee = String(cells[1] || "").trim(); + const due = String(cells[cells.length - 1] || "").trim(); + const body = cells + .slice(2, cells.length - 1) + .map((c) => c.trim()) + .join(" ") + .replace(/\s+/g, " ") + .trim(); + + rebuilt.push(`| ${num} | ${sanitizeActionItemAssigneeCell(assignee)} | ${body} | ${due} |`); + p++; + } + + if (rebuilt.length < 3) return null; + + return { + replacement: [...leadingBlankLines, ...rebuilt], + nextIndex: p, + }; +} + +/** + * 「## … 액션 아이템」(Action Items) 다음 절에서 위 깨진 표만 반복 교정합니다. + * @param {string} markdown + * @returns {string} + */ +function normalizeActionItemsMarkdownTables(markdown) { + const lines = String(markdown || "").split(/\r?\n/); + /** @param {string} ln */ + const canonLn = (ln) => String(ln || "").trimEnd().replace(/|/g, "|"); + /** @type {string[]} */ + const out = []; + let i = 0; + + while (i < lines.length) { + const raw = lines[i]; + const ts = raw.trim(); + + if (/^##\s+/.test(ts)) { + out.push(raw); + const isActionHeading = /액션\s*아이템/.test(ts) || /^##\s+Action\s+Items\b/i.test(ts); + if (!isActionHeading) { + i++; + continue; + } + + i++; + let secEnd = lines.length; + for (let k = i; k < lines.length; k++) { + const v = lines[k].trim(); + if (v && /^##\s+/.test(v)) { + secEnd = k; + break; + } + } + + let jj = i; + while (jj < secEnd) { + if (canonLn(lines[jj]).trim() === "") { + out.push(lines[jj]); + jj++; + continue; + } + const patch = tryNormalizeBrokenActionItemTable(lines, jj, secEnd); + if (patch) { + patch.replacement.forEach((ln) => out.push(ln)); + jj = patch.nextIndex; + } else { + out.push(lines[jj]); + jj++; + } + } + i = secEnd; + continue; + } + + out.push(raw); + i++; + } + + return out.join("\n"); +} + /** * API·저장·생성 공통: 스크립트 제거 → (옵션) 체크리스트 섹션 삭제 → 체크리스트 이후 말미 정리 → … * @param {string} markdown @@ -550,6 +864,8 @@ function prepareMeetingMinutesForApi(markdown, options = {}) { md = stripTrailingAfterMeetingChecklistSection(md); md = removeKnownBoilerplateLines(md); md = stripTrailingJunkSectionsFromStart(md); + md = normalizeActionItemsMarkdownTables(md); + md = sanitizeActionItemAssigneesInMarkdown(md); return enhanceMeetingMinutesHeadingLines(md); } @@ -653,18 +969,23 @@ async function ffmpegSplitAudioForTranscription(inputPath, segmentSeconds) { } /** + * ffmpeg 분할 후 각 구간 파일을 순서대로 전사해 합칩니다(OpenAI 호출 순차 처리). * @param {import("openai").default} openai * @param {string} filePath * @param {string} [uiModel] * @param {number} [segmentSeconds] - ffmpeg 분할 길이(초) + * @param {(p: {stage: string, done: number, total: number}) => void} [onProgress] */ -async function transcribeMeetingAudioChunked(openai, filePath, uiModel, segmentSeconds) { +async function transcribeMeetingAudioChunked(openai, filePath, uiModel, segmentSeconds, onProgress) { const { files, tmpDir } = await ffmpegSplitAudioForTranscription(filePath, segmentSeconds); + if (onProgress) onProgress({ stage: "init", done: 0, total: files.length }); try { const parts = []; - for (const fp of files) { - const text = await transcribeMeetingAudioOnce(openai, fp, uiModel); + for (let i = 0; i < files.length; i++) { + const text = await transcribeMeetingAudioOnce(openai, files[i], uiModel); parts.push(text); + const done = i + 1; + if (onProgress) onProgress({ stage: "transcribe", done, total: files.length }); } return parts.join("\n\n").trim(); } finally { @@ -677,13 +998,14 @@ async function transcribeMeetingAudioChunked(openai, filePath, uiModel, segmentS } /** - * 단일 파일 전사. OpenAI 요청당 한도(약 25MB)를 넘으면 ffmpeg로 분할 후 순차 전사. - * gpt-4o 전사 계열은 오디오 토큰 상한으로, 용량이 작아도 전체를 한 요청에 넣으면 400이 날 수 있어 짧은 구간으로 분할한다. + * 단일 파일 전사. OpenAI 요청당 한도(약 25MB)를 넘으면 ffmpeg로 분할 후 구간 순차 전사. + * gpt-4o 전사 계열은 오디오 토큰 상한으로, 용량이 작아도 한 파일 전체를 한 번에 보내면 400이 날 수 있어 짧게 분할한다. * @param {import("openai").default} openai * @param {string} filePath * @param {string} [uiModel] - gpt-4o-mini-transcribe | gpt-4o-transcribe + * @param {(p: {stage: string, done: number, total: number}) => void} [onProgress] */ -async function transcribeMeetingAudio(openai, filePath, uiModel = DEFAULT_TRANSCRIPTION_MODEL) { +async function transcribeMeetingAudio(openai, filePath, uiModel = DEFAULT_TRANSCRIPTION_MODEL, onProgress) { const apiModel = resolveTranscriptionApiModel(uiModel); const isGpt4oStyleTranscribe = apiModel.startsWith("gpt-4o") && apiModel.includes("transcribe") && !apiModel.includes("diarize"); @@ -691,13 +1013,16 @@ async function transcribeMeetingAudio(openai, filePath, uiModel = DEFAULT_TRANSC const gpt4oSeg = getGpt4oTranscribeSegmentSeconds(); if (isGpt4oStyleTranscribe) { - return transcribeMeetingAudioChunked(openai, filePath, uiModel, gpt4oSeg); + return transcribeMeetingAudioChunked(openai, filePath, uiModel, gpt4oSeg, onProgress); } if (size <= SAFE_SINGLE_REQUEST_BYTES) { - return transcribeMeetingAudioOnce(openai, filePath, uiModel); + if (onProgress) onProgress({ stage: "init", done: 0, total: 1 }); + const result = await transcribeMeetingAudioOnce(openai, filePath, uiModel); + if (onProgress) onProgress({ stage: "transcribe", done: 1, total: 1 }); + return result; } - return transcribeMeetingAudioChunked(openai, filePath, uiModel); + return transcribeMeetingAudioChunked(openai, filePath, uiModel, undefined, onProgress); } /** @@ -709,7 +1034,7 @@ async function transcribeMeetingAudio(openai, filePath, uiModel = DEFAULT_TRANSC * @param {(m: string) => string} opts.resolveApiModel */ async function generateMeetingMinutes(openai, { systemPrompt, userContent, uiModel, resolveApiModel, omitMeetingChecklistSection }) { - const apiModel = resolveApiModel(uiModel || "gpt-5-mini"); + const apiModel = resolveApiModel(uiModel || "gpt-5.4"); const namePrefix = meetingEmployeeNames.buildNameNormalizationUserPrefix(userContent); const userPayload = namePrefix ? `${namePrefix}${userContent}` : userContent; const completion = await openai.chat.completions.create({ @@ -730,11 +1055,14 @@ const CHECKLIST_EXTRACT_SYSTEM = `You extract actionable work items from Korean 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. +1) **Action Items / 액션 아이템** sections (headings like "## 6) 액션 아이템" or "## 액션 아이템"): + - If the section contains a **Markdown table**: expect **four columns**: # | 담당 | 내용 | 기한 (header may mistakenly omit the first column '#' / 순번, but data rows often have numeric first cells — map columns by header names when clear, otherwise treat consecutive cells as 순번→담당→내용→기한). + - **Each logical table row becomes a separate item.** Map **내용** column → title, **담당** → assignee, **기한** → dueNote. Leading **#**(순번) is not the title unless 내용 column is absent. + - If the section contains **numbered lines** (1. 2. 3. or 1) 2) 3)): each numbered line becomes a separate item. Title = the line's main task name; put 담당/기한/할 일 sub-lines into assignee, dueNote, detail. 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. +- For assignee (담당): use only literal person names or real org or team names as printed in those minutes or table cells. Put an empty assignee string or 미정 when the cell would otherwise be placeholders such as 발언자, 저희, 우리 팀, 우리 alone, or 해당 부서. Do not invent names. +- Do not merge multiple rows or numbered actions into one item. - Deduplicate only exact duplicate titles. - If nothing found, return {"items":[]}. - All human-readable text in Korean.`; @@ -746,7 +1074,7 @@ Priority (Action Items are as important as checklists — they are real work): * @returns {Promise>} */ async function extractChecklistStructured(openai, { minutesMarkdown, uiModel, resolveApiModel }) { - const apiModel = resolveApiModel(uiModel || "gpt-5-mini"); + const apiModel = resolveApiModel(uiModel || "gpt-5.4"); const text = (minutesMarkdown || "").trim(); if (!text) return []; const completion = await openai.chat.completions.create({ @@ -758,7 +1086,7 @@ async function extractChecklistStructured(openai, { minutesMarkdown, uiModel, re role: "user", content: "아래 회의록에서 업무 항목을 JSON으로 추출하세요.\n" + - "액션 아이템(번호 목록)과 회의 체크리스트 항목을 모두 포함하고, 번호마다 별도 item으로 나누세요.\n\n" + + "액션 아이템(마크다운 표 또는 번호 목록)과 회의 체크리스트 항목을 모두 포함하고, 행·번호마다 별도 item으로 나누세요.\n\n" + "---\n\n" + text, }, diff --git a/lib/mgmt-perf.js b/lib/mgmt-perf.js index 4d1601e..2d94815 100644 --- a/lib/mgmt-perf.js +++ b/lib/mgmt-perf.js @@ -13,7 +13,7 @@ function loadXlsx() { } const ROOT = path.join(__dirname, ".."); -const DEFAULT_PAYLOAD_PATH = path.join(ROOT, "data", "mgmt-perf-default-payload.json"); +const DEFAULT_PAYLOAD_PATH = path.join(ROOT, "config", "mgmt-perf-default-payload.json"); const FILE_STATE_PATH = path.join(ROOT, "data", "mgmt-perf-last-state.json"); function loadDefaultPayload() { diff --git a/lib/ops-session-revoke.js b/lib/ops-session-revoke.js new file mode 100644 index 0000000..c090cf0 --- /dev/null +++ b/lib/ops-session-revoke.js @@ -0,0 +1,115 @@ +/** + * OPS 이메일 세션 전체 무효화(모든 디바이스 로그아웃) + * 쿠키에 iat(발급 시각)를 넣고, DB sessions_revoked_at 이후 iat만 유효하게 한다. + */ + +const REVOKE_FILE = "ops-session-revocations.json"; + +/** + * @param {string} email + * @returns {string} + */ +function normalizeEmail(email) { + return String(email || "") + .trim() + .toLowerCase() + .slice(0, 320); +} + +/** + * @param {import('pg').Pool | null | undefined} pgPool + * @param {string} dataDir + * @param {string} email + * @returns {Promise} revoked at epoch ms, 0 if never revoked + */ +async function getSessionsRevokedAtMs(pgPool, dataDir, email) { + const e = normalizeEmail(email); + if (!e) return 0; + + if (pgPool) { + try { + const r = await pgPool.query( + `SELECT sessions_revoked_at FROM ops_email_users WHERE email = $1`, + [e] + ); + const ts = r.rows?.[0]?.sessions_revoked_at; + return ts ? new Date(ts).getTime() : 0; + } catch (err) { + console.error("[ops-session-revoke] get failed:", err.message); + return 0; + } + } + + try { + const fs = require("fs"); + const path = require("path"); + const fp = path.join(dataDir, REVOKE_FILE); + if (!fs.existsSync(fp)) return 0; + const raw = fs.readFileSync(fp, "utf8"); + const map = JSON.parse(raw); + const v = map?.[e]; + return typeof v === "number" && Number.isFinite(v) ? v : 0; + } catch { + return 0; + } +} + +/** + * @param {import('pg').Pool | null | undefined} pgPool + * @param {string} dataDir + * @param {string} email + * @param {number} iatMs + * @returns {Promise} + */ +async function isOpsSessionRevoked(pgPool, dataDir, email, iatMs) { + const revokedAt = await getSessionsRevokedAtMs(pgPool, dataDir, email); + if (!revokedAt) return false; + const iat = typeof iatMs === "number" && Number.isFinite(iatMs) ? iatMs : 0; + return iat <= revokedAt; +} + +/** + * @param {import('pg').Pool | null | undefined} pgPool + * @param {string} dataDir + * @param {string} email + * @returns {Promise<{ revokedAtMs: number }>} + */ +async function revokeAllOpsSessionsForEmail(pgPool, dataDir, email) { + const e = normalizeEmail(email); + if (!e) { + throw new Error("email is required"); + } + const revokedAtMs = Date.now(); + + if (pgPool) { + await pgPool.query( + `INSERT INTO ops_email_users (email, first_seen_at, last_login_at, login_count, sessions_revoked_at) + VALUES ($1, NOW(), NOW(), 0, to_timestamp($2 / 1000.0)) + ON CONFLICT (email) DO UPDATE SET sessions_revoked_at = EXCLUDED.sessions_revoked_at`, + [e, revokedAtMs] + ); + return { revokedAtMs }; + } + + const fs = require("fs"); + const path = require("path"); + const fp = path.join(dataDir, REVOKE_FILE); + let map = {}; + try { + if (fs.existsSync(fp)) { + map = JSON.parse(fs.readFileSync(fp, "utf8")) || {}; + } + } catch { + map = {}; + } + map[e] = revokedAtMs; + fs.mkdirSync(path.dirname(fp), { recursive: true }); + fs.writeFileSync(fp, JSON.stringify(map, null, 2), "utf8"); + return { revokedAtMs }; +} + +module.exports = { + getSessionsRevokedAtMs, + isOpsSessionRevoked, + revokeAllOpsSessionsForEmail, +}; diff --git a/lib/ops-state.js b/lib/ops-state.js index 9a4efee..c6ce901 100644 --- a/lib/ops-state.js +++ b/lib/ops-state.js @@ -21,7 +21,7 @@ function isOpsStateSuper() { return normalizeOpsState() === "SUPER"; } -/** 임직원 이메일(@xavis.co.kr) 매직 링크 로그인을 강제하는 모드 (REAL 구 값 포함) */ +/** 임직원 이메일(@ncue.net) 매직 링크 로그인을 강제하는 모드 (REAL 구 값 포함) */ function isOpsProdMode() { return isOpsStateProd(); } diff --git a/lib/parse-checklist-from-minutes.js b/lib/parse-checklist-from-minutes.js index cf9b465..c6a5a55 100644 --- a/lib/parse-checklist-from-minutes.js +++ b/lib/parse-checklist-from-minutes.js @@ -142,9 +142,16 @@ function parseBulletItems(body) { cur = null; } }; + let inFootnote = false; for (const raw of lines) { const t = raw.trim(); if (!t) continue; + if (/^※/.test(t)) { + inFootnote = true; + flush(); + continue; + } + if (inFootnote) continue; let m = t.match(/^\s*[-*•]\s+\[([ xX✓])\]\s*(.+)$/); if (m) { flush(); @@ -205,6 +212,95 @@ function parseBulletItems(body) { return items.filter((x) => x.title.length > 0); } +/** + * 마크다운 표 행에서 셀 배열 추출 (`| a | b | c |` → ["a","b","c"]) + * @param {string} line + * @returns {string[]} + */ +function parseTableCells(line) { + let s = String(line || "").trim(); + if (/^\d+\s*\|/.test(s) && !s.startsWith("|")) s = "| " + s; + return s + .replace(/^\s*\|\s*/, "") + .replace(/\s*\|\s*$/, "") + .split("|") + .map((cell) => cell.trim()); +} + +/** + * 마크다운 표 구분선 행인지 판별 (각 셀이 `-`, `:`, 공백만 포함) + * @param {string} t - trim된 행 + */ +function isTableSeparatorRow(t) { + const cells = t.replace(/^\s*\|\s*/, "").replace(/\s*\|\s*$/, "").split("|"); + return cells.length > 0 && cells.every((c) => /^[\s:]*-+[\s:-]*$/.test(c.trim()) || c.trim() === ""); +} + +/** + * 마크다운 표 형식 액션 아이템 파싱 (# | 담당 | 내용 | 기한 구조) + * @param {string} body + * @returns {ParsedItem[]} + */ +function parseTableActionRows(body) { + if (!body || !body.trim()) return []; + const lines = body.split(/\r?\n/); + /** @type {string[]|null} */ + let headers = null; + /** @type {ParsedItem[]} */ + const out = []; + for (const raw of lines) { + const t = raw.trim(); + if (!t) continue; + const isPipeRow = /^\|/.test(t) || /^\d+\s*\|/.test(t); + if (!isPipeRow) continue; + const tSep = /^\d+\s*\|/.test(t) && !t.startsWith("|") ? "| " + t : t; + if (isTableSeparatorRow(tSep)) continue; + const cells = parseTableCells(t); + if (!cells.length) continue; + if (headers === null) { + headers = cells.map((h) => h.toLowerCase()); + continue; + } + const get = (keywords) => { + for (const kw of keywords) { + const idx = headers.findIndex((h) => h.includes(kw)); + if (idx !== -1 && idx < cells.length) return cells[idx].trim(); + } + return ""; + }; + let title = get(["내용", "항목", "task", "할 일"]); + let assignee = get(["담당", "assignee", "담당자"]); + let due_note = get(["기한", "due", "마감"]); + + const numFirstCell = + cells.length >= 4 && /^\d+$/.test(String(cells[0] || "").trim()); + const headerHasOrdinalCol = headers.some((h) => { + const x = String(h || "").trim().toLowerCase(); + return x === "#" || x === "no" || /^no\.?$/.test(x) || x.includes("순번") || x.includes("번호"); + }); + /** 헤더에 '#'/순번 열이 빠져 있으나 데이터 줄은 순번 포함 4열 — 담당·내용·기한 순으로 해석 */ + if (numFirstCell && !headerHasOrdinalCol && headers.length >= 3 && headers.length <= cells.length - 1) { + assignee = String(cells[1] || "").trim(); + due_note = String(cells[cells.length - 1] || "").trim(); + title = cells + .slice(2, cells.length - 1) + .join(" ") + .replace(/\s*\|\s*/g, " ") + .trim(); + } + + if (!title) continue; + out.push({ + title: stripMd(title), + detail: "", + assignee: assignee || null, + due_note: due_note || null, + completed: false, + }); + } + return out.filter((x) => x.title.length > 0); +} + /** * 액션 아이템 번호 목록 블록 (담당/기한/할 일) * @param {string} body @@ -329,6 +425,8 @@ function parseItemsFromMinutes(generatedMinutes, mode = "checklist") { if (mode === "actions") { const section = extractActionSectionBody(text); if (!section) return []; + const tableRows = parseTableActionRows(section); + if (tableRows.length) return tableRows; const numbered = parseNumberedActionBlocks(section); if (numbered.length) return numbered; return parseBulletItems(section); @@ -345,6 +443,9 @@ module.exports = { extractActionSectionBody, parseBulletItems, parseNumberedActionBlocks, + parseTableCells, + parseTableActionRows, + isTableSeparatorRow, parseItemsFromMinutes, parseAllRuleBasedWorkItems, CHECKLIST_HEADINGS, diff --git a/lib/prompt-library.js b/lib/prompt-library.js new file mode 100644 index 0000000..7651d71 --- /dev/null +++ b/lib/prompt-library.js @@ -0,0 +1,334 @@ +/** + * 프롬프트 라이브러리(공식 JSON + DB 커뮤니티/좋아요) + */ +"use strict"; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +function maskEmailForDisplay(email) { + const s = String(email || "") + .trim() + .toLowerCase(); + const at = s.indexOf("@"); + if (at < 1) return "팀원"; + const local = s.slice(0, at); + const dom = s.slice(at); + if (local.length <= 2) { + return `${local[0] || "*"}*${dom}`; + } + return `${local[0]}**${local.slice(-1)}${dom}`; +} + +/** @ 사인 앞 로컬부(예: spark_ai@ncue.net → spark_ai) — 라이브러리 카드·메타 표시용 */ +function emailLocalPartForDisplay(email) { + const s = String(email || "").trim(); + if (!s) return "팀원"; + const at = s.indexOf("@"); + if (at < 1) { + const t = s.slice(0, 80); + return t || "팀원"; + } + const local = s.slice(0, at); + return (local && local.trim()) || "팀원"; +} + +/** + * @param {import("pg").Pool} pool + * @param {Array<{id:string,title?:string,description?:string,tag?:string,body?:string}>} officialPrompts + * @param {string | null} userEmail + */ +async function getLibraryData(pool, officialPrompts, userEmail) { + const official = Array.isArray(officialPrompts) ? officialPrompts : []; + const officialIds = official.map((p) => p.id).filter((id) => id && String(id).length < 200); + + if (!pool) { + return { + hasDb: false, + community: [], + likeCountOfficial: {}, + likeCountCommunity: {}, + myOfficialLikes: [], + myCommunityLikes: [], + mySubmissions: [], + }; + } + + const [commRes, offCntRes, myLikesRes] = await Promise.all([ + pool.query( + `SELECT id, author_email, title, description, body, tag, created_at, prompt_attachments, result_sample_attachments + FROM prompt_community_entries + WHERE is_published = true AND is_deleted = false + ORDER BY created_at DESC + LIMIT 300` + ), + officialIds.length + ? pool.query( + `SELECT target_id, COUNT(*)::int AS c + FROM prompt_likes + WHERE target_kind = 'official' AND target_id = ANY($1::text[]) + GROUP BY target_id`, + [officialIds] + ) + : Promise.resolve({ rows: [] }), + userEmail + ? pool.query( + `SELECT target_kind, target_id FROM prompt_likes WHERE user_email = $1`, + [userEmail] + ) + : Promise.resolve({ rows: [] }), + ]); + + const commIds = commRes.rows.map((r) => r.id); + let commCntRes = { rows: [] }; + if (commIds.length) { + commCntRes = await pool.query( + `SELECT target_id, COUNT(*)::int AS c + FROM prompt_likes + WHERE target_kind = 'community' AND target_id = ANY($1::text[]) + GROUP BY target_id`, + [commIds.map((id) => String(id))] + ); + } + + const likeCountOfficial = {}; + for (const r of offCntRes.rows) { + likeCountOfficial[r.target_id] = r.c; + } + const likeCountCommunity = {}; + for (const r of commCntRes.rows) { + likeCountCommunity[r.target_id] = r.c; + } + + const myOfficialLikes = []; + const myCommunityLikes = []; + for (const r of myLikesRes.rows) { + if (r.target_kind === "official") myOfficialLikes.push(r.target_id); + else if (r.target_kind === "community") myCommunityLikes.push(r.target_id); + } + + let mySubmissions = []; + if (userEmail) { + const mine = await pool.query( + `SELECT id, title, created_at + FROM prompt_community_entries + WHERE author_email = $1 AND is_deleted = false + ORDER BY created_at DESC + LIMIT 100`, + [userEmail] + ); + mySubmissions = (mine.rows || []).map((row) => ({ + id: String(row.id), + title: (row.title || "").trim() || "제목 없음", + createdAt: row.created_at ? new Date(row.created_at).toISOString() : "", + })); + } + + const community = (commRes.rows || []).map((row) => { + const parseFileJson = (raw) => { + try { + const j = typeof raw === "string" ? JSON.parse(raw) : raw; + return Array.isArray(j) ? j : []; + } catch { + return []; + } + }; + return { + id: String(row.id), + title: (row.title || "").trim() || "제목 없음", + description: String(row.description || "").trim(), + body: String(row.body || ""), + tag: (row.tag || "기타").trim() || "기타", + authorLabel: emailLocalPartForDisplay(row.author_email), + likeCount: likeCountCommunity[String(row.id)] || 0, + createdAt: row.created_at ? new Date(row.created_at).toISOString() : "", + promptFiles: parseFileJson(row.prompt_attachments).filter((f) => f && f.relativePath), + resultFiles: parseFileJson(row.result_sample_attachments).filter((f) => f && f.relativePath), + }; + }); + + return { + hasDb: true, + community, + likeCountOfficial, + likeCountCommunity, + myOfficialLikes, + myCommunityLikes, + mySubmissions, + }; +} + +/** + * @param {import("pg").Pool} pool + * @param {string} userEmail + * @param {string} kind + * @param {string} targetId + * @param {Set} officialIdSet + */ +async function toggleLike(pool, userEmail, kind, targetId, officialIdSet) { + if (!["official", "community"].includes(kind)) { + const err = new Error("target_kind"); + err.code = "VALIDATION"; + throw err; + } + const id = String(targetId || "").trim(); + if (!id) { + const err = new Error("target_id"); + err.code = "VALIDATION"; + throw err; + } + if (kind === "official" && !officialIdSet.has(id)) { + const err = new Error("알 수 없는 공식 프롬프트입니다."); + err.code = "VALIDATION"; + throw err; + } + if (kind === "community" && !UUID_RE.test(id)) { + const err = new Error("잘못된 ID입니다."); + err.code = "VALIDATION"; + throw err; + } + if (kind === "community") { + const r = await pool.query( + `SELECT 1 FROM prompt_community_entries WHERE id = $1::uuid AND is_deleted = false AND is_published = true LIMIT 1`, + [id] + ); + if (!r.rowCount) { + const err = new Error("삭제되었거나 없는 프롬프트입니다."); + err.code = "NOT_FOUND"; + throw err; + } + } + + const del = await pool.query( + `DELETE FROM prompt_likes + WHERE user_email = $1 AND target_kind = $2 AND target_id = $3 + RETURNING id`, + [userEmail, kind, id] + ); + if (del.rowCount) { + const cnt = await pool.query( + `SELECT COUNT(*)::int AS c FROM prompt_likes WHERE target_kind = $1 AND target_id = $2`, + [kind, id] + ); + return { liked: false, likeCount: cnt.rows[0].c || 0 }; + } + await pool.query( + `INSERT INTO prompt_likes (user_email, target_kind, target_id) VALUES ($1, $2, $3)`, + [userEmail, kind, id] + ); + const cnt = await pool.query( + `SELECT COUNT(*)::int AS c FROM prompt_likes WHERE target_kind = $1 AND target_id = $2`, + [kind, id] + ); + return { liked: true, likeCount: cnt.rows[0].c || 0 }; +} + +const MAX_TITLE = 500; +const MAX_DESC = 2000; +const MAX_BODY = 50000; +const MAX_TAG = 100; +const MAX_ATTACH_PER_GROUP = 5; +const MAX_ATTACH_BYTES = 20 * 1024 * 1024; + +/** + * @param {import("pg").Pool} pool + * @param {object} p + * @param {string} p.authorEmail + * @param {string} p.title + * @param {string} p.description + * @param {string} p.body + * @param {string} p.tag + * @param {Array<{ originalName: string, relativePath: string, size: number }>} [p.promptAttachments] + * @param {Array<{ originalName: string, relativePath: string, size: number }>} [p.resultSampleAttachments] + */ +function normalizeFileList(arr, maxN) { + if (!Array.isArray(arr) || !arr.length) return []; + return arr + .filter((a) => a && typeof a.relativePath === "string" && a.relativePath.startsWith("/uploads/")) + .slice(0, maxN) + .map((a) => ({ + originalName: String(a.originalName || "file").slice(0, 400), + relativePath: String(a.relativePath).slice(0, 2000), + size: Math.min(Number(a.size) || 0, MAX_ATTACH_BYTES * 2), + })); +} + +async function createCommunityEntry(pool, p) { + const title = String(p.title || "") + .trim() + .slice(0, MAX_TITLE); + const description = String(p.description || "") + .trim() + .slice(0, MAX_DESC); + const body = String(p.body || "").trim(); + const tag = String(p.tag || "기타") + .trim() + .slice(0, MAX_TAG) || "기타"; + if (!title) { + const e = new Error("제목을 입력해 주세요."); + e.code = "VALIDATION"; + throw e; + } + if (body.length < 10) { + const e = new Error("본문은 10자 이상 입력해 주세요."); + e.code = "VALIDATION"; + throw e; + } + if (body.length > MAX_BODY) { + const e = new Error(`본문은 ${MAX_BODY}자 이하여야 합니다.`); + e.code = "VALIDATION"; + throw e; + } + const promptA = normalizeFileList(p.promptAttachments, MAX_ATTACH_PER_GROUP); + const resultA = normalizeFileList(p.resultSampleAttachments, MAX_ATTACH_PER_GROUP); + const r = await pool.query( + `INSERT INTO prompt_community_entries (author_email, title, description, body, tag, prompt_attachments, result_sample_attachments) + VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb) + RETURNING id, created_at`, + [p.authorEmail, title, description, body, tag, JSON.stringify(promptA), JSON.stringify(resultA)] + ); + const row = r.rows[0]; + return { + id: String(row.id), + createdAt: row.created_at ? new Date(row.created_at).toISOString() : "", + promptFiles: promptA, + resultFiles: resultA, + }; +} + +/** + * @param {import("pg").Pool} pool + * @param {string} id + * @param {string} authorEmail + */ +async function softDeleteCommunityEntry(pool, id, authorEmail) { + if (!UUID_RE.test(String(id || "").trim())) { + const e = new Error("잘못된 ID입니다."); + e.code = "VALIDATION"; + throw e; + } + const r = await pool.query( + `UPDATE prompt_community_entries + SET is_deleted = true, is_published = false, updated_at = NOW() + WHERE id = $1::uuid AND author_email = $2 AND is_deleted = false + RETURNING id`, + [id, authorEmail] + ); + if (!r.rowCount) { + const e = new Error("삭제할 수 없습니다. 본인이 올린 글만 삭제할 수 있습니다."); + e.code = "NOT_FOUND"; + throw e; + } + return { ok: true }; +} + +module.exports = { + getLibraryData, + toggleLike, + createCommunityEntry, + softDeleteCommunityEntry, + maskEmailForDisplay, + emailLocalPartForDisplay, + UUID_RE, + MAX_ATTACH_PER_GROUP, + MAX_ATTACH_BYTES, +}; diff --git a/lib/sanitize-use-case-body.js b/lib/sanitize-use-case-body.js new file mode 100644 index 0000000..d5031a4 --- /dev/null +++ b/lib/sanitize-use-case-body.js @@ -0,0 +1,65 @@ +const sanitizeHtml = require("sanitize-html"); + +const SANITIZE_OPTIONS = { + allowedTags: [ + "p", + "br", + "div", + "span", + "strong", + "b", + "em", + "i", + "u", + "s", + "del", + "strike", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "ul", + "ol", + "li", + "blockquote", + "pre", + "code", + "hr", + "a", + "table", + "thead", + "tbody", + "tfoot", + "tr", + "th", + "td", + ], + allowedAttributes: { + a: ["href", "target", "rel", "name"], + th: ["colspan", "rowspan", "align"], + td: ["colspan", "rowspan", "align"], + }, + allowedSchemes: ["http", "https", "mailto", "tel"], + transformTags: { + a: (tagName, attribs) => { + const next = { ...attribs }; + if (next.target === "_blank") { + next.rel = (next.rel || "noopener") + (next.rel && next.rel.indexOf("noreferrer") >= 0 ? "" : " noreferrer"); + } + return { tagName, attribs: next }; + }, + }, +}; + +/** + * @param {string} html + * @returns {string} + */ +function sanitizeUseCaseBody(html) { + if (html == null) return ""; + return sanitizeHtml(String(html), SANITIZE_OPTIONS); +} + +module.exports = { sanitizeUseCaseBody }; diff --git a/lib/strip-for-count.js b/lib/strip-for-count.js new file mode 100644 index 0000000..b4a2d0e --- /dev/null +++ b/lib/strip-for-count.js @@ -0,0 +1,20 @@ +/** + * HTML·태그·엔티티를 제외한 보이는 텍스트(글자 수 제한용) + * — sanitize-html 없이 사용해 서버 기동 시 모듈 누락으로 전체 앱이 죽는 것을 막습니다. + * @param {string} html + * @returns {string} + */ +function stripForCount(html) { + if (!html) return ""; + return String(html) + .replace(//gi, " ") + .replace(//gi, " ") + .replace(/<[^>]+>/g, " ") + .replace(/ /g, " ") + .replace(/&#[0-9]+;/g, " ") + .replace(/&[a-zA-Z0-9]+;/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +module.exports = { stripForCount }; diff --git a/ops-auth.js b/ops-auth.js index c44acde..faafe5e 100644 --- a/ops-auth.js +++ b/ops-auth.js @@ -1,5 +1,5 @@ /** - * OPS_STATE=PROD(구 REAL) 일 때 이메일(@xavis.co.kr) 매직 링크 인증 + * OPS_STATE=PROD(구 REAL) 일 때 이메일(@ncue.net) 매직 링크 인증 */ const crypto = require("crypto"); const { isOpsProdMode } = require("./lib/ops-state"); @@ -15,10 +15,16 @@ try { } const OPS_AUTH_COOKIE = "ops_user_session"; -const ALLOWED_EMAIL_SUFFIX = "@xavis.co.kr"; +const ALLOWED_EMAIL_SUFFIX = "@ncue.net"; /** 메일 안 인증 링크 유효 시간(토큰 만료) */ const MAGIC_LINK_MAX_AGE_MS = 15 * 60 * 1000; +/** + * 만기 없음 모드일 때 Set-Cookie max-age 상한(브라우저·HTTP 한도; 서명 payload의 exp는 Number.MAX_SAFE_INTEGER) + * 10년마다 쿠키가 사라지면 다시 이메일 인증(매직 링크)이 필요할 수 있음 + */ +const OPS_SESSION_BROWSER_MAX_AGE_MS = 10 * 365 * 24 * 60 * 60 * 1000; + /** OPS_SESSION_TZ 기준 달력 날짜 키 (YYYY-MM-DD) */ function calendarDateKeyInTz(tsMs, tz) { const parts = new Intl.DateTimeFormat("en-CA", { @@ -80,16 +86,33 @@ function getLastMsOfCalendarDayInTz(dateKey, tz) { } /** - * 로그인 세션 만료: 로그인일(OPS_SESSION_TZ 달력) + OPS_SESSION_TTL_DAYS(기본 15)일의 마지막 순간 + * 로그인 세션 만료 시각(ms) + * - OPS_SESSION_TTL_DAYS: 미설정·0·never·none → 만기 없음(서명 exp = Number.MAX_SAFE_INTEGER) + * - 양의 정수 N → 로그인일(OPS_SESSION_TZ 달력) + N일의 마지막 순간 */ function getOpsSessionExpiresAtMs(nowMs = Date.now()) { const tz = (process.env.OPS_SESSION_TZ || "Asia/Seoul").trim() || "Asia/Seoul"; - const raw = (process.env.OPS_SESSION_TTL_DAYS || "15").trim(); + const raw = String(process.env.OPS_SESSION_TTL_DAYS ?? "0").trim() || "0"; + const lower = raw.toLowerCase(); + if (lower === "never" || lower === "none" || raw === "0") { + return Number.MAX_SAFE_INTEGER; + } const parsed = parseInt(raw, 10); - const ttlDays = Number.isFinite(parsed) && parsed >= 0 ? parsed : 15; - const loginDayKey = calendarDateKeyInTz(nowMs, tz); - const targetKey = addCalendarDaysToKey(loginDayKey, ttlDays); - return getLastMsOfCalendarDayInTz(targetKey, tz); + if (Number.isFinite(parsed) && parsed > 0) { + const loginDayKey = calendarDateKeyInTz(nowMs, tz); + const targetKey = addCalendarDaysToKey(loginDayKey, parsed); + return getLastMsOfCalendarDayInTz(targetKey, tz); + } + return Number.MAX_SAFE_INTEGER; +} + +/** res.cookie maxAge(ms) — 만기 없음일 때 astronomically large 값 대신 브라우저에 맞는 상한 */ +function getOpsSessionCookieMaxAgeMs(sessionExpMs) { + const remain = Math.max(0, sessionExpMs - Date.now()); + if (sessionExpMs === Number.MAX_SAFE_INTEGER) { + return Math.min(remain, OPS_SESSION_BROWSER_MAX_AGE_MS); + } + return remain; } function isOpsProd() { @@ -97,7 +120,7 @@ function isOpsProd() { } function getAuthSecret() { - return (process.env.AUTH_SECRET || process.env.ADMIN_TOKEN || "xavis-admin").trim(); + return (process.env.AUTH_SECRET || process.env.ADMIN_TOKEN || "ncue-admin").trim(); } function getBaseUrl() { @@ -119,9 +142,38 @@ function buildMagicVerifyUrl(baseUrl, token) { return new URL(path, `${base}/`).href; } +const RETURN_TO_DEFAULT = "/learning"; + +/** + * 인증 후·로그인 returnTo — 상대 경로만 허용(오픈 리다이렉트·한글 조사 등 오염 차단) + * @param {unknown} v + * @returns {string} + */ function sanitizeReturnTo(v) { const s = (v || "").toString().trim(); - if (!s.startsWith("/") || s.startsWith("//")) return "/learning"; + if (!s.startsWith("/") || s.startsWith("//")) return RETURN_TO_DEFAULT; + if (s.includes("\\") || s.includes("..")) return RETURN_TO_DEFAULT; + + const qIdx = s.indexOf("?"); + const hIdx = s.indexOf("#"); + let pathEnd = s.length; + if (qIdx >= 0) pathEnd = Math.min(pathEnd, qIdx); + if (hIdx >= 0) pathEnd = Math.min(pathEnd, hIdx); + const pathPart = s.slice(0, pathEnd); + const suffix = s.slice(pathEnd); + + if (pathPart !== "/" && !/^\/[a-zA-Z0-9][a-zA-Z0-9/_\-.]*$/.test(pathPart)) { + if (s !== RETURN_TO_DEFAULT) { + console.warn("[OPS] invalid returnTo path rejected:", s.slice(0, 160)); + } + return RETURN_TO_DEFAULT; + } + if (suffix && !/^[?#][a-zA-Z0-9_\-=&.%+]*$/.test(suffix)) { + if (s !== RETURN_TO_DEFAULT) { + console.warn("[OPS] invalid returnTo query rejected:", s.slice(0, 160)); + } + return RETURN_TO_DEFAULT; + } return s; } @@ -138,11 +190,12 @@ function isAllowedXavisEmail(email) { return s.endsWith(ALLOWED_EMAIL_SUFFIX); } -function signSessionCookie(email, expMs) { +function signSessionCookie(email, expMs, iatMs = Date.now()) { const exp = expMs; - const payload = `${email}|${exp}`; + const iat = iatMs; + const payload = `${email}|${exp}|${iat}`; const sig = crypto.createHmac("sha256", getAuthSecret()).update(payload).digest("hex"); - return Buffer.from(JSON.stringify({ email, exp, sig })).toString("base64url"); + return Buffer.from(JSON.stringify({ email, exp, iat, sig })).toString("base64url"); } function parseSessionCookie(val) { @@ -151,17 +204,41 @@ function parseSessionCookie(val) { 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(); + const email = String(j.email).toLowerCase(); + const exp = j.exp; + const iat = typeof j.iat === "number" ? j.iat : 0; + const payloadNew = `${email}|${exp}|${iat}`; + const sigNew = crypto.createHmac("sha256", getAuthSecret()).update(payloadNew).digest("hex"); + if (sigNew === j.sig) { + return { email, exp, iat }; + } + const payloadLegacy = `${email}|${exp}`; + const sigLegacy = crypto.createHmac("sha256", getAuthSecret()).update(payloadLegacy).digest("hex"); + if (sigLegacy === j.sig) { + return { email, exp, iat: 0 }; + } + return null; } catch { return null; } } function getOpsSessionEmail(req) { - return parseSessionCookie(req.cookies?.[OPS_AUTH_COOKIE]); + const session = parseSessionCookie(req.cookies?.[OPS_AUTH_COOKIE]); + return session ? session.email : null; +} + +async function resolveOpsSessionEmail(req, hooks) { + const session = parseSessionCookie(req.cookies?.[OPS_AUTH_COOKIE]); + if (!session) return null; + if (typeof hooks.isSessionRevoked === "function") { + const revoked = await hooks.isSessionRevoked({ + email: session.email, + iatMs: session.iat, + }); + if (revoked) return null; + } + return session.email; } function loadMagicLinks(MAGIC_LINK_PATH) { @@ -303,7 +380,7 @@ async function sendMagicLinkEmail(to, linkUrl) { 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 from = (process.env.SMTP_FROM || (process.env.SMTP_USER || "").trim() || "noreply@ncue.net").trim(); const transporter = createSmtpTransport(); const linkMinutes = Math.max(1, Math.floor(MAGIC_LINK_MAX_AGE_MS / 60000)); const textBody = [ @@ -344,24 +421,34 @@ module.exports = function createOpsAuth(DATA_DIR, hooks = {}) { 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)); + resolveOpsSessionEmail(req, hooks) + .then((email) => { + 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)); + }) + .catch((err) => { + console.error("[OPS] session resolve failed:", err?.message || err); + if (req.path.startsWith("/api/")) { + return res.status(500).json({ error: "인증 확인에 실패했습니다." }); + } + return res.redirect("/login"); + }); } function registerRoutes(app) { - app.get("/login", (req, res) => { + app.get("/login", async (req, res) => { if (!isOpsProd()) { return res.redirect("/learning"); } - if (getOpsSessionEmail(req)) { + const email = await resolveOpsSessionEmail(req, hooks); + if (email) { return res.redirect(sanitizeReturnTo(req.query.returnTo)); } return res.render("login", { @@ -371,7 +458,7 @@ module.exports = function createOpsAuth(DATA_DIR, hooks = {}) { }); app.get("/logout", async (req, res) => { - const sessionEmail = getOpsSessionEmail(req); + const sessionEmail = await resolveOpsSessionEmail(req, hooks); if (typeof hooks.onLogout === "function" && sessionEmail) { try { await hooks.onLogout({ email: sessionEmail, req }); @@ -464,16 +551,17 @@ module.exports = function createOpsAuth(DATA_DIR, hooks = {}) { } } const sessionExp = getOpsSessionExpiresAtMs(); - const cookieVal = signSessionCookie(row.email, sessionExp); + const issuedAt = Date.now(); + const cookieVal = signSessionCookie(row.email, sessionExp, issuedAt); const secure = process.env.NODE_ENV === "production"; res.cookie(OPS_AUTH_COOKIE, cookieVal, { httpOnly: true, - maxAge: Math.max(0, sessionExp - Date.now()), + maxAge: getOpsSessionCookieMaxAgeMs(sessionExp), sameSite: "lax", path: "/", secure, }); - const dest = appendVerifiedParam(sanitizeReturnTo(row.returnTo || "/learning")); + const dest = appendVerifiedParam(row.returnTo || RETURN_TO_DEFAULT); return res.redirect(dest); } diff --git a/package.json b/package.json index fdb5f00..f2beb0a 100644 --- a/package.json +++ b/package.json @@ -7,14 +7,17 @@ "start": "node server.js", "dev": "node server.js", "db:schema": "node scripts/apply-schema.js", + "db:backup": "bash scripts/pg-backup.sh", + "db:restore": "bash scripts/pg-restore.sh", "db:migrate-from-ncue": "node scripts/migrate-db-ncue-to-env.js", "db:normalize-meeting-minutes": "node scripts/normalize-stored-meeting-minutes.js", + "ai-success:merge-orphans": "node scripts/merge-orphan-ai-success-stories.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" + "url": "https://git.xavis.co.kr/AI_Innovation_Team/ai_platform.git" }, "keywords": [], "author": "", @@ -34,6 +37,7 @@ "nodemailer": "^6.10.1", "openai": "^6.29.0", "pg": "^8.20.0", + "sanitize-html": "^2.17.3", "uuid": "^13.0.0", "xlsx": "^0.18.5" } diff --git a/public/resources/fscan/fscan-selector-v1.html b/public/resources/fscan/fscan-selector-v1.html new file mode 100644 index 0000000..91d4b2f --- /dev/null +++ b/public/resources/fscan/fscan-selector-v1.html @@ -0,0 +1,386 @@ + + + + + +FSCAN 시리즈 제품 선정 도우미 + + + +
+
+ +
+

FSCAN 시리즈 제품 선정 도우미

+

검사 대상물의 H · W 치수를 입력하여 적합한 모델을 선정합니다

+
+
+ +
+
+ + +
+ + + +
+
+ + +
+
+
+ +
+ + mm +
+
+
+ +
+ + mm +
+
+
+
+
+
+ +
+
※ 이미지 기준표에서 추출한 데이터입니다. 최종 확인은 원본 FSCAN-D 기준표를 참조하세요.
+
+ + + + diff --git a/public/styles.css b/public/styles.css index dcd8d21..ef87e75 100644 --- a/public/styles.css +++ b/public/styles.css @@ -245,6 +245,589 @@ button.nav-item { font-size: 14px; } +.topbar-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; +} + +.top-action-icon { + margin-right: 2px; + font-size: 15px; +} + +.top-action-link--ghost { + background: #fff; + color: #374151; + border: 1px solid #d1d5db; +} + +.top-action-link--ghost:hover { + background: #f9fafb; +} + +.container.container-narrow { + max-width: 880px; +} + +.use-case-compose .use-case-compose__hint { + font-size: 14px; + color: #4b5563; + margin: 0 0 8px; +} + +.use-case-compose__form { + display: grid; + gap: 20px; +} + +.use-case-compose__meta { + display: grid; + gap: 30px; + padding-top: 28px; + border-top: 1px solid #e5e7eb; +} + +.use-case-wysiwyg-hint { + font-weight: 400; + color: #4b5563; + font-size: 14px; +} + +.use-case-section--rich h3 { + font-size: 16px; + margin: 0 0 8px; + color: #111827; +} + +.use-case-editor-host { + border: 1px solid #d1d5db; + border-radius: 10px; + overflow: hidden; + background: #fff; +} + +.use-case-editor-host .toastui-editor, +.use-case-editor-host .toastui-editor-defaultUI, +.use-case-editor-host .ProseMirror { + font-family: inherit; +} + +.use-case-editor-host .ProseMirror { + min-height: 6.5rem; + font-size: 15px; + line-height: 1.65; + color: #1f2937; + padding: 2px 0; +} + +.use-case-editor-host--merged .toastui-editor-contents { + box-sizing: border-box; +} + +/* STAR 한 칸 편집기: 본문이 테두리에 붙지 않도록 */ +.use-case-editor-host--merged .ProseMirror { + min-height: 28rem; + padding: 14px 22px 28px; + box-sizing: border-box; +} + +.use-case-editor-host .uc-example-text { + color: #9ca3af; +} + +.use-case-merge-hint { + margin: 0 0 10px; + font-size: 13px; + color: #6b7280; + line-height: 1.5; +} + +.use-case-section--merged { + display: block; +} + +.use-case-section .use-case-body-input { + width: 100%; + min-height: 96px; + padding: 12px; + border: 1px solid #d1d5db; + border-radius: 8px; + font: inherit; + line-height: 1.5; + resize: vertical; +} + +.use-case-title-input { + width: 100%; + padding: 12px 14px; + font-size: 18px; + font-weight: 600; + border: 1px solid #d1d5db; + border-radius: 8px; +} + +/* 태그: 본문 에어리어(ProseMirror)와 동일 15px, 너비는 본문 편집기 대략 50% */ +.use-case-field--tags .use-case-tag-input { + width: 50%; + max-width: 100%; + box-sizing: border-box; + padding: 10px 12px; + font-size: 15px; + line-height: 1.65; + font-weight: 400; + font-family: inherit; + color: #1f2937; + border: 1px solid #d1d5db; + border-radius: 8px; + background: #fff; +} + +.use-case-field--tags .use-case-tag-input::placeholder { + font-size: 15px; + line-height: 1.65; + color: #9ca3af; +} + +@media (max-width: 520px) { + .use-case-field--tags .use-case-tag-input { + width: 100%; + } +} + +.use-case-field .req { + color: #dc2626; +} +.use-case-field .opt { + color: #6b7280; + font-weight: 400; + font-size: 13px; +} +.use-case-field .label-block { + display: block; + font-weight: 600; + margin-bottom: 6px; +} + +.use-case-charcount { + text-align: right; + font-size: 13px; + color: #6b7280; + margin: 8px 0 0; +} + +.use-case-dropzone { + border: 2px dashed #d1d5db; + border-radius: 10px; + padding: 20px; + text-align: center; + position: relative; + background: #fafafa; +} +.use-case-file-input { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; + width: 100%; + height: 100%; +} +.use-case-dropzone__label { + pointer-events: none; + color: #6b7280; + font-size: 14px; +} + +.use-case-thumb-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 12px; + margin-top: 12px; +} + +.use-case-thumb-tile { + position: relative; + margin: 0; + aspect-ratio: 1; + border-radius: 10px; + overflow: hidden; + border: 1px solid #e5e7eb; + background: #f9fafb; +} + +.use-case-thumb-tile.is-removed { + display: none; +} + +.use-case-thumb-tile img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.use-case-thumb-remove { + position: absolute; + top: 6px; + right: 6px; + width: 26px; + height: 26px; + padding: 0; + border: none; + border-radius: 999px; + background: rgba(17, 24, 39, 0.78); + color: #fff; + font-size: 18px; + line-height: 1; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.use-case-thumb-remove:hover { + background: rgba(185, 28, 28, 0.92); +} + +.use-case-filename { + margin: 8px 0 0; + font-size: 13px; + color: #059669; +} + +.use-case-file-multi { + width: 100%; + font-size: 14px; +} + +.use-case-file-list { + margin: 8px 0 0; + padding-left: 20px; + color: #4b5563; + font-size: 14px; +} + +.use-case-cb { + display: inline-flex; + align-items: flex-start; + gap: 8px; + font-size: 14px; + color: #4b5563; + cursor: pointer; + line-height: 1.4; +} +.use-case-cb input { + margin-top: 2px; + flex-shrink: 0; +} +.use-case-existing-attach { + list-style: none; + margin: 6px 0 10px; + padding: 10px 12px; + background: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 8px; + font-size: 14px; +} +.use-case-existing-attach li + li { + margin-top: 6px; +} +.use-case-existing-attach__title { + margin: 8px 0 0 !important; + font-weight: 600; + color: #374151; +} + +.success-submission-cover-section { + margin: 20px 0 8px; +} + +.success-submission-cover-title { + font-size: 16px; + margin: 0 0 12px; + color: #111827; +} + +.success-submission-cover-hint { + margin: -6px 0 14px; + font-size: 13px; + color: #6b7280; +} + +.success-submission-cover-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 12px; +} + +.success-submission-cover-item { + margin: 0; +} + +.success-submission-cover-grid img { + display: block; + width: 100%; + height: auto; + border-radius: 8px; + border: 1px solid #e5e7eb; + background: #f9fafb; +} + +/* AI 활용 사례 상세: 실행 화면은 크게 + 클릭 확대 */ +.ai-case-viewer .success-submission-cover-header { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 12px 20px; + margin-bottom: 14px; +} + +.ai-case-viewer .success-submission-cover-header .success-submission-cover-title { + margin: 0; +} + +.ai-case-viewer .success-submission-cover-header .success-submission-cover-hint { + margin: 6px 0 0; +} + +.ai-case-viewer .success-submission-cover-grid { + gap: 16px; +} + +.ai-case-viewer .success-submission-cover-grid--cols-1 { + grid-template-columns: minmax(0, 1fr); +} + +.ai-case-viewer .success-submission-cover-grid--cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.ai-case-viewer .success-submission-cover-grid--cols-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +@media (max-width: 960px) { + .ai-case-viewer .success-submission-cover-grid--cols-4 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 640px) { + .ai-case-viewer .success-submission-cover-grid--cols-2, + .ai-case-viewer .success-submission-cover-grid--cols-4 { + grid-template-columns: minmax(0, 1fr); + } +} + +.ai-case-viewer .success-submission-screenshot-btn { + display: block; + width: 100%; + margin: 0; + padding: 0; + border: 1px solid #d1d5db; + border-radius: 12px; + background: #f9fafb; + cursor: zoom-in; + overflow: hidden; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.ai-case-viewer .success-submission-screenshot-btn:hover { + border-color: #9ca3af; + box-shadow: 0 4px 14px rgba(17, 24, 39, 0.08); +} + +.ai-case-viewer .success-submission-screenshot-btn:focus-visible { + outline: 2px solid #2563eb; + outline-offset: 2px; +} + +.ai-case-viewer .success-submission-cover-grid img { + width: 100%; + height: auto; + border: 0; + border-radius: 0; + background: #fff; +} + +.ai-case-viewer .success-submission-screenshot-caption { + margin: 8px 0 0; + font-size: 13px; + color: #6b7280; + word-break: break-all; +} + +.ai-case-viewer .success-submission-cover--single img { + display: block; + width: 100%; + height: auto; + border-radius: 12px; + border: 1px solid #d1d5db; + background: #fff; +} + +.ai-case-viewer .success-submission-cover--single .success-submission-screenshot-btn { + border: 0; + background: transparent; +} + +.ai-case-lightbox { + position: fixed; + inset: 0; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + padding: 24px 56px; + background: rgba(17, 24, 39, 0.88); +} + +.ai-case-lightbox[hidden] { + display: none !important; +} + +.ai-case-lightbox__dialog { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + max-width: min(96vw, 1400px); + max-height: 92vh; + width: 100%; +} + +.ai-case-lightbox__img { + display: block; + max-width: 100%; + max-height: calc(92vh - 72px); + width: auto; + height: auto; + border-radius: 8px; + background: #fff; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35); +} + +.ai-case-lightbox__caption { + margin: 12px 0 0; + max-width: 100%; + color: #f3f4f6; + font-size: 14px; + text-align: center; + word-break: break-all; +} + +.ai-case-lightbox__meta { + margin-top: 4px; + color: #9ca3af; + font-size: 12px; +} + +.ai-case-lightbox__close, +.ai-case-lightbox__nav { + position: absolute; + border: 0; + border-radius: 999px; + background: rgba(255, 255, 255, 0.14); + color: #fff; + cursor: pointer; + transition: background 0.15s ease; +} + +.ai-case-lightbox__close:hover, +.ai-case-lightbox__nav:hover { + background: rgba(255, 255, 255, 0.24); +} + +.ai-case-lightbox__close { + top: 16px; + right: 16px; + width: 40px; + height: 40px; + font-size: 24px; + line-height: 1; +} + +.ai-case-lightbox__nav { + top: 50%; + transform: translateY(-50%); + width: 44px; + height: 44px; + font-size: 28px; + line-height: 1; +} + +.ai-case-lightbox__nav--prev { + left: 16px; +} + +.ai-case-lightbox__nav--next { + right: 16px; +} + +.ai-case-lightbox__nav[hidden] { + display: none; +} + +.ai-case-lightbox__close:focus-visible, +.ai-case-lightbox__nav:focus-visible { + outline: 2px solid #fff; + outline-offset: 2px; +} + +@media (max-width: 640px) { + .ai-case-lightbox { + padding: 16px; + } + + .ai-case-lightbox__nav { + width: 36px; + height: 36px; + font-size: 22px; + } + + .ai-case-lightbox__nav--prev { + left: 8px; + } + + .ai-case-lightbox__nav--next { + right: 8px; + } +} +.use-case-attach-hint { + margin: 0 0 6px !important; +} +.use-case-hint--thumb-remove { + margin: 8px 0 0 !important; +} + +.use-case-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + padding-top: 8px; +} + +.use-case-btn-cancel { + padding: 10px 18px; + border-radius: 999px; +} + +.use-case-btn-save { + min-width: 120px; +} + +.use-case-form-msg { + margin-top: 8px; +} + +.field-hint { + font-size: 13px; + color: #6b7280; + margin: 4px 0 0; +} + .container { max-width: 1260px; margin: 20px auto 40px; @@ -596,6 +1179,11 @@ button { gap: 6px; } +.ai-case-viewer .ai-case-submission-like { + padding: 4px 12px; + font-size: 13px; +} + .ai-case-tools-meta { display: flex; flex-wrap: wrap; @@ -639,6 +1227,151 @@ button.ai-case-inline-link:hover { margin-bottom: 4px; } +/* AI 활용 사례 · 일반 제출 상세: WYSIWYG HTML (편집기와 읽기 화면 밀도 맞춤) */ +.ai-case-viewer .success-submission-html { + font-size: 15px; + line-height: 1.5; + color: #1f2937; + word-wrap: break-word; + overflow-wrap: break-word; +} + +.ai-case-viewer .success-submission-html > *:first-child { + margin-top: 0; +} +.ai-case-viewer .success-submission-html > *:last-child { + margin-bottom: 0; +} + +.ai-case-viewer .success-submission-html p { + margin: 0.35em 0; + line-height: 1.5; +} + +/* ProseMirror/Toast: li > p 구조에서 문단 기본 margin 이중 방지 */ +.ai-case-viewer .success-submission-html li > p { + margin: 0; + line-height: 1.5; +} +.ai-case-viewer .success-submission-html li p + p { + margin-top: 0.4em; +} + +.ai-case-viewer .success-submission-html ul, +.ai-case-viewer .success-submission-html ol { + display: block; + margin: 0.4em 0; + padding-left: 1.3em; + list-style-position: outside; +} + +.ai-case-viewer .success-submission-html li { + margin: 0.2em 0; + line-height: 1.5; + display: list-item; +} + +.ai-case-viewer .success-submission-html ul { + list-style: disc; +} +.ai-case-viewer .success-submission-html ol { + list-style: decimal; +} +.ai-case-viewer .success-submission-html li > ul, +.ai-case-viewer .success-submission-html li > ol { + margin: 0.25em 0; +} + +.ai-case-viewer .success-submission-html h1, +.ai-case-viewer .success-submission-html h2, +.ai-case-viewer .success-submission-html h3, +.ai-case-viewer .success-submission-html h4 { + margin: 0.6em 0 0.35em; + font-weight: 700; + color: #111827; + line-height: 1.35; +} +.ai-case-viewer .success-submission-html h1 { + font-size: 1.2rem; +} +.ai-case-viewer .success-submission-html h2 { + font-size: 1.1rem; +} +.ai-case-viewer .success-submission-html h3, +.ai-case-viewer .success-submission-html h4 { + font-size: 1.02rem; +} +.ai-case-viewer .success-submission-html h1:first-child, +.ai-case-viewer .success-submission-html h2:first-child, +.ai-case-viewer .success-submission-html h3:first-child, +.ai-case-viewer .success-submission-html h4:first-child { + margin-top: 0; +} + +.ai-case-viewer .success-submission-html blockquote { + margin: 0.5em 0; + padding: 0.25em 0 0.25em 0.75em; + border-left: 3px solid #d1d5db; + color: #4b5563; +} + +.ai-case-viewer .success-submission-html hr { + border: none; + border-top: 1px solid #e5e7eb; + margin: 0.75em 0; +} + +.ai-case-viewer .success-submission-html strong { + font-weight: 700; + color: #111827; +} + +.ai-case-viewer .success-submission-html a { + color: #2563eb; + text-decoration: underline; + text-underline-offset: 2px; + word-break: break-word; +} + +.ai-case-viewer .success-submission-html table { + width: 100%; + max-width: 100%; + border-collapse: collapse; + font-size: 14px; + margin: 0.5em 0; +} +.ai-case-viewer .success-submission-html th, +.ai-case-viewer .success-submission-html td { + border: 1px solid #e5e7eb; + padding: 6px 8px; + text-align: left; +} +.ai-case-viewer .success-submission-html th { + background: #f9fafb; +} + +.ai-case-viewer .success-submission-html pre { + margin: 0.5em 0; + padding: 10px 12px; + background: #f3f4f6; + border-radius: 8px; + font-size: 13px; + line-height: 1.45; + overflow: auto; +} +.ai-case-viewer .success-submission-html :not(pre) > code { + padding: 0.1em 0.35em; + background: #f3f4f6; + border-radius: 4px; + font-size: 0.9em; +} + +/* .slide-card ul { display:grid } 가 본문 리스트에도 적용되던 것을 본문 안에서는 일반 흐름으로 */ +.ai-case-viewer .slide-card .success-submission-html ul, +.ai-case-viewer .slide-card .success-submission-html ol { + gap: 0; +} + .success-detail-body-in-card { margin-top: 0; } @@ -1479,6 +2212,38 @@ button.ai-case-inline-link:hover { margin-bottom: 12px; } +.ai-type-filters { + margin-top: 10px; + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.ai-type-filter-option { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + border: 1px solid #d1d5db; + border-radius: 999px; + background: #fff; + color: #374151; + font-size: 13px; + cursor: pointer; + user-select: none; +} + +.ai-type-filter-option input[type="radio"] { + margin: 0; + accent-color: #1d4ed8; +} + +.ai-type-filter-option:has(input[type="radio"]:checked) { + border-color: #bfdbfe; + background: #eff6ff; + color: #1e3a8a; +} + .search-input { width: 100%; max-width: 400px; @@ -1587,6 +2352,14 @@ a.ai-card-link:focus-visible { user-select: none; } +.fscan-embed-frame { + width: 100%; + min-height: 860px; + border: 0; + border-radius: 12px; + background: #f3f4f6; +} + .ai-explore-action-disabled { opacity: 0.55; pointer-events: none; @@ -1599,7 +2372,7 @@ body.ai-explore-dev-guest .search-input:disabled { cursor: not-allowed; } -/* AI 프롬프트 라이브러리 (NotebookLM 스타일 빌더 느낌의 히어로 + 카드 + 미리보기) */ +/* AI 프롬프트 라이브러리 (히어로 + 카드 + 미리보기) */ .prompts-back { display: inline-flex; align-items: center; @@ -1795,6 +2568,903 @@ body.ai-explore-dev-guest .search-input:disabled { } } +/* —— 프롬프트 라이브러리 v2 (탭·좋아요·팀 공유) */ +.prompt-lib-tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 16px 0 8px; +} +.prompt-lib-tab { + padding: 8px 14px; + border-radius: 999px; + border: 1px solid #d1d5db; + background: #fff; + color: #374151; + font-size: 13px; + font-weight: 500; + cursor: pointer; +} +.prompt-lib-tab:hover { + border-color: #9ca3af; + background: #f9fafb; +} +.prompt-lib-tab.is-active { + background: #1f2937; + color: #fff; + border-color: #1f2937; +} +.prompt-lib-login-hint { + margin: 0 0 12px; + font-size: 13px; + color: #6b7280; +} +.prompt-lib-db-hint { + margin: 0 0 12px; + padding: 10px 12px; + border-radius: 8px; + background: #fef3c7; + border: 1px solid #fcd34d; + color: #92400e; + font-size: 13px; +} +.prompts-stats--info { + color: #6b7280; +} +.prompt-lib-toolbar { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + justify-content: space-between; + margin-bottom: 14px; +} +.prompt-lib-filters { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.prompt-lib-select { + padding: 8px 10px; + border-radius: 8px; + border: 1px solid #d1d5db; + font-size: 14px; + background: #fff; +} +.prompt-lib-search { + flex: 1; + min-width: 180px; + max-width: 360px; +} +.prompt-lib-search-input { + width: 100%; + padding: 8px 12px; + border: 1px solid #d1d5db; + border-radius: 8px; + font-size: 14px; +} +.prompt-lib-main.prompts-layout { + align-items: start; +} +.prompt-card-footer { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 8px; + font-size: 12px; + color: #6b7280; +} +.prompt-card-src { + padding: 2px 8px; + border-radius: 6px; + background: #f3f4f6; + color: #4b5563; + font-weight: 600; +} +.prompt-template-card .prompt-card-author { + font-size: 12px; + font-weight: 600; + color: #334155; + max-width: 8rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.prompt-card-likes { + margin-left: auto; + color: #e11d48; + font-weight: 600; +} +.prompt-lib-preview-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; +} +.prompt-lib-like { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + border-radius: 999px; + border: 1px solid #fecdd3; + background: #fff1f2; + color: #9f1239; + font-size: 14px; + font-weight: 600; + cursor: pointer; +} +.prompt-lib-like:disabled { + opacity: 0.45; + cursor: not-allowed; +} +.prompt-lib-like.is-liked { + background: #fb7185; + border-color: #fb7185; + color: #fff; +} +.prompt-lib-meta { + margin: 0 0 8px; + font-size: 13px; + color: #6b7280; +} +.prompt-lib-textarea { + width: 100%; + min-height: 180px; + padding: 12px 14px; + border: 1px solid #d1d5db; + border-radius: 10px; + font-size: 14px; + line-height: 1.5; + font-family: inherit; + resize: vertical; + box-sizing: border-box; +} +.prompt-lib-mine-list { + list-style: none; + margin: 0; + padding: 0; +} +.prompt-lib-mine-item { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + padding: 10px 0; + border-bottom: 1px solid #e5e7eb; +} +.prompt-lib-mine-item .mine-title { + flex: 1; + min-width: 0; + font-weight: 600; + color: #111827; +} +.prompt-lib-mine-item .mine-date { + font-size: 13px; + color: #9ca3af; +} +.prompt-lib-mine-item .mine-del { + font-size: 13px; +} + +/* 프롬프트 공개 폼(탭2) */ +.prompt-lib-panel--submit { + border: none; + background: linear-gradient(135deg, #faf5ff 0%, #f8fafc 45%, #eff6ff 100%); + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06), 0 12px 40px rgba(15, 23, 42, 0.06); + border-radius: 16px; + padding: 22px 22px 28px; +} +.prompt-lib-submit-hero { + text-align: center; + margin-bottom: 16px; + padding-bottom: 18px; + border-bottom: 1px solid rgba(148, 163, 184, 0.35); +} +.prompt-lib-submit-hero-icon { + display: inline-flex; + width: 44px; + height: 44px; + align-items: center; + justify-content: center; + border-radius: 12px; + background: linear-gradient(145deg, #7c3aed, #4f46e5); + color: #fff; + font-size: 20px; + margin-bottom: 10px; + box-shadow: 0 4px 14px rgba(79, 70, 229, 0.35); +} +.prompt-lib-panel--submit h2 { + margin: 0 0 8px; + font-size: 1.5rem; + color: #0f172a; + letter-spacing: -0.02em; +} +.prompt-lib-submit-hero p { + margin: 0; + font-size: 15px; + line-height: 1.5; + color: #475569; + max-width: 520px; + margin-left: auto; + margin-right: auto; +} +.prompt-lib-policy { + font-size: 13px; + line-height: 1.5; + color: #64748b; + background: rgba(255, 255, 255, 0.75); + border: 1px solid #e2e8f0; + border-radius: 10px; + padding: 10px 14px; + margin: 0 0 20px; +} +/* 공유하기 탭: 정책/히어로와 동일한 가로로 폼·카드 정렬(이전 800px 제한 해제) */ +.prompt-lib-form { + width: 100%; + max-width: 100%; + margin: 0; + box-sizing: border-box; +} +.prompt-lib-fieldset { + margin: 0 0 20px; + padding: 16px 18px 18px; + background: #fff; + border-radius: 12px; + border: 1px solid #e2e8f0; + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.8) inset; +} +.prompt-lib-h3 { + margin: 0 0 12px; + font-size: 1rem; + color: #1e293b; + font-weight: 700; +} +.prompt-lib-hintline { + margin: -4px 0 10px; + font-size: 13px; + color: #64748b; +} +.prompt-lib-label { + display: block; + font-size: 13px; + font-weight: 600; + color: #334155; + margin-bottom: 6px; +} +.prompt-lib-input { + width: 100%; + padding: 10px 12px; + border: 1px solid #cbd5e1; + border-radius: 10px; + font-size: 15px; + line-height: 1.4; + background: #f8fafc; + transition: border-color 0.15s, box-shadow 0.15s; + box-sizing: border-box; +} +.prompt-lib-input:focus { + outline: none; + border-color: #6366f1; + background: #fff; + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2); +} +.prompt-lib-input-row { + margin-bottom: 12px; +} +.prompt-lib-input-row--2col { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px 16px; +} +@media (max-width: 640px) { + .prompt-lib-input-row--2col { + grid-template-columns: 1fr; + } +} +.prompt-lib-textarea--body { + min-height: 280px; + font-family: ui-monospace, SFMono-Regular, "Segoe UI", Menlo, Consolas, monospace; + font-size: 14px; + line-height: 1.55; + background: #f8fafc; +} +.prompt-lib-textarea--body:focus { + background: #fff; +} + +/* 공유하기: 프롬프트 본문 — 원문 / 마크다운 미리보기 */ +.prompt-lib-body-editor-wrap { + display: flex; + flex-direction: column; + gap: 0; + width: 100%; + box-sizing: border-box; +} +.prompt-lib-body-toolbar { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 0 0 10px; +} +.prompt-lib-body-mode-btn { + padding: 8px 16px; + font-size: 13px; + font-weight: 600; + color: #475569; + background: #fff; + border: 1px solid #cbd5e1; + border-radius: 999px; + cursor: pointer; + transition: background 0.12s, border-color 0.12s, color 0.12s; +} +.prompt-lib-body-mode-btn:hover { + border-color: #94a3b8; + color: #1e293b; +} +.prompt-lib-body-mode-btn.is-active { + color: #fff; + background: linear-gradient(135deg, #4f46e5, #6366f1); + border-color: #4f46e5; + box-shadow: 0 2px 8px rgba(79, 70, 229, 0.25); +} +.prompt-lib-md-preview { + min-height: 280px; + max-height: 520px; + padding: 12px 14px; + border: 1px solid #cbd5e1; + border-radius: 10px; + background: #fff; + overflow: auto; + box-sizing: border-box; + color: #1e293b; +} +.prompt-lib-md-preview[hidden] { + display: none !important; +} +.prompt-lib-md-body { + font-size: 14px; + line-height: 1.55; + word-wrap: break-word; + overflow-wrap: break-word; +} +.prompt-lib-md-body > *:first-child { + margin-top: 0; +} +.prompt-lib-md-body > *:last-child { + margin-bottom: 0; +} +.prompt-lib-md-body h1, +.prompt-lib-md-body h2, +.prompt-lib-md-body h3, +.prompt-lib-md-body h4 { + margin: 12px 0 8px; + font-weight: 700; + color: #111827; + line-height: 1.35; +} +.prompt-lib-md-body h1 { + font-size: 1.2rem; +} +.prompt-lib-md-body h2 { + font-size: 1.1rem; +} +.prompt-lib-md-body h3, +.prompt-lib-md-body h4 { + font-size: 1.02rem; +} +.prompt-lib-md-body p { + margin: 8px 0; +} +.prompt-lib-md-body hr { + border: none; + border-top: 1px solid #d1d5db; + margin: 14px 0; +} +.prompt-lib-md-body ul, +.prompt-lib-md-body ol { + margin: 8px 0; + padding-left: 1.35em; +} +.prompt-lib-md-body li { + margin: 4px 0; +} +.prompt-lib-md-body ul { + list-style: disc; +} +.prompt-lib-md-body ol { + list-style: decimal; +} +.prompt-lib-md-body blockquote { + margin: 8px 0; + padding: 6px 0 6px 12px; + border-left: 3px solid #d1d5db; + color: #4b5563; +} +.prompt-lib-md-body strong { + font-weight: 700; + color: #111827; +} +.prompt-lib-md-body a { + color: #2563eb; + text-decoration: underline; + text-underline-offset: 2px; + word-break: break-word; +} +.prompt-lib-md-body a:hover { + color: #1d4ed8; +} +.prompt-lib-md-body pre { + margin: 10px 0; + padding: 10px 12px; + border-radius: 8px; + background: #1f2937; + color: #e5e7eb; + font-size: 12px; + line-height: 1.45; + overflow-x: auto; +} +.prompt-lib-md-body pre code { + background: none; + padding: 0; + font-size: inherit; +} +.prompt-lib-md-body :not(pre) > code { + padding: 2px 6px; + border-radius: 4px; + background: #e5e7eb; + font-size: 0.9em; +} +.prompt-lib-md-body table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + margin: 10px 0; +} +.prompt-lib-md-body th, +.prompt-lib-md-body td { + border: 1px solid #e5e7eb; + padding: 6px 8px; + text-align: left; +} +.prompt-lib-md-body th { + background: #f9fafb; + font-weight: 600; +} +.prompt-lib-md-empty, +.prompt-lib-md-fallback { + margin: 0; + color: #64748b; + font-size: 14px; +} +.prompt-lib-upload-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px; +} +@media (max-width: 720px) { + .prompt-lib-upload-grid { + grid-template-columns: 1fr; + } +} +.prompt-lib-drop { + position: relative; + padding: 16px 14px 14px; + border-radius: 12px; + border: 2px dashed #c4b5fd; + background: linear-gradient(180deg, #faf5ff 0%, #fff 100%); + min-height: 150px; +} +.prompt-lib-drop--result { + border-color: #93c5fd; + background: linear-gradient(180deg, #eff6ff 0%, #fff 100%); +} +.prompt-lib-drop-eyebrow { + display: inline-block; + font-size: 10px; + font-weight: 800; + letter-spacing: 0.12em; + color: #6d28d9; + margin-bottom: 4px; +} +.prompt-lib-drop--result .prompt-lib-drop-eyebrow { + color: #1d4ed8; +} +.prompt-lib-drop strong { + display: block; + font-size: 15px; + color: #0f172a; + margin-bottom: 4px; +} +.prompt-lib-drop p { + margin: 0 0 12px; + font-size: 12px; + line-height: 1.4; + color: #64748b; +} +.prompt-lib-file-native { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} +.prompt-lib-file-btn { + display: inline-block; + padding: 8px 16px; + border-radius: 999px; + background: #fff; + border: 1px solid #a78bfa; + color: #5b21b6; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, transform 0.1s; +} +.prompt-lib-file-btn:hover { + background: #ede9fe; +} +.prompt-lib-drop--result .prompt-lib-file-btn { + border-color: #60a5fa; + color: #1d4ed8; +} +.prompt-lib-drop--result .prompt-lib-file-btn:hover { + background: #dbeafe; +} +.prompt-lib-file-list { + margin: 8px 0 0; + font-size: 12px; + color: #64748b; + word-break: break-all; + min-height: 1.2em; +} +.prompt-lib-submit-bar { + display: flex; + justify-content: flex-end; + margin-top: 8px; +} +.prompt-lib-submit-btn { + padding: 12px 28px; + border: none; + border-radius: 999px; + font-size: 15px; + font-weight: 700; + color: #fff; + cursor: pointer; + background: linear-gradient(135deg, #ea580c, #f97316); + box-shadow: 0 4px 20px rgba(234, 88, 12, 0.35); + transition: transform 0.1s, box-shadow 0.15s; +} +.prompt-lib-submit-btn:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 6px 24px rgba(234, 88, 12, 0.45); +} +.prompt-lib-submit-btn:disabled { + opacity: 0.55; + cursor: not-allowed; + transform: none; +} + +/* 미리보기: 첨부 */ +.prompt-lib-preview-files { + margin: 0 0 10px; + display: flex; + flex-direction: column; + gap: 10px; +} +.prompt-lib-preview-file-block { + padding: 10px 12px; + background: #f1f5f9; + border-radius: 10px; + border: 1px solid #e2e8f0; +} +.prompt-lib-preview-file-block h4 { + margin: 0 0 6px; + font-size: 12px; + font-weight: 700; + color: #475569; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.prompt-lib-file-link-list { + margin: 0; + padding: 0 0 0 1.1em; + list-style: disc; + font-size: 13px; + color: #1e293b; +} +.prompt-lib-file-link-list li { + margin: 4px 0; + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 6px 10px; +} +.prompt-lib-file-link-list a { + color: #2563eb; + font-weight: 500; + text-decoration: none; + word-break: break-all; +} +.prompt-lib-file-link-list a:hover { + text-decoration: underline; +} +.prompt-lib-file-size { + font-size: 12px; + color: #94a3b8; +} + +/* 워크플로 → 프롬프트 탭 */ +.prompt-lib-panel--workflow { + border: none; + background: linear-gradient(160deg, #f0fdfa 0%, #f8fafc 40%, #f5f3ff 100%); + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06), 0 12px 36px rgba(15, 23, 42, 0.07); + border-radius: 16px; + padding: 22px 22px 28px; +} +.prompt-lib-wf-hero { + text-align: center; + margin-bottom: 16px; + padding-bottom: 16px; + border-bottom: 1px solid rgba(148, 163, 184, 0.35); +} +.prompt-lib-wf-hero-icon { + display: inline-flex; + width: 44px; + height: 44px; + align-items: center; + justify-content: center; + border-radius: 12px; + background: linear-gradient(145deg, #0d9488, #6366f1); + color: #fff; + font-size: 20px; + line-height: 1; + margin-bottom: 10px; + box-shadow: 0 4px 16px rgba(13, 148, 136, 0.35); +} +.prompt-lib-panel--workflow h2 { + margin: 0 0 8px; + font-size: 1.5rem; + color: #0f172a; + letter-spacing: -0.02em; +} +.prompt-lib-wf-hero p { + margin: 0 auto; + max-width: 100%; + width: 100%; + box-sizing: border-box; + font-size: 15px; + line-height: 1.55; + color: #475569; +} +.prompt-lib-wf-api-note { + font-size: 13px; + line-height: 1.6; + color: #334155; + background: rgba(255, 255, 255, 0.85); + border: 1px solid #c7d2fe; + border-radius: 12px; + padding: 12px 14px 12px 16px; + margin: 0 0 18px; + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.9) inset; +} +.prompt-lib-wf-api-note strong { + display: block; + margin-bottom: 6px; + color: #312e81; + font-size: 12px; + letter-spacing: 0.03em; + text-transform: uppercase; +} +.prompt-lib-wf-api-note ul { + margin: 0; + padding: 0 0 0 1.1em; +} +.prompt-lib-wf-api-note li { + margin: 6px 0; +} +.prompt-lib-wf-api-note li em { + color: #0f766e; + font-style: normal; + font-weight: 600; +} +.prompt-lib-wf-api-note code { + font-size: 12px; + background: #e0e7ff; + padding: 1px 5px; + border-radius: 4px; + color: #3730a3; +} +.prompt-lib-wf-warn { + color: #b45309; + font-weight: 600; +} +.prompt-lib-wf-k { + display: inline-block; + font-weight: 700; + color: #0f172a; +} +/* 워크플로 탭: 상단 설명(노트)과 동일한 가로 */ +.prompt-lib-wf-body { + width: 100%; + max-width: 100%; + margin: 0; + display: flex; + flex-direction: column; + gap: 16px; + box-sizing: border-box; +} +.prompt-lib-wf-fieldset { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 16px 18px 18px; + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.9) inset; +} +.prompt-lib-wf-fieldset--actions { + border-color: #cbd5e1; + background: linear-gradient(180deg, #fff 0%, #f8fafc 100%); +} +.prompt-lib-wf-h3 { + margin: 0 0 10px; + font-size: 1rem; + font-weight: 700; + color: #1e293b; +} +.prompt-lib-wf-lead { + margin: -2px 0 14px; + font-size: 14px; + line-height: 1.5; + color: #64748b; +} +.prompt-lib-wf-row { + margin-bottom: 12px; +} +.prompt-lib-wf-row:last-of-type { + margin-bottom: 0; +} +.prompt-lib-wf-label { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px 8px; + font-size: 13px; + font-weight: 600; + color: #334155; + margin-bottom: 6px; +} +.prompt-lib-wf-label .req { + color: #dc2626; + font-size: 12px; + font-weight: 600; +} +.prompt-lib-wf-label .opt { + font-weight: 500; + color: #94a3b8; + font-size: 12px; +} +.prompt-lib-wf-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 22px; + height: 22px; + padding: 0 6px; + border-radius: 6px; + background: #e0e7ff; + color: #3730a3; + font-size: 12px; + font-weight: 800; +} +.prompt-lib-wf-input, +.prompt-lib-wf-textarea { + width: 100%; + box-sizing: border-box; + padding: 10px 12px; + border: 1px solid #cbd5e1; + border-radius: 10px; + font-size: 15px; + line-height: 1.45; + background: #f8fafc; + transition: border-color 0.15s, box-shadow 0.15s, background 0.15s; + font-family: inherit; +} +.prompt-lib-wf-textarea { + min-height: 100px; + resize: vertical; + font-size: 14px; +} +.prompt-lib-wf-textarea--result { + min-height: 260px; + font-family: ui-monospace, SFMono-Regular, "Segoe UI", Menlo, Consolas, monospace; + line-height: 1.55; + background: #f1f5f9; +} +.prompt-lib-wf-input:focus, +.prompt-lib-wf-textarea:focus { + outline: none; + border-color: #0d9488; + background: #fff; + box-shadow: 0 0 0 3px rgba(13, 148, 136, 0.18); +} +.prompt-lib-wf-actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px 16px; + align-items: start; +} +@media (max-width: 560px) { + .prompt-lib-wf-actions { + grid-template-columns: 1fr; + } +} +.prompt-lib-wf-action { + display: flex; + flex-direction: column; + gap: 6px; +} +.prompt-lib-wf-btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 44px; + padding: 10px 18px; + border-radius: 10px; + font-size: 15px; + font-weight: 700; + cursor: pointer; + border: none; + transition: transform 0.1s, box-shadow 0.15s, opacity 0.15s; +} +.prompt-lib-wf-btn:disabled { + opacity: 0.45; + cursor: not-allowed; + transform: none; + box-shadow: none; +} +.prompt-lib-wf-btn--merge { + background: #fff; + color: #0f172a; + border: 2px solid #94a3b8; + box-shadow: 0 2px 0 #e2e8f0; +} +.prompt-lib-wf-btn--merge:hover:not(:disabled) { + background: #f8fafc; + border-color: #64748b; + transform: translateY(-1px); +} +.prompt-lib-wf-btn--ai { + background: linear-gradient(135deg, #0d9488, #4f46e5); + color: #fff; + box-shadow: 0 4px 16px rgba(79, 70, 229, 0.35); + border: none; +} +.prompt-lib-wf-btn--ai:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 6px 22px rgba(79, 70, 229, 0.45); +} +.prompt-lib-wf-action-desc { + margin: 0; + font-size: 12px; + line-height: 1.4; + color: #64748b; + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; +} +.prompt-lib-wf-tip { + cursor: help; + color: #94a3b8; + font-size: 14px; + line-height: 1; + user-select: none; +} + .chat-area { display: flex; flex-direction: column; @@ -1820,31 +3490,69 @@ body.ai-explore-dev-guest .search-input:disabled { } .chat-msg-streaming .chat-msg-content { - min-height: 1.5em; + min-height: 46px; } -.chat-typing-dots { +.chat-progress-indicator { display: inline-flex; - gap: 4px; align-items: center; - color: #9ca3af; + gap: 8px; + padding: 6px 10px; + border-radius: 999px; + border: 1px solid #f59e0b; + background: #fffbeb; + color: #92400e; font-weight: 700; + font-size: 13px; } -.chat-typing-dots span { - animation: chatTypingDot 1.1s ease-in-out infinite; +.chat-progress-text { + letter-spacing: 0.2px; + animation: chatProgressBlink 1.1s ease-in-out infinite; +} + +.chat-progress-dots { + display: inline-flex; + gap: 3px; + align-items: center; +} + +.chat-progress-dots span { + width: 5px; + height: 5px; + border-radius: 999px; + background: #d97706; opacity: 0.35; + animation: chatProgressDot 1.05s ease-in-out infinite; } -.chat-typing-dots span:nth-child(2) { +.chat-progress-dots span:nth-child(2) { animation-delay: 0.15s; } -.chat-typing-dots span:nth-child(3) { +.chat-progress-dots span:nth-child(3) { animation-delay: 0.3s; } -@keyframes chatTypingDot { +.chat-progress-track { + position: relative; + width: 72px; + height: 6px; + border-radius: 999px; + background: #fde68a; + overflow: hidden; +} + +.chat-progress-fill { + position: absolute; + inset: 0; + width: 42%; + border-radius: 999px; + background: linear-gradient(90deg, #f59e0b 0%, #d97706 100%); + animation: chatProgressSlide 1s ease-in-out infinite; +} + +@keyframes chatProgressDot { 0%, 80%, 100% { @@ -1853,13 +3561,43 @@ body.ai-explore-dev-guest .search-input:disabled { } 40% { opacity: 1; - transform: translateY(-2px); + transform: translateY(-1px); + } +} + +@keyframes chatProgressBlink { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.45; + } +} + +@keyframes chatProgressSlide { + 0% { + transform: translateX(-120%); + } + 100% { + transform: translateX(260%); } } .chat-send-btn.is-busy { - opacity: 0.85; + opacity: 1; cursor: wait; + animation: chatSendBusyPulse 0.9s ease-in-out infinite; +} + +@keyframes chatSendBusyPulse { + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.18); + } + 50% { + box-shadow: 0 0 0 5px rgba(245, 158, 11, 0.4); + } } .chat-main { @@ -1873,6 +3611,16 @@ body.ai-explore-dev-guest .search-input:disabled { padding: 0 24px 24px; } +.chat-native-shell { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + max-width: 800px; + width: 100%; + margin: 0 auto; +} + .chat-messages { flex: 1; overflow-y: auto; @@ -2101,6 +3849,13 @@ body.ai-explore-dev-guest .search-input:disabled { color: #1d4ed8; } +.chat-source-excerpt { + margin-top: 4px; + color: #6b7280; + font-size: 12px; + line-height: 1.4; +} + .chat-input-wrap { padding: 16px 0; border-top: 1px solid #e5e7eb; @@ -2885,6 +4640,9 @@ body.ai-explore-dev-guest .search-input:disabled { flex-wrap: wrap; margin-bottom: 6px; } +.mm-source-text-header .mm-field-label { + margin-bottom: 0; +} .mm-minutes-actions { display: flex; align-items: center; @@ -3035,6 +4793,27 @@ body.ai-explore-dev-guest .search-input:disabled { background: #e2e8f0; font-size: 0.92em; } +/* 마크다운 표(회의 진행 흐름 등): 셀·행 구분선 */ +.mm-minutes-rendered table { + width: 100%; + border-collapse: collapse; + margin: 12px 0; + font-size: 13px; + border: 1px solid #d8dee6; +} +.mm-minutes-rendered th, +.mm-minutes-rendered td { + border: 1px solid #d8dee6; + padding: 10px 12px; + vertical-align: top; + text-align: left; +} +.mm-minutes-rendered th { + background: #f1f5f9; + font-weight: 600; + color: #0f172a; +} + .mm-minutes-source[hidden] { display: none !important; } @@ -3049,13 +4828,28 @@ body.ai-explore-dev-guest .search-input:disabled { border: 1px solid #99f6e4; border-radius: 10px; } +.mm-gen-progress-track-row { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; +} .mm-gen-progress-track { position: relative; + flex: 1; height: 6px; border-radius: 3px; background: #e5e7eb; overflow: hidden; - margin-bottom: 10px; +} +.mm-gen-progress-pct { + flex-shrink: 0; + min-width: 3.2em; + text-align: right; + font-size: 13px; + font-weight: 700; + color: #0f766e; + font-variant-numeric: tabular-nums; } .mm-gen-progress-bar { position: absolute; @@ -3066,6 +4860,12 @@ body.ai-explore-dev-guest .search-input:disabled { border-radius: 3px; background: linear-gradient(90deg, #0f766e, #14b8a6, #0f766e); animation: mm-gen-progress-slide 1.35s ease-in-out infinite; + transition: width 0.3s ease; +} +.mm-gen-progress-bar--determined { + animation: none; + background: linear-gradient(90deg, #0f766e, #14b8a6); + transition: width 0.4s cubic-bezier(0.25, 0.8, 0.25, 1); } @keyframes mm-gen-progress-slide { 0% { @@ -3082,6 +4882,9 @@ body.ai-explore-dev-guest .search-input:disabled { max-width: none; opacity: 0.85; } + .mm-gen-progress-bar--determined { + transition: none; + } } .mm-gen-progress-msg { margin: 0; @@ -3090,6 +4893,115 @@ body.ai-explore-dev-guest .search-input:disabled { color: #0f766e; font-weight: 600; } +.mm-gen-audio-pipeline { + margin-bottom: 12px; + padding-bottom: 12px; + border-bottom: 1px dashed #99f6e4; +} +.mm-gen-pipeline-steps { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 6px; + margin: 0 0 10px; + font-size: 13px; + color: #64748b; +} +.mm-gen-pipeline-step-num { + font-weight: 700; + font-variant-numeric: tabular-nums; + letter-spacing: -0.02em; +} +.mm-gen-pipeline-join { + color: #94a3b8; + font-weight: 500; + user-select: none; +} +.mm-gen-audio-pipeline .mm-gen-pipeline-step { + padding: 6px 14px; + border-radius: 999px; + border: 2px solid #cbd5e1; + background: #f1f5f9; + color: #94a3b8; + font-weight: 600; + transition: + background 0.2s ease, + border-color 0.2s ease, + color 0.2s ease, + box-shadow 0.2s ease, + opacity 0.2s ease, + transform 0.2s ease; +} +.mm-gen-audio-pipeline .mm-gen-pipeline-step.is-pending { + opacity: 0.55; + border-style: dashed; + border-color: #cbd5e1; + background: #f8fafc; + color: #9ca3af; + font-weight: 500; +} +.mm-gen-audio-pipeline .mm-gen-pipeline-step.is-active { + opacity: 1; + transform: translateY(-1px); + border-style: solid; + border-width: 3px; + padding: 5px 13px; + border-color: #0f766e; + background: linear-gradient(165deg, #5eead4 0%, #2dd4bf 45%, #14b8a6 100%); + color: #042f2e; + font-weight: 800; + box-shadow: + 0 0 0 1px rgba(15, 118, 110, 0.25), + 0 4px 12px rgba(15, 118, 110, 0.22); + animation: mm-step-active-pulse 2s ease-in-out infinite; +} +.mm-gen-audio-pipeline .mm-gen-pipeline-step.is-active .mm-gen-pipeline-step-num { + font-weight: 900; +} +.mm-gen-audio-pipeline .mm-gen-pipeline-step.is-done { + opacity: 1; + transform: none; + border-style: solid; + border-width: 3px; + padding: 5px 13px; + border-color: #059669; + background: linear-gradient(165deg, #a7f3d0 0%, #6ee7b7 50%, #34d399 100%); + color: #064e3b; + font-weight: 700; + box-shadow: 0 1px 6px rgba(5, 150, 105, 0.2); +} +.mm-gen-audio-pipeline .mm-gen-pipeline-step.is-done .mm-gen-pipeline-step-num { + font-weight: 900; +} + +@keyframes mm-step-active-pulse { + 0%, + 100% { + box-shadow: + 0 0 0 1px rgba(15, 118, 110, 0.25), + 0 4px 12px rgba(15, 118, 110, 0.22); + } + 50% { + box-shadow: + 0 0 0 5px rgba(45, 212, 191, 0.2), + 0 6px 16px rgba(15, 118, 110, 0.28); + } +} + +@media (prefers-reduced-motion: reduce) { + .mm-gen-audio-pipeline .mm-gen-pipeline-step.is-active { + animation: none; + transform: none; + } +} + +.mm-gen-pipeline-note { + margin: 0; + font-size: 11px; + line-height: 1.35; + color: #64748b; + font-weight: 500; +} .mm-result-body { white-space: pre-wrap; word-break: break-word; diff --git a/scripts/build-menu-guide-ppt-user.py b/scripts/build-menu-guide-ppt-user.py new file mode 100644 index 0000000..490da20 --- /dev/null +++ b/scripts/build-menu-guide-ppt-user.py @@ -0,0 +1,429 @@ +#!/usr/bin/env python3 +""" +AI Platform 메뉴 안내 PPT — 일반 임직원(관리자·특수 메뉴 제외) +- 가이드봇·WM·대시보드·업무 체크리스트: 허용 계정만 (본 PPT 범위 외) +""" +from __future__ import annotations + +from pathlib import Path + +from pptx import Presentation +from pptx.dml.color import RGBColor +from pptx.enum.shapes import MSO_SHAPE +from pptx.enum.text import MSO_ANCHOR, PP_ALIGN +from pptx.util import Inches, Pt + +ROOT = Path(__file__).resolve().parent.parent +SHOT_DIR = ROOT / "docs" / "ppt-screenshots-user" +OUT_PPT = ROOT / "docs" / "XAVIS-AI-Platform-메뉴안내-일반사용자.pptx" + +# Brand palette +NAVY = RGBColor(0x0F, 0x17, 0x2A) +BLUE = RGBColor(0x25, 0x63, 0xEB) +BLUE_LIGHT = RGBColor(0xDB, 0xEA, 0xFE) +SLATE = RGBColor(0x47, 0x55, 0x69) +SLATE_LIGHT = RGBColor(0x94, 0xA3, 0xB8) +WHITE = RGBColor(0xFF, 0xFF, 0xFF) +BG = RGBColor(0xF8, 0xFA, 0xFC) + +SLIDE_W = Inches(13.333) +SLIDE_H = Inches(7.5) + +SLIDES: list[tuple[str, str, list[str], str | None]] = [ + ( + "cover", + "XAVIS AI Platform", + [ + "일반 사용자 이용 가이드", + "https://ai.xavis.co.kr/", + "@ncue.net 이메일 인증 후 이용 · 관리자 모드 불필요", + ], + None, + ), + ( + "section", + "시작하기", + [], + None, + ), + ( + "content", + "이메일 인증으로 접속", + [ + "1. ai.xavis.co.kr 접속 → 회사 이메일 입력 → [검증]", + "2. 메일함 [인증 완료하기] 클릭 (링크 15분 유효)", + "3. 인증 완료 후 AI·학습센터 등 메뉴 이용", + "로그아웃: 좌측 하단 · 좌측 [관리자]는 운영 담당용(일반 사용자 불필요)", + ], + "login.png", + ), + ( + "content", + "일반 사용자 메뉴 구성", + [ + "AI — 회의록·채팅·FSCAN 등 AI 서비스", + "프롬프트 — 업무용 템플릿·팀 공유", + "학습센터 — YouTube·PPT·동영상 강의", + "과제신청 — AX 과제 온라인 신청", + "AI 활용 사례 — 임직원 간 실무 AI 활용 노하우 공유·열람", + "※ 가이드봇·WM·대시보드·업무 체크리스트는 허용 계정만 표시", + ], + "menu-overview.png", + ), + ( + "section", + "AI 서비스", + [], + None, + ), + ( + "content", + "AI — 서비스 허브", + [ + "경로: /ai-explore", + "검색 + 타입 필터(전체 / 일반 / XScan / FScan)", + "카드 클릭으로 각 AI 도구 실행", + ], + "ai-explore.png", + ), + ( + "content", + "회의록 AI", + [ + "텍스트: 회의 원문 붙여넣기 → [회의록 생성] → 수정 후 [저장]", + "음성: mp3·m4a·wav(최대 300MB) → 업로드 → 전사 → 회의록 정리", + "대안: 클로버노트·Claude 전사 → 텍스트 입력 탭에 붙여넣기", + "유의: 결과 검토 필수 · 개인정보·대외비 주의", + ], + "meeting-minutes.png", + ), + ( + "content", + "일반 채팅", + [ + "이메일 인증 후 ChatGPT 기반 사내 채팅", + "업무 질의·초안·아이디어 · (설정 시) 웹 검색", + "반복 업무는 [프롬프트] 템플릿 복사 후 활용", + ], + "chat.png", + ), + ( + "content", + "FSCAN 조사각 선정도우미", + [ + "검사물 H/W 치수 입력 → FSCAN 모델 1차 선정", + "영업·기술 검토용 · 최종 스펙은 공식 카탈로그·기술팀 확인", + ], + "fscan.png", + ), + ( + "section", + "업무 지원", + [], + None, + ), + ( + "content", + "프롬프트 라이브러리", + [ + "공식 템플릿(회의·메일·보고·OKR 등) 미리보기 → 복사", + "워크플로: 4단계 입력으로 맞춤 지시문 초안", + "공유하기: 팀 프롬프트 등록(로그인 필요, 기밀 제외)", + ], + "prompts.png", + ), + ( + "content", + "학습센터", + [ + "YouTube · PPT/PDF · 동영상 · 웹 링크 강의 검색·시청", + "카테고리: AX 사고 전환 · AI 툴 활용 · AI Agent · 바이브 코딩", + "캡처: 등록 강의 목록 전체(무한 스크롤 로드 후)", + "강의 등록·수정은 운영 담당 — 일반 사용자는 시청·검색", + ], + "learning.png", + ), + ( + "content", + "AX 과제 신청", + [ + "Word 양식 다운로드 + 온라인 신청서 작성", + "본인 신청 조회·수정(부서·이름·이메일)", + "유사 사례는 AI 활용 사례 메뉴 참고", + ], + "ax-apply.png", + ), + ( + "content", + "AI 활용 사례 — 공유 공간과 열람", + [ + "각자의 실무 AI 활용법을 전사 임직원과 공유하는 공간(학습센터=교육, 여기=현장 검증 사례)", + "로그인 임직원 누구나 열람·글쓰기 · 부서·태그·검색으로 유사 사례 탐색", + "AX 과제·새 업무 전 참고 · 카드 클릭 → STAR 상세·재현 방법 확인", + "경로: 좌측 [AI 활용 사례] · Before/After·활용 도구·성과 중심", + ], + "ai-cases.png", + ), + ( + "content", + "AI 활용 사례 — 나의 경험 공유하기", + [ + "[글쓰기] → STAR 본문(배경→과제→AI 활용→성과) · 활용 AI 태그 · 썸네일", + "동료가 내일 바로 따라 할 수 있도록 도구·절차·수치를 구체적으로(과장 금지)", + "※ 개인정보·고객·기밀 미포함 · AI 결과는 본인 검증 후 · 문의 AI혁신팀", + ], + "ai-cases-compose.png", + ), + ( + "section", + "정리", + [], + None, + ), + ( + "content", + "업무별 Quick Reference", + [ + "음성 회의록 → AI → 회의록 AI (또는 클로버노트 → 텍스트 입력)", + "빠른 질문 → 일반 채팅 · 메일·보고 초안 → 프롬프트", + "학습 → 학습센터 · AX 과제 → 과제신청 · 사례 → AI 활용 사례", + "문의: AI혁신팀", + ], + None, + ), + ( + "closing", + "감사합니다", + [ + "XAVIS AI Platform", + "https://ai.xavis.co.kr/", + "AI혁신팀", + ], + None, + ), +] + + +def _set_solid_fill(shape, color: RGBColor) -> None: + shape.fill.solid() + shape.fill.fore_color.rgb = color + + +def _add_header_bar(slide, title: str) -> None: + bar = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0), Inches(0), SLIDE_W, Inches(0.95)) + _set_solid_fill(bar, NAVY) + bar.line.fill.background() + + accent = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0), Inches(0.95), SLIDE_W, Inches(0.06)) + _set_solid_fill(accent, BLUE) + accent.line.fill.background() + + tb = slide.shapes.add_textbox(Inches(0.55), Inches(0.18), Inches(10.5), Inches(0.55)) + tf = tb.text_frame + tf.text = title + p = tf.paragraphs[0] + p.font.name = "Apple SD Gothic Neo" + p.font.size = Pt(24) + p.font.bold = True + p.font.color.rgb = WHITE + + +def _add_footer(slide, page_num: int) -> None: + line = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0.55), Inches(7.05), Inches(12.2), Inches(0.015)) + _set_solid_fill(line, BLUE_LIGHT) + line.line.fill.background() + + left = slide.shapes.add_textbox(Inches(0.55), Inches(7.12), Inches(5), Inches(0.28)) + ltf = left.text_frame + ltf.text = "XAVIS AI Platform · 일반 사용자 가이드" + lp = ltf.paragraphs[0] + lp.font.size = Pt(9) + lp.font.color.rgb = SLATE_LIGHT + + right = slide.shapes.add_textbox(Inches(11.8), Inches(7.12), Inches(1.0), Inches(0.28)) + rtf = right.text_frame + rtf.text = str(page_num) + rp = rtf.paragraphs[0] + rp.font.size = Pt(9) + rp.font.color.rgb = SLATE_LIGHT + rp.alignment = PP_ALIGN.RIGHT + + +def _add_bullets(slide, bullets: list[str], left: float, top: float, width: float, height: float, font_size: int = 14) -> None: + box = slide.shapes.add_textbox(Inches(left), Inches(top), Inches(width), Inches(height)) + tf = box.text_frame + tf.word_wrap = True + for i, line in enumerate(bullets): + para = tf.paragraphs[0] if i == 0 else tf.add_paragraph() + para.text = line + para.font.name = "Apple SD Gothic Neo" + para.font.size = Pt(font_size) + para.font.color.rgb = SLATE + para.space_after = Pt(8) + para.level = 0 + if not line.startswith("※"): + para.text = f"• {line}" + + +def _add_screenshot(slide, shot_name: str, left: float, top: float, width: float) -> bool: + path = SHOT_DIR / shot_name + if not path.is_file(): + return False + pic = slide.shapes.add_picture(str(path), Inches(left), Inches(top), width=Inches(width)) + max_height = Inches(5.85) + if pic.height > max_height: + scale = max_height / pic.height + pic.width = int(pic.width * scale) + pic.height = int(pic.height * scale) + frame = slide.shapes.add_shape( + MSO_SHAPE.RECTANGLE, + pic.left - Inches(0.04), + pic.top - Inches(0.04), + pic.width + Inches(0.08), + pic.height + Inches(0.08), + ) + frame.fill.background() + frame.line.color.rgb = BLUE_LIGHT + frame.line.width = Pt(1.25) + # picture on top + slide.shapes._spTree.remove(pic._element) + slide.shapes._spTree.insert(-1, pic._element) + return True + + +def add_cover_slide(prs: Presentation, title: str, bullets: list[str]) -> None: + slide = prs.slides.add_slide(prs.slide_layouts[6]) + bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0), Inches(0), SLIDE_W, SLIDE_H) + _set_solid_fill(bg, NAVY) + bg.line.fill.background() + + stripe = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0), Inches(2.8), SLIDE_W, Inches(0.08)) + _set_solid_fill(stripe, BLUE) + stripe.line.fill.background() + + tb = slide.shapes.add_textbox(Inches(0.9), Inches(1.5), Inches(11.5), Inches(1.0)) + tf = tb.text_frame + tf.text = title + p = tf.paragraphs[0] + p.font.size = Pt(44) + p.font.bold = True + p.font.color.rgb = WHITE + + sub = slide.shapes.add_textbox(Inches(0.9), Inches(3.2), Inches(11.0), Inches(2.5)) + stf = sub.text_frame + for i, line in enumerate(bullets): + para = stf.paragraphs[0] if i == 0 else stf.add_paragraph() + para.text = line + para.font.size = Pt(20 if i == 0 else 16) + para.font.bold = i == 0 + para.font.color.rgb = BLUE_LIGHT if i == 0 else SLATE_LIGHT + para.space_after = Pt(10) + + +def add_section_slide(prs: Presentation, title: str, page_num: int) -> None: + slide = prs.slides.add_slide(prs.slide_layouts[6]) + bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0), Inches(0), SLIDE_W, SLIDE_H) + _set_solid_fill(bg, BLUE) + bg.line.fill.background() + + tb = slide.shapes.add_textbox(Inches(0.9), Inches(3.0), Inches(11), Inches(1.2)) + tf = tb.text_frame + tf.text = title + p = tf.paragraphs[0] + p.font.size = Pt(36) + p.font.bold = True + p.font.color.rgb = WHITE + _add_footer(slide, page_num) + + +def add_text_slide(prs: Presentation, title: str, bullets: list[str], page_num: int) -> None: + """스크린샷 없이 본문만 — 설명·가이드용""" + slide = prs.slides.add_slide(prs.slide_layouts[6]) + bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0), Inches(0.95), SLIDE_W, Inches(6.55)) + _set_solid_fill(bg, BG) + bg.line.fill.background() + + _add_header_bar(slide, title) + + highlight = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0.55), Inches(1.15), Inches(0.08), Inches(5.6)) + _set_solid_fill(highlight, BLUE) + highlight.line.fill.background() + + _add_bullets(slide, bullets, 0.75, 1.25, 11.8, 5.5, font_size=15) + _add_footer(slide, page_num) + + +def add_content_slide(prs: Presentation, title: str, bullets: list[str], shot_name: str | None, page_num: int) -> None: + slide = prs.slides.add_slide(prs.slide_layouts[6]) + bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0), Inches(0.95), SLIDE_W, Inches(6.55)) + _set_solid_fill(bg, BG) + bg.line.fill.background() + + _add_header_bar(slide, title) + + has_shot = shot_name and (SHOT_DIR / shot_name).is_file() + text_w = 5.6 if has_shot else 12.0 + fs = 13 if len(bullets) >= 5 else 14 + _add_bullets(slide, bullets, 0.55, 1.25, text_w, 5.5, font_size=fs) + + if has_shot and shot_name: + _add_screenshot(slide, shot_name, 6.45, 1.15, 6.35) + + _add_footer(slide, page_num) + + +def add_closing_slide(prs: Presentation, title: str, bullets: list[str], page_num: int) -> None: + slide = prs.slides.add_slide(prs.slide_layouts[6]) + bg = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(0), Inches(0), SLIDE_W, SLIDE_H) + _set_solid_fill(bg, NAVY) + bg.line.fill.background() + + tb = slide.shapes.add_textbox(Inches(0.9), Inches(2.6), Inches(11.5), Inches(1.0)) + tf = tb.text_frame + tf.text = title + p = tf.paragraphs[0] + p.font.size = Pt(40) + p.font.bold = True + p.font.color.rgb = WHITE + p.alignment = PP_ALIGN.CENTER + + sub = slide.shapes.add_textbox(Inches(0.9), Inches(3.8), Inches(11.5), Inches(1.5)) + stf = sub.text_frame + for i, line in enumerate(bullets): + para = stf.paragraphs[0] if i == 0 else stf.add_paragraph() + para.text = line + para.font.size = Pt(18) + para.font.color.rgb = SLATE_LIGHT + para.alignment = PP_ALIGN.CENTER + para.space_after = Pt(6) + + _add_footer(slide, page_num) + + +def build() -> Path: + prs = Presentation() + prs.slide_width = SLIDE_W + prs.slide_height = SLIDE_H + + page = 0 + for kind, title, bullets, shot in SLIDES: + page += 1 + if kind == "cover": + add_cover_slide(prs, title, bullets) + elif kind == "section": + add_section_slide(prs, title, page) + elif kind == "closing": + add_closing_slide(prs, title, bullets, page) + elif kind == "text": + add_text_slide(prs, title, bullets, page) + else: + add_content_slide(prs, title, bullets, shot, page) + + OUT_PPT.parent.mkdir(parents=True, exist_ok=True) + prs.save(str(OUT_PPT)) + return OUT_PPT + + +if __name__ == "__main__": + out = build() + print(f"PPT saved: {out}") diff --git a/scripts/build-menu-guide-ppt.py b/scripts/build-menu-guide-ppt.py new file mode 100644 index 0000000..cf2ac83 --- /dev/null +++ b/scripts/build-menu-guide-ppt.py @@ -0,0 +1,285 @@ +#!/usr/bin/env python3 +""" +AI Platform 메뉴 안내 PPT 생성 (python-pptx) +스크린샷: docs/ppt-screenshots/*.png +""" +from __future__ import annotations + +import os +from pathlib import Path + +from pptx import Presentation +from pptx.dml.color import RGBColor +from pptx.enum.text import MSO_ANCHOR, PP_ALIGN +from pptx.util import Inches, Pt + +ROOT = Path(__file__).resolve().parent.parent +SHOT_DIR = ROOT / "docs" / "ppt-screenshots" +OUT_PPT = ROOT / "docs" / "XAVIS-AI-Platform-메뉴안내.pptx" + +# 슬라이드 정의: (제목, 불릿 목록, 스크린샷 파일명 또는 None) +SLIDES: list[tuple[str, list[str], str | None]] = [ + ( + "XAVIS AI Platform 메뉴 안내", + [ + "사내 AI 도구·학습·과제·활용 사례 통합 포털", + "접속: https://ai.xavis.co.kr/", + "최초 1회: @ncue.net 이메일 → 인증 메일 링크(15분 유효)", + "좌측 메뉴: 회사규정 · WM · AI · 프롬프트 · 학습센터 · 과제신청 · AI 활용 사례 · 대시보드(허용자)", + ], + None, + ), + ( + "1. 서비스 접속 (로그인)", + [ + "경로: /login — 미인증 시 자동 이동", + "회사 이메일 입력 → [검증] → 메일 [인증 완료하기] 클릭", + "인증 후 학습센터 등 원하는 화면으로 이동", + "유의: @ncue.net 만 가능 · 링크 만료 시 재검증 · 로그아웃은 좌측 하단", + ], + "login.png", + ), + ( + "2. 회사규정", + [ + "좌측 [회사규정] 클릭 → Google NotebookLM (새 탭, 회사 Google 계정 로그인)", + "사내 규정·지침 AI 검색·요약·질의응답", + "캡처: 좌측 메뉴 [회사규정] 위치 (학습센터 화면)", + "AI 답변은 원문 규정·다우오피스 공식 문서와 반드시 대조", + ], + "company-policy.png", + ), + ( + "3. WM", + [ + "좌측 [WM] 클릭 → NotebookLM WM 노트북 (새 탭)", + "WM 프로세스·용어·가이드 질의", + "캡처: 좌측 메뉴 [WM] 위치 (AI 화면)", + "공식 매뉴얼·담당 부서 확인 병행", + ], + "wm.png", + ), + ( + "4. AI (메인 허브)", + [ + "경로: /ai-explore — AI 서비스 카드 허브", + "검색 + 타입 필터(전체/일반/XScan/FScan)", + "회의록 AI · 업무 체크리스트 · 일반 채팅 · FSCAN 선정도우미", + ], + "ai-explore.png", + ), + ( + "5. 회의록 AI", + [ + "텍스트 입력: 회의 원문 붙여넣기 → 회의록 생성 → 저장", + "음성 파일: mp3·m4a·wav(최대 300MB) → 업로드→전사→회의록 정리", + "저장 시 업무 체크리스트 AI와 자동 연동", + "대안: Claude 음성 업로드 / 클로버노트 전사 → 텍스트 입력 탭 붙여넣기", + "유의: 생성 결과 검토 필수 · 개인정보·대외비 주의", + ], + "meeting-minutes.png", + ), + ( + "6. 업무 체크리스트 AI", + [ + "회의록 AI 저장분의 액션·체크리스트 통합 관리", + "진행/완료 필터 · 회의별 필터 · 완료 처리 메모", + "흐름: 회의록 AI 저장 → 체크리스트에서 추적", + ], + "task-checklist.png", + ), + ( + "7. 일반 채팅", + [ + "ChatGPT 기반 사내 인앱 채팅 (로그인 필요)", + "업무 질의·초안·아이디어 · (설정 시) 웹 검색", + "반복 프롬프트는 [프롬프트] 메뉴 템플릿 활용", + "유의: AI 답변은 실수할 수 있음 · 기밀 입력 자제", + ], + "chat.png", + ), + ( + "8. FSCAN 조사각 선정도우미", + [ + "검사 대상물 H/W 치수 입력 → FSCAN 모델 1차 선정", + "영업·기술 검토 시 빠른 선정용", + "최종 스펙은 공식 카탈로그·기술팀 확인 필수", + ], + "fscan.png", + ), + ( + "9. 프롬프트 라이브러리", + [ + "공식 템플릿: 회의 요약·이메일·보고·OKR·코드리뷰 등 → 복사", + "워크플로: 4단계 입력 → 맞춤 프롬프트 초안", + "공유하기: 팀 프롬프트·참고 파일 (기밀 제외)", + ], + "prompts.png", + ), + ( + "10. 학습센터", + [ + "YouTube · PPT/PDF · 동영상 · 웹·뉴스 링크 강의", + "카테고리: AX 사고 전환 · AI 툴 활용 · AI Agent · 바이브 코딩", + "검색 예: claude, 클로드 → 도구별 강의 찾기", + ], + "learning.png", + ), + ( + "11. AX 과제 신청", + [ + "온라인 신청 + Word 양식 다운로드", + "Pain Point · AI 기대 · 데이터·효과 등 작성", + "조회로 본인 신청 확인·수정 · AI 활용 사례 참고", + ], + "ax-apply.png", + ), + ( + "12. AI 활용 사례", + [ + "부서별 도입·성과 사례 카드 열람", + "글쓰기: STAR 형식(1.Situation~4.Result)", + "AX 과제·팀 공유 레퍼런스", + ], + "ai-cases.png", + ), + ( + "13. 대시보드 (허용 계정)", + [ + "허용 이메일만 좌측 메뉴 표시", + "경영성과: 연도·분기 KPI 차트 + 매출일보 엑셀 업로드", + ], + "dashboard.png", + ), + ( + "14. 경영성과 대시보드", + [ + "상단: Chart.js KPI·차트 조회", + "하단: .xlsx 매출일보 업로드 → 스냅샷 저장", + ], + "dashboard-business-performance.png", + ), + ( + "업무별 Quick Reference", + [ + "음성 회의록 → AI → 회의록 AI (또는 클로버노트 → 텍스트 입력)", + "할 일 추적 → 업무 체크리스트 AI", + "빠른 질문 → 일반 채팅 · 메일·보고 → 프롬프트", + "규정 → 회사규정 · 학습 → 학습센터 · AI 과제 → 과제신청", + "문의: AI혁신팀", + ], + None, + ), +] + +ACCENT = RGBColor(0x25, 0x63, 0xEB) +DARK = RGBColor(0x11, 0x18, 0x27) +GRAY = RGBColor(0x4B, 0x55, 0x63) + + +def add_title_slide(prs: Presentation, title: str, bullets: list[str]) -> None: + layout = prs.slide_layouts[6] # blank + slide = prs.slides.add_slide(layout) + slide.background.fill.solid() + slide.background.fill.fore_color.rgb = RGBColor(0xF0, 0xF4, 0xFF) + + box = slide.shapes.add_textbox(Inches(0.8), Inches(2.2), Inches(11.5), Inches(1.2)) + tf = box.text_frame + tf.text = title + p = tf.paragraphs[0] + p.font.size = Pt(40) + p.font.bold = True + p.font.color.rgb = DARK + p.alignment = PP_ALIGN.CENTER + + sub = slide.shapes.add_textbox(Inches(1.2), Inches(3.6), Inches(10.8), Inches(2.5)) + stf = sub.text_frame + stf.word_wrap = True + for i, line in enumerate(bullets[:4]): + para = stf.paragraphs[0] if i == 0 else stf.add_paragraph() + para.text = line + para.font.size = Pt(18) + para.font.color.rgb = GRAY + para.space_after = Pt(8) + para.alignment = PP_ALIGN.CENTER + + +def add_content_slide( + prs: Presentation, + title: str, + bullets: list[str], + shot_name: str | None, +) -> None: + layout = prs.slide_layouts[6] + slide = prs.slides.add_slide(layout) + + # 제목 + title_box = slide.shapes.add_textbox(Inches(0.5), Inches(0.25), Inches(12.3), Inches(0.6)) + ttf = title_box.text_frame + ttf.text = title + tp = ttf.paragraphs[0] + tp.font.size = Pt(26) + tp.font.bold = True + tp.font.color.rgb = ACCENT + + has_shot = shot_name and (SHOT_DIR / shot_name).is_file() + text_left = Inches(0.5) + text_top = Inches(0.95) + text_w = Inches(5.8) if has_shot else Inches(12.3) + text_h = Inches(6.2) + + body = slide.shapes.add_textbox(text_left, text_top, text_w, text_h) + btf = body.text_frame + btf.word_wrap = True + for i, line in enumerate(bullets): + para = btf.paragraphs[0] if i == 0 else btf.add_paragraph() + para.text = f"• {line}" + para.font.size = Pt(14) + para.font.color.rgb = DARK + para.space_after = Pt(6) + para.level = 0 + + if has_shot: + shot_path = str(SHOT_DIR / shot_name) + slide.shapes.add_picture( + shot_path, + Inches(6.6), + Inches(0.85), + width=Inches(6.2), + ) + elif shot_name: + note = slide.shapes.add_textbox(Inches(6.6), Inches(2.5), Inches(6.0), Inches(1.0)) + ntf = note.text_frame + ntf.text = f"(캡처 없음: {shot_name})" + ntf.paragraphs[0].font.size = Pt(12) + ntf.paragraphs[0].font.color.rgb = GRAY + + # 슬라이드 번호 + num = len(prs.slides) + footer = slide.shapes.add_textbox(Inches(12.0), Inches(7.05), Inches(0.8), Inches(0.3)) + ftf = footer.text_frame + ftf.text = str(num) + ftf.paragraphs[0].font.size = Pt(10) + ftf.paragraphs[0].font.color.rgb = GRAY + ftf.paragraphs[0].alignment = PP_ALIGN.RIGHT + + +def build() -> Path: + prs = Presentation() + prs.slide_width = Inches(13.333) + prs.slide_height = Inches(7.5) + + for idx, (title, bullets, shot) in enumerate(SLIDES): + if idx == 0: + add_title_slide(prs, title, bullets) + else: + add_content_slide(prs, title, bullets, shot) + + OUT_PPT.parent.mkdir(parents=True, exist_ok=True) + prs.save(str(OUT_PPT)) + return OUT_PPT + + +if __name__ == "__main__": + out = build() + print(f"PPT saved: {out}") diff --git a/scripts/capture-menu-screenshots.mjs b/scripts/capture-menu-screenshots.mjs new file mode 100644 index 0000000..b15dc86 --- /dev/null +++ b/scripts/capture-menu-screenshots.mjs @@ -0,0 +1,293 @@ +/** + * AI Platform 메뉴 화면 캡처 (Playwright) + * - 로그인: 운영 URL (PROD 전용 화면) + * - 나머지: 로컬 SUPER 모드 서버 (인증 없이 캡처) + * - 대시보드: OPS 세션 쿠키(허용 이메일) 주입 후 캡처 + */ +import { chromium } from "playwright"; +import crypto from "crypto"; +import fs from "fs/promises"; +import fsSync from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const OUT_DIR = + process.env.CAPTURE_OUT_DIR || + path.join(__dirname, "..", "docs", "ppt-screenshots"); +const LOCAL_BASE = process.env.CAPTURE_BASE_URL || "http://127.0.0.1:8030"; +const PROD_BASE = (process.env.CAPTURE_PROD_BASE || "https://ai.xavis.co.kr").replace(/\/$/, ""); +const PROD_LOGIN = `${PROD_BASE}/login`; +/** 목록(학습·사례)은 운영 데이터·전체 로드 후 캡처 */ +const CAPTURE_LISTS_FROM_PROD = process.env.CAPTURE_LISTS_FROM_PROD !== "0"; +/** 일반 사용자 OPS 세션 (관리자·특수 메뉴 쿠키 없음) */ +const CAPTURE_USER_EMAIL = ( + process.env.CAPTURE_USER_EMAIL || "employee@ncue.net" +) + .trim() + .toLowerCase(); +/** general: 가이드봇·WM·대시보드·체크리스트 제외 캡처 */ +const CAPTURE_PROFILE = (process.env.CAPTURE_PROFILE || "general").trim().toLowerCase(); + +const LOCAL_PAGES_ALL = [ + { id: "ai-explore", path: "/ai-explore", waitMs: 800 }, + { id: "meeting-minutes", path: "/ai-explore/meeting-minutes", waitMs: 1200 }, + { id: "task-checklist", path: "/ai-explore/task-checklist", waitMs: 1200 }, + { id: "chat", path: "/ai-explore/chat", waitMs: 800 }, + { id: "fscan", path: "/ai-explore/fscan", waitMs: 2000 }, + { id: "prompts", path: "/ai-explore/prompts", waitMs: 1500 }, + { id: "ax-apply", path: "/ax-apply", waitMs: 1000 }, +]; + +/** 목록 패널 전체(element) 캡처 — viewport 잘림 방지 */ +const LIST_PANEL_PAGES = [ + { + id: "learning", + path: "/learning", + selector: "section.panel:has(#lecture-results-root)", + useProd: true, + preload: "learning", + }, + { + id: "ai-cases", + path: "/ai-cases", + selector: "section.panel:has(.success-story-grid)", + useProd: true, + preload: "none", + }, + { + id: "ai-cases-compose", + path: "/ai-cases/compose", + selector: "main.use-case-compose", + useProd: true, + preload: "none", + }, +]; + +const LOCAL_PAGES = + CAPTURE_PROFILE === "general" + ? LOCAL_PAGES_ALL.filter( + (p) => !["task-checklist", "dashboard", "dashboard-business-performance"].includes(p.id) + ) + : LOCAL_PAGES_ALL; + +/** 일반 사용자 좌측 메뉴(가이드봇·WM·대시보드 없음) */ +const MENU_OVERVIEW = { id: "menu-overview", path: "/ai-explore", waitMs: 1000 }; + +function readEnvValue(key) { + const envPath = path.join(__dirname, "..", ".env"); + try { + const raw = fsSync.readFileSync(envPath, "utf8"); + const re = new RegExp(`^${key}=(.+)$`, "m"); + const m = raw.match(re); + if (!m) return ""; + return m[1].split("#")[0].trim().replace(/^["']|["']$/g, ""); + } catch { + return ""; + } +} + +/** ops-auth.js 와 동일한 세션 쿠키 서명 */ +function buildOpsSessionCookie(email) { + const secret = readEnvValue("AUTH_SECRET") || readEnvValue("ADMIN_TOKEN") || "ncue-admin"; + const exp = Number.MAX_SAFE_INTEGER; + const iat = Date.now(); + const payload = `${email}|${exp}|${iat}`; + const sig = crypto.createHmac("sha256", secret).update(payload).digest("hex"); + return Buffer.from(JSON.stringify({ email, exp, iat, sig })).toString("base64url"); +} + +function captureUserEmail() { + return CAPTURE_USER_EMAIL; +} + +async function capturePage(page, url, outPath, waitMs = 1000, fullPage = false) { + await page.goto(url, { waitUntil: "networkidle", timeout: 90000 }); + await page.waitForTimeout(waitMs); + await page.screenshot({ path: outPath, fullPage }); + console.log(" saved:", outPath); +} + +/** 학습센터 무한 스크롤 — 모든 페이지 로드 */ +async function loadAllLearningPages(page) { + await page.waitForSelector("#lecture-grid", { timeout: 60000 }); + let guard = 0; + while (guard < 80) { + guard += 1; + const state = await page.evaluate(() => { + const sentinel = document.getElementById("infinite-scroll-sentinel"); + const btn = document.getElementById("lecture-load-more-btn"); + const countEl = document.getElementById("lecture-total-count"); + const cards = document.querySelectorAll("#lecture-grid .lecture-card, #lecture-grid a.lecture-card"); + return { + hasNext: !!(sentinel && sentinel.getAttribute("data-has-next") === "true"), + hasBtn: !!btn, + total: countEl ? countEl.textContent.trim() : "", + loaded: cards.length, + }; + }); + if (!state.hasNext) break; + if (state.hasBtn) { + await page.locator("#lecture-load-more-btn").click({ timeout: 5000 }).catch(() => {}); + } else { + await page.evaluate(() => { + const s = document.getElementById("infinite-scroll-sentinel"); + if (s) s.scrollIntoView({ block: "end", behavior: "instant" }); + window.scrollTo(0, document.body.scrollHeight); + }); + } + await page.waitForTimeout(900); + await page + .waitForFunction( + () => { + const el = document.getElementById("infinite-scroll-loading"); + return !el || el.style.display === "none" || el.style.display === ""; + }, + { timeout: 45000 } + ) + .catch(() => {}); + } + const finalCount = await page.evaluate(() => { + const countEl = document.getElementById("lecture-total-count"); + const cards = document.querySelectorAll("#lecture-grid .lecture-card, #lecture-grid a.lecture-card"); + return { total: countEl ? countEl.textContent.trim() : "?", loaded: cards.length }; + }); + console.log(" learning loaded:", finalCount.loaded, "/ total", finalCount.total); +} + +/** 목록 섹션(element) 캡처 — 카드 전체가 포함되도록 */ +async function captureListPanel(page, baseUrl, item, outPath) { + const url = `${baseUrl}${item.path}`; + await page.goto(url, { waitUntil: "networkidle", timeout: 90000 }); + if (item.preload === "learning") { + await loadAllLearningPages(page); + } + await page.waitForSelector(item.selector, { timeout: 60000 }); + await page.waitForTimeout(600); + const panel = page.locator(item.selector).first(); + await panel.scrollIntoViewIfNeeded(); + await panel.screenshot({ path: outPath }); + const meta = await page.evaluate((sel) => { + const cards = + sel.includes("lecture") + ? document.querySelectorAll("#lecture-grid .lecture-card, #lecture-grid a.lecture-card").length + : document.querySelectorAll(".success-story-grid .success-story-card, .success-story-grid a").length; + const chip = document.querySelector(".count-chip"); + return { cards, chip: chip ? chip.textContent.trim() : "" }; + }, item.selector); + console.log(" saved:", outPath, meta.chip || "", "visible cards:", meta.cards); +} + +async function main() { + await fs.mkdir(OUT_DIR, { recursive: true }); + + const browser = await chromium.launch({ headless: true }); + const context = await browser.newContext({ + viewport: { width: 1440, height: 900 }, + locale: "ko-KR", + }); + const page = await context.newPage(); + + const userEmail = captureUserEmail(); + const opsCookie = buildOpsSessionCookie(userEmail); + const cookieJar = [ + { + name: "ops_user_session", + value: opsCookie, + url: LOCAL_BASE, + httpOnly: true, + sameSite: "Lax", + }, + ]; + if (CAPTURE_LISTS_FROM_PROD) { + cookieJar.push({ + name: "ops_user_session", + value: opsCookie, + domain: new URL(PROD_BASE).hostname, + path: "/", + httpOnly: true, + secure: true, + sameSite: "Lax", + }); + } + await context.addCookies(cookieJar); + console.log("Capture persona:", userEmail, "(no admin cookie)", "profile:", CAPTURE_PROFILE); + console.log("Output dir:", OUT_DIR); + + console.log("[1/4] Login (production)..."); + try { + await capturePage( + page, + PROD_LOGIN, + path.join(OUT_DIR, "login.png"), + 1500 + ); + } catch (err) { + console.warn(" login capture failed:", err.message); + } + + console.log("[2/4] Local menus (PROD + OPS cookie, no admin)..."); + for (const item of LOCAL_PAGES) { + const outPath = path.join(OUT_DIR, `${item.id}.png`); + try { + await capturePage(page, `${LOCAL_BASE}${item.path}`, outPath, item.waitMs); + } catch (err) { + console.warn(` ${item.id} failed:`, err.message); + } + } + + console.log("[2b/4] List panels (full load + element capture)..."); + for (const item of LIST_PANEL_PAGES) { + const outPath = path.join(OUT_DIR, `${item.id}.png`); + const base = item.useProd && CAPTURE_LISTS_FROM_PROD ? PROD_BASE : LOCAL_BASE; + try { + await captureListPanel(page, base, item, outPath); + } catch (err) { + console.warn(` ${item.id} list panel failed (${base}):`, err.message); + if (base !== LOCAL_BASE) { + try { + await captureListPanel(page, LOCAL_BASE, item, outPath); + } catch (err2) { + console.warn(` ${item.id} local fallback failed:`, err2.message); + } + } + } + } + + console.log("[3/4] Menu overview..."); + try { + await capturePage( + page, + `${LOCAL_BASE}${MENU_OVERVIEW.path}`, + path.join(OUT_DIR, `${MENU_OVERVIEW.id}.png`), + MENU_OVERVIEW.waitMs + ); + } catch (err) { + console.warn(" menu-overview failed:", err.message); + } + + if (CAPTURE_PROFILE !== "general") { + console.log("[4/4] Dashboard (허용 계정:", userEmail, ")..."); + for (const item of [ + { id: "dashboard", path: "/dashboard", waitMs: 1000 }, + { id: "dashboard-business-performance", path: "/dashboard/business-performance", waitMs: 2500 }, + ]) { + const outPath = path.join(OUT_DIR, `${item.id}.png`); + try { + await capturePage(page, `${LOCAL_BASE}${item.path}`, outPath, item.waitMs); + } catch (err) { + console.warn(` ${item.id} failed:`, err.message); + } + } + } else { + console.log("[4/4] Dashboard skip (general profile)"); + } + + await browser.close(); + console.log("Done. Output:", OUT_DIR); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/lib/load-env.sh b/scripts/lib/load-env.sh new file mode 100755 index 0000000..bfa50c2 --- /dev/null +++ b/scripts/lib/load-env.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# 프로젝트 루트 .env에서 KEY=VALUE 줄만 export (섹션 헤더 [..]·주석 무시) +load_project_env() { + local env_file="$1" + if [[ ! -f "$env_file" ]]; then + echo "load_project_env: .env not found: $env_file" >&2 + return 1 + fi + while IFS= read -r line || [[ -n "$line" ]]; do + line="${line#"${line%%[![:space:]]*}"}" + [[ -z "$line" || "$line" == \#* ]] && continue + [[ "$line" == \[* ]] && continue + if [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then + local key="${BASH_REMATCH[1]}" + local val="${BASH_REMATCH[2]}" + if [[ "$val" =~ ^\"(.*)\"$ ]]; then + val="${BASH_REMATCH[1]}" + elif [[ "$val" =~ ^\'(.*)\'$ ]]; then + val="${BASH_REMATCH[1]}" + fi + export "$key=$val" + fi + done < "$env_file" +} diff --git a/scripts/merge-orphan-ai-success-stories.js b/scripts/merge-orphan-ai-success-stories.js new file mode 100644 index 0000000..2dd7d64 --- /dev/null +++ b/scripts/merge-orphan-ai-success-stories.js @@ -0,0 +1,122 @@ +#!/usr/bin/env node +/** + * data/ai-success-stories.json 에는 없는데 data/ai-success-stories/*.md 만 남은 경우( Git pull로 json만 예전으로 돌아간 경우 등 ) + * 본문 파일을 스캔해 메타 항목을 자동 추가합니다. + * + * 사용: 프로젝트 루트에서 + * node scripts/merge-orphan-ai-success-stories.js + * + * 동작: json 백업(data/ai-success-stories.json.bak.<시간>) 후, 등록되지 않은 .md 마다 항목 추가. + * 제목은 md 첫 줄의 # 제목을 쓰고, 없으면 슬러그에서 추정한 임시 제목. + * pdfUrl은 비어 있음 → 관리자 /ai-cases/write 에서 해당 사례 편집 후 PDF 경로를 다시 넣으세요. + */ + +const path = require("path"); +const fs = require("fs"); + +const ROOT = path.join(__dirname, ".."); +const DATA_DIR = path.join(ROOT, "data"); +const META_PATH = path.join(DATA_DIR, "ai-success-stories.json"); +const CONTENT_DIR = path.join(DATA_DIR, "ai-success-stories"); + +function parseSlugFromMdName(basename) { + const noExt = basename.replace(/\.md$/i, ""); + const m = noExt.match(/^(.+)-(\d{10,20})$/); + if (m) return m[1].toLowerCase(); + return noExt.toLowerCase().replace(/[^a-z0-9\-]/g, "") || "story"; +} + +function firstHeadingOrTitle(md, slug) { + const lines = md.split(/\r?\n/); + for (const line of lines) { + const t = line.trim(); + if (t.startsWith("# ")) return t.slice(2).trim(); + } + return ( + slug + .split("-") + .filter(Boolean) + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(" ") || "사례 (제목 확인)" + ); +} + +function excerptFromMd(md) { + const lines = md.split(/\r?\n/).map((l) => l.trim()); + const paras = []; + for (const line of lines) { + if (!line || line.startsWith("#")) continue; + if (line.startsWith("```")) break; + paras.push(line); + if (paras.join(" ").length > 200) break; + } + const e = paras.join(" ").slice(0, 280); + return e || ""; +} + +function main() { + if (!fs.existsSync(META_PATH)) { + console.error("없음:", META_PATH); + process.exit(1); + } + if (!fs.existsSync(CONTENT_DIR)) { + console.error("없음:", CONTENT_DIR); + process.exit(1); + } + + const raw = fs.readFileSync(META_PATH, "utf8"); + const meta = JSON.parse(raw); + if (!Array.isArray(meta)) { + console.error("ai-success-stories.json 형식이 배열이 아닙니다."); + process.exit(1); + } + + const used = new Set(meta.map((m) => (m.contentFile || "").split("/").pop()).filter(Boolean)); + const files = fs.readdirSync(CONTENT_DIR).filter((f) => /\.md$/i.test(f)); + + const orphans = files.filter((f) => !used.has(f)); + if (orphans.length === 0) { + console.log("추가할 고아 .md 없음. 종료."); + return; + } + + const bak = `${META_PATH}.bak.${Date.now()}`; + fs.copyFileSync(META_PATH, bak); + console.log("백업:", bak); + + for (const file of orphans) { + const full = path.join(CONTENT_DIR, file); + const md = fs.readFileSync(full, "utf8"); + const slug = parseSlugFromMdName(file); + if (meta.some((m) => m.slug === slug)) { + console.warn("슬러그 충돌 건너뜀 (이미 다른 항목에 동일 slug):", slug, file); + continue; + } + const title = firstHeadingOrTitle(md, slug); + const excerpt = excerptFromMd(md) || title.slice(0, 140); + const tsMatch = file.match(/-(\d{10,20})\.md$/i); + const id = tsMatch ? `story-${tsMatch[1]}` : `story-${Date.now()}`; + const row = { + id, + slug, + title, + excerpt, + author: "", + department: "", + publishedAt: new Date().toISOString().slice(0, 10), + tags: [], + contentFile: file, + pdfUrl: "", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + meta.push(row); + console.log("추가:", file, "→ slug:", slug); + } + + fs.writeFileSync(META_PATH, JSON.stringify(meta, null, 2), "utf8"); + console.log("저장 완료:", META_PATH, "총", meta.length, "건"); + console.log("pdfUrl이 비어 있으면 관리자 화면에서 PDF 경로를 지정하세요."); +} + +main(); diff --git a/scripts/pg-backup.sh b/scripts/pg-backup.sh new file mode 100755 index 0000000..1ff6c34 --- /dev/null +++ b/scripts/pg-backup.sh @@ -0,0 +1,181 @@ +#!/usr/bin/env bash +# PostgreSQL 논리 백업 (pg_dump -Fc). .env의 DB_* 및 PG_BACKUP_* 사용. +# PG_BACKUP_SCOPE=all 이면 서버의 연결 가능·비템플릿 DB 전체를 각각 .dump 로 저장. +# 운영 서버 cron 예: 0 2 * * * cd /home/xavis/workspace/ai_platform && bash scripts/pg-backup.sh >> /var/log/pg-backup.log 2>&1 +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=scripts/lib/load-env.sh +source "$REPO_ROOT/scripts/lib/load-env.sh" + +load_project_env "$REPO_ROOT/.env" + +: "${DB_HOST:?DB_HOST is required in .env}" +: "${DB_DATABASE:?DB_DATABASE is required in .env}" +: "${DB_USERNAME:?DB_USERNAME is required in .env}" +: "${DB_PASSWORD:?DB_PASSWORD is required in .env}" + +DB_PORT="${DB_PORT:-5432}" +PG_BACKUP_DIR="${PG_BACKUP_DIR:-/home/xavis/workspace/backup/ai_platform}" +PG_BACKUP_RETENTION_DAYS="${PG_BACKUP_RETENTION_DAYS:-30}" +PG_BACKUP_SCOPE="${PG_BACKUP_SCOPE:-all}" +PG_BACKUP_GLOBALS="${PG_BACKUP_GLOBALS:-1}" + +export PGHOST="$DB_HOST" +export PGPORT="$DB_PORT" + +if ! command -v pg_dump >/dev/null 2>&1; then + echo "pg-backup: pg_dump not found. Install postgresql-client (apt) or postgresql (brew)." >&2 + exit 1 +fi +if ! command -v psql >/dev/null 2>&1; then + echo "pg-backup: psql not found. Install postgresql-client." >&2 + exit 1 +fi + +log_ts() { date '+%Y-%m-%dT%H:%M:%S%z'; } + +set_backup_credentials() { + if [[ -n "${PG_BACKUP_SUPERUSER_PASSWORD:-}" ]]; then + export PGUSER="${PG_BACKUP_SUPERUSER:-postgres}" + export PGPASSWORD="$PG_BACKUP_SUPERUSER_PASSWORD" + else + export PGUSER="$DB_USERNAME" + export PGPASSWORD="$DB_PASSWORD" + fi +} + +list_all_databases() { + psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d postgres -v ON_ERROR_STOP=1 -tAc \ + "SELECT datname FROM pg_database WHERE datallowconn AND NOT datistemplate ORDER BY datname;" +} + +dump_database() { + local db_name="$1" + local dump_file="$2" + pg_dump \ + -h "$PGHOST" \ + -p "$PGPORT" \ + -U "$PGUSER" \ + -d "$db_name" \ + -Fc \ + --no-password \ + -f "$dump_file" +} + +backup_globals() { + if [[ "$PG_BACKUP_GLOBALS" != "1" ]]; then + return 0 + fi + if [[ -z "${PG_BACKUP_SUPERUSER_PASSWORD:-}" ]]; then + echo "[$(log_ts)] globals skipped: set PG_BACKUP_SUPERUSER_PASSWORD for role/global backup." >&2 + return 0 + fi + local prev_user="$PGUSER" prev_pass="$PGPASSWORD" + export PGUSER="${PG_BACKUP_SUPERUSER:-postgres}" + export PGPASSWORD="$PG_BACKUP_SUPERUSER_PASSWORD" + pg_dumpall \ + -h "$PGHOST" \ + -p "$PGPORT" \ + -U "$PGUSER" \ + --globals-only \ + --no-password \ + -f "$RUN_DIR/00_globals.sql" + export PGUSER="$prev_user" + export PGPASSWORD="$prev_pass" + echo "[$(log_ts)] globals saved: $RUN_DIR/00_globals.sql" +} + +set_backup_credentials + +# cron 일 1회 기준: YYYYMMDD 폴더에 당일 백업 저장 +STAMP="$(date +%Y%m%d)" +RUN_DIR="$PG_BACKUP_DIR/$STAMP" +mkdir -p "$RUN_DIR" + +echo "[$(log_ts)] pg-backup start → $RUN_DIR (scope=${PG_BACKUP_SCOPE}, retention ${PG_BACKUP_RETENTION_DAYS} days)" + +if [[ -z "${PG_BACKUP_SUPERUSER_PASSWORD:-}" && "$PG_BACKUP_SCOPE" == "all" ]]; then + echo "[$(log_ts)] note: PG_BACKUP_SUPERUSER_PASSWORD not set; backing up only databases visible to ${DB_USERNAME}." >&2 +fi + +backup_globals + +declare -a TARGET_DBS=() +if [[ "$PG_BACKUP_SCOPE" == "single" ]]; then + TARGET_DBS=("$DB_DATABASE") +else + while IFS= read -r db; do + [[ -n "$db" ]] && TARGET_DBS+=("$db") + done < <(list_all_databases) + if [[ ${#TARGET_DBS[@]} -eq 0 ]]; then + echo "pg-backup: no databases found to backup." >&2 + exit 1 + fi +fi + +MANIFEST="$RUN_DIR/00_manifest.txt" +{ + echo "# pg-backup manifest $(log_ts)" + echo "scope=${PG_BACKUP_SCOPE}" + echo "host=${PGHOST}:${PGPORT}" + echo "user=${PGUSER}" +} > "$MANIFEST" + +dump_ok=0 +dump_fail=0 +for db_name in "${TARGET_DBS[@]}"; do + dump_file="$RUN_DIR/${db_name}.dump" + echo "[$(log_ts)] dumping database: $db_name" + if dump_database "$db_name" "$dump_file"; then + bytes="$(wc -c < "$dump_file" | tr -d ' ')" + echo "[$(log_ts)] dump saved: $dump_file (${bytes} bytes)" + echo "${db_name}.dump ${bytes}" >> "$MANIFEST" + dump_ok=$((dump_ok + 1)) + else + echo "[$(log_ts)] dump failed: $db_name" >&2 + dump_fail=$((dump_fail + 1)) + fi +done + +if [[ "$dump_ok" -eq 0 ]]; then + echo "pg-backup: all database dumps failed." >&2 + exit 1 +fi +if [[ "$dump_fail" -gt 0 ]]; then + echo "[$(log_ts)] warning: ${dump_fail} database dump(s) failed, ${dump_ok} succeeded." >&2 +fi + +ln -sfn "$RUN_DIR" "$PG_BACKUP_DIR/latest" + +prune_old_backups() { + local cutoff removed=0 base day entry + if date -d "1 day ago" +%Y%m%d >/dev/null 2>&1; then + cutoff=$(date -d "${PG_BACKUP_RETENTION_DAYS} days ago" +%Y%m%d) + else + cutoff=$(date -v-"${PG_BACKUP_RETENTION_DAYS}"d +%Y%m%d) + fi + + shopt -s nullglob + for entry in "$PG_BACKUP_DIR"/*; do + [[ -d "$entry" ]] || continue + base=$(basename "$entry") + [[ "$base" == "latest" ]] && continue + if [[ "$base" =~ ^([0-9]{8}) ]]; then + day="${BASH_REMATCH[1]}" + if [[ "$day" < "$cutoff" ]]; then + rm -rf "$entry" + removed=$((removed + 1)) + echo "[$(log_ts)] retention: removed $entry (backup date $day < cutoff $cutoff)" + fi + fi + done + shopt -u nullglob + echo "[$(log_ts)] retention: pruned ${removed} dir(s); keeping backups from ${cutoff} onward (${PG_BACKUP_RETENTION_DAYS} days)" +} + +if [[ "$PG_BACKUP_RETENTION_DAYS" =~ ^[0-9]+$ ]] && [[ "$PG_BACKUP_RETENTION_DAYS" -gt 0 ]]; then + prune_old_backups +fi + +echo "[$(log_ts)] pg-backup done: ${dump_ok} database(s), latest → $PG_BACKUP_DIR/latest" diff --git a/scripts/pg-restore.sh b/scripts/pg-restore.sh new file mode 100755 index 0000000..60a9015 --- /dev/null +++ b/scripts/pg-restore.sh @@ -0,0 +1,225 @@ +#!/usr/bin/env bash +# PostgreSQL 논리 복원 (pg_restore). custom format(.dump) 전용. +# 사용 예: +# bash scripts/pg-restore.sh /home/xavis/workspace/backup/ai_platform/latest/ai_web_platform.dump +# bash scripts/pg-restore.sh --all --globals --confirm /home/xavis/workspace/backup/ai_platform/latest/ +# bash scripts/pg-restore.sh --test --confirm /home/xavis/workspace/backup/ai_platform/latest/ai_web_platform.dump +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: bash scripts/pg-restore.sh [options] + +Options: + --all 백업 디렉터리 안의 *.dump 전체 복원 (00_globals.sql 있으면 --globals 권장) + --test 복원 대상 DB를 {dbname}_restore_test 로 생성 (단일 dump 또는 --all) + --clean pg_restore --clean --if-exists (기존 객체 삭제 후 복원, --test 미사용 시 위험) + --confirm 확인 없이 실행 (cron·자동화용; --test 없이 --clean 시 필수) + --globals 같은 디렉터리의 00_globals.sql 을 먼저 적용 (슈퍼유저 env 필요) + -h, --help 도움말 + +환경 변수 (.env): + DB_* 연결 정보 + PG_BACKUP_SUPERUSER --globals 시 사용 (기본 postgres) + PG_BACKUP_SUPERUSER_PASSWORD +EOF +} + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=scripts/lib/load-env.sh +source "$REPO_ROOT/scripts/lib/load-env.sh" + +MODE="prod" +CLEAN=0 +CONFIRM=0 +GLOBALS=0 +RESTORE_ALL=0 +TARGET_PATH="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --test) MODE="test"; shift ;; + --clean) CLEAN=1; shift ;; + --confirm) CONFIRM=1; shift ;; + --globals) GLOBALS=1; shift ;; + --all) RESTORE_ALL=1; shift ;; + -h|--help) usage; exit 0 ;; + -*) echo "Unknown option: $1" >&2; usage >&2; exit 1 ;; + *) + if [[ -n "$TARGET_PATH" ]]; then + echo "Unexpected argument: $1" >&2 + exit 1 + fi + TARGET_PATH="$1" + shift + ;; + esac +done + +if [[ -z "$TARGET_PATH" ]]; then + usage >&2 + exit 1 +fi + +load_project_env "$REPO_ROOT/.env" + +: "${DB_HOST:?DB_HOST is required in .env}" +: "${DB_DATABASE:?DB_DATABASE is required in .env}" +: "${DB_USERNAME:?DB_USERNAME is required in .env}" +: "${DB_PASSWORD:?DB_PASSWORD is required in .env}" + +DB_PORT="${DB_PORT:-5432}" + +export PGHOST="$DB_HOST" +export PGPORT="$DB_PORT" +export PGUSER="$DB_USERNAME" +export PGPASSWORD="$DB_PASSWORD" + +if ! command -v pg_restore >/dev/null 2>&1; then + echo "pg-restore: pg_restore not found. Install postgresql-client." >&2 + exit 1 +fi + +log_ts() { date '+%Y-%m-%dT%H:%M:%S%z'; } + +resolve_superuser_creds() { + SUPERUSER="${PG_BACKUP_SUPERUSER:-postgres}" + if [[ -n "${PG_BACKUP_SUPERUSER_PASSWORD:-}" ]]; then + SU_USER="$SUPERUSER" + SU_PASS="$PG_BACKUP_SUPERUSER_PASSWORD" + else + SU_USER="$DB_USERNAME" + SU_PASS="$DB_PASSWORD" + fi +} + +apply_globals() { + local globals_file="$1" + if [[ ! -f "$globals_file" ]]; then + echo "pg-restore: globals file not found: $globals_file" >&2 + exit 1 + fi + : "${PG_BACKUP_SUPERUSER_PASSWORD:?PG_BACKUP_SUPERUSER_PASSWORD required for --globals}" + resolve_superuser_creds + export PGUSER="$SU_USER" + export PGPASSWORD="$SU_PASS" + echo "[$(log_ts)] applying globals: $globals_file" + psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -v ON_ERROR_STOP=1 -f "$globals_file" + export PGUSER="$DB_USERNAME" + export PGPASSWORD="$DB_PASSWORD" +} + +ensure_database() { + local db_name="$1" + resolve_superuser_creds + export PGUSER="$SU_USER" + export PGPASSWORD="$SU_PASS" + psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -v ON_ERROR_STOP=1 -tc \ + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$db_name' AND pid <> pg_backend_pid();" \ + >/dev/null 2>&1 || true + if [[ "$MODE" == "test" ]]; then + psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS \"$db_name\";" + psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -v ON_ERROR_STOP=1 -c \ + "CREATE DATABASE \"$db_name\" OWNER \"$DB_USERNAME\";" + fi + export PGUSER="$DB_USERNAME" + export PGPASSWORD="$DB_PASSWORD" +} + +restore_one_dump() { + local dump_path="$1" + local source_db + source_db="$(basename "$dump_path" .dump)" + local target_db="$source_db" + if [[ "$MODE" == "test" ]]; then + target_db="${source_db}_restore_test" + ensure_database "$target_db" + fi + + local restore_args=( + -h "$PGHOST" + -p "$PGPORT" + -U "$PGUSER" + -d "$target_db" + --no-owner + --role="$DB_USERNAME" + -v + ) + if [[ "$CLEAN" -eq 1 ]]; then + restore_args+=(--clean --if-exists) + fi + + echo "[$(log_ts)] pg_restore → $target_db ($dump_path)" + pg_restore "${restore_args[@]}" "$dump_path" + echo "[$(log_ts)] pg-restore done: $target_db" +} + +confirm_restore() { + local message="$1" + if [[ "$MODE" == "prod" && "$CLEAN" -eq 1 && "$CONFIRM" -ne 1 ]]; then + echo "pg-restore: --clean on production DB requires --confirm" >&2 + exit 1 + fi + if [[ "$CONFIRM" -eq 1 ]]; then + return 0 + fi + echo "$message" + read -r -p "Continue? [y/N] " ans + [[ "$ans" == "y" || "$ans" == "Y" ]] || exit 0 +} + +if [[ -d "$TARGET_PATH" ]]; then + BACKUP_DIR="${TARGET_PATH%/}" + if [[ "$RESTORE_ALL" -ne 1 ]]; then + echo "pg-restore: path is a directory. Use --all to restore every *.dump inside." >&2 + exit 1 + fi + confirm_restore "About to restore ALL dumps in: $BACKUP_DIR on $DB_HOST:$PGPORT" + if [[ "$GLOBALS" -eq 1 ]]; then + apply_globals "$BACKUP_DIR/00_globals.sql" + fi + shopt -s nullglob + dumps=( "$BACKUP_DIR"/*.dump ) + shopt -u nullglob + if [[ ${#dumps[@]} -eq 0 ]]; then + echo "pg-restore: no .dump files in $BACKUP_DIR" >&2 + exit 1 + fi + for dump in "${dumps[@]}"; do + restore_one_dump "$dump" + done + exit 0 +fi + +if [[ ! -f "$TARGET_PATH" ]]; then + echo "pg-restore: path not found: $TARGET_PATH" >&2 + exit 1 +fi + +if [[ "$RESTORE_ALL" -eq 1 ]]; then + echo "pg-restore: --all requires a backup directory path." >&2 + exit 1 +fi + +TARGET_DB="$DB_DATABASE" +if [[ "$MODE" == "test" ]]; then + TARGET_DB="${DB_DATABASE}_restore_test" +fi + +confirm_restore "About to restore into database: $TARGET_DB on $DB_HOST:$PGPORT +Dump: $TARGET_PATH" + +if [[ "$GLOBALS" -eq 1 ]]; then + apply_globals "$(dirname "$TARGET_PATH")/00_globals.sql" +fi + +if [[ "$MODE" == "test" ]]; then + ensure_database "$TARGET_DB" +fi + +restore_one_dump "$TARGET_PATH" + +if [[ "$MODE" == "test" ]]; then + echo "Verify with: psql -h $DB_HOST -U $DB_USERNAME -d $TARGET_DB -c '\\dt'" + echo "Drop test DB: psql ... -c 'DROP DATABASE \"$TARGET_DB\";'" +fi diff --git a/scripts/safe-git-pull.sh b/scripts/safe-git-pull.sh new file mode 100755 index 0000000..00f3af4 --- /dev/null +++ b/scripts/safe-git-pull.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# 배포 서버에서 런타임 data 파일의 **로컬 수정** 때문에 +# "Your local changes to the following files would be overwritten by merge" 가 나와 +# git pull 이 막힐 때 사용합니다. +# 동작: 지정 경로를 백업 → 인덱스/작업트리를 리셃해 pull을 허용 → 백업을 다시 복사합니다. +set -euo pipefail +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT" + +# pull 전에 Git이 덮어쓰기를 거부하는 런타임 전용 경로(필요 시 추가) +PULL_SAFE_PATHS=( + "data/ai-success-stories.json" +) + +STAMP="$(date +%Y%m%d%H%M%S)" +BAK_DIR="${REPO_ROOT}/.tmp-safe-pull-backup-${STAMP}" +mkdir -p "$BAK_DIR" + +backed=() +for relpath in "${PULL_SAFE_PATHS[@]}"; do + if [[ -f "$relpath" ]]; then + dest="${BAK_DIR}/$(echo "$relpath" | tr / _)" + cp -a "$relpath" "$dest" + backed+=("$relpath|$dest") + fi +done + +# 추적 중이면 머지/체크아웃이 막힘 — 먼저 백업했으므로 작업트리·스테이징만 맞춤 +for relpath in "${PULL_SAFE_PATHS[@]}"; do + if git ls-files --error-unmatch -- "$relpath" >/dev/null 2>&1; then + if ! git restore --worktree --staged -- "$relpath" 2>/dev/null; then + git reset -q HEAD -- "$relpath" 2>/dev/null || true + git checkout -- "$relpath" 2>/dev/null || true + fi + fi +done + +git pull "$@" + +for ent in "${backed[@]}"; do + relpath="${ent%%|*}" + src="${ent#*|}" + if [[ -f "$src" ]]; then + cp -a "$src" "$relpath" + fi +done + +rm -rf "$BAK_DIR" +echo "safe-git-pull: 완료. 런타임 파일은 백업에서 복원되었습니다." diff --git a/server.js b/server.js index f6ad17a..1f43d81 100644 --- a/server.js +++ b/server.js @@ -27,7 +27,13 @@ const { } = require("./lib/ops-state"); const { fetchOpenGraphImageUrl } = require("./lib/link-preview"); const mgmtPerf = require("./lib/mgmt-perf"); +const aiUseCaseSubmissions = require("./lib/ai-use-case-submissions"); +const promptLibrary = require("./lib/prompt-library"); const { decodeUploadFilename } = require("./lib/decode-upload-filename"); +const { + isOpsSessionRevoked, + revokeAllOpsSessionsForEmail, +} = require("./lib/ops-session-revoke"); const app = express(); const PORT = process.env.PORT || 8030; @@ -35,7 +41,7 @@ const PORT = process.env.PORT || 8030; const HOST = process.env.HOST || "0.0.0.0"; /** 강의 목록 페이지당 개수. `.env`에 PAGE_SIZE가 있으면 그 값이 우선(미설정 시 9) */ const PAGE_SIZE = Number(process.env.PAGE_SIZE || 9); -const ADMIN_TOKEN = (process.env.ADMIN_TOKEN || "xavis-admin").trim(); +const ADMIN_TOKEN = (process.env.ADMIN_TOKEN || "ncue-admin").trim(); const ENABLE_PPT_THUMBNAIL = process.env.ENABLE_PPT_THUMBNAIL !== "0"; /** 업로드 동영상 카드 썸네일(ffmpeg 1프레임). `ENABLE_VIDEO_THUMBNAIL=0`이면 비활성화 */ const ENABLE_VIDEO_THUMBNAIL = process.env.ENABLE_VIDEO_THUMBNAIL !== "0"; @@ -57,14 +63,19 @@ const MEETING_AUDIO_MAX_MB = Number.isFinite(MEETING_AUDIO_MAX_MB_RAW) && MEETIN : 300; const MEETING_AUDIO_MAX_BYTES = Math.floor(MEETING_AUDIO_MAX_MB * 1024 * 1024); const OPENAI_API_KEY = (process.env.OPENAI_API_KEY || "").trim(); +const GUIDE_BOT_URL = + "https://notebooklm.google.com/notebook/f2797467-5078-48be-a8e7-d1e52c8277dd/preview"; +const COMPANY_WM_URL = + "https://notebooklm.google.com/notebook/500d3330-ff71-4be2-9546-fa2f64fee71d/preview"; /** OpenAI Responses API의 내장 `web_search` 도구 사용. `OPENAI_WEB_SEARCH=0`이면 기존 Chat Completions만 사용 */ const OPENAI_WEB_SEARCH = process.env.OPENAI_WEB_SEARCH !== "0"; const CLAUDE_API_KEY = process.env.CLAUDE_API_KEY || ""; const GENAI_API_KEY = process.env.GENAI_API_KEY || ""; const ROOT_DIR = __dirname; +const CONFIG_DIR = path.join(ROOT_DIR, "config"); const DATA_DIR = path.join(ROOT_DIR, "data"); -const COMPANY_PROMPTS_PATH = path.join(DATA_DIR, "company-prompts.json"); +const COMPANY_PROMPTS_PATH = path.join(CONFIG_DIR, "company-prompts.json"); function loadCompanyPrompts() { try { @@ -150,6 +161,44 @@ function allAiSuccessStoryTags(list) { return Array.from(set).sort(); } +function aiCaseDateSortMs(story) { + if (story && story._dateSort instanceof Date) return story._dateSort.getTime(); + const d = new Date( + (story && (story.publishedAt || story.updatedAt || story.createdAt)) || 0 + ); + return Number.isNaN(d.getTime()) ? 0 : d.getTime(); +} + +/** JSON 메타 + 일반 제출( submission ) 카드 병합 후 검색·태그 */ +function filterUnifiedAiCases(list, q, tag) { + let out = [...list]; + const qLower = (q || "").trim().toLowerCase(); + if (qLower) { + out = out.filter((s) => { + return ( + (s.title || "").toLowerCase().includes(qLower) || + (s.excerpt || "").toLowerCase().includes(qLower) || + (s.department || "").toLowerCase().includes(qLower) || + (s.author || "").toLowerCase().includes(qLower) || + (s.submitterEmail || "").toLowerCase().includes(qLower) || + (s.tags || []).some((t) => String(t).toLowerCase().includes(qLower)) + ); + }); + } + const tagTrim = (tag || "").trim(); + if (tagTrim) { + out = out.filter((s) => (s.tags || []).includes(tagTrim)); + } + out.sort((a, b) => aiCaseDateSortMs(b) - aiCaseDateSortMs(a)); + return out; +} + +function allTagsFromUnifiedAiCases(merged) { + const set = new Set(); + (merged || []).forEach((s) => (s.tags || []).forEach((t) => set.add(t))); + return Array.from(set).sort(); +} + function requireAdminApi(req, res, next) { if (!res.locals.adminMode) { res.status(403).json({ error: "관리자만 사용할 수 있습니다." }); @@ -165,6 +214,13 @@ const RESOURCES_LECTURE_DIR = path.join(ROOT_DIR, "resources", "lecture"); const RESOURCES_AX_APPLY_DIR = path.join(ROOT_DIR, "resources", "ax-apply"); const AX_APPLY_UPLOAD_DIR = path.join(UPLOAD_DIR, "ax-apply"); const MEETING_MINUTES_UPLOAD_DIR = path.join(UPLOAD_DIR, "meeting-minutes"); +/** 음성 job 메타(UUID→파일경로 등). uploads/ 에 두지 않아 정적으로 노출되지 않음(data/ 아래). */ +const MEETING_AUDIO_JOB_META_DIR = path.join(DATA_DIR, "meeting-audio-job-meta"); +const MEETING_AUDIO_JOB_MAX_AGE_MS = (() => { + const n = Number(process.env.MEETING_AUDIO_JOB_TTL_MS ?? 3600000); + if (!Number.isFinite(n)) return 3600000; + return Math.min(24 * 60 * 60 * 1000, Math.max(60 * 1000, n)); +})(); const LECTURE_DB_PATH = path.join(DATA_DIR, "lectures.json"); const AX_ASSIGNMENTS_DB_PATH = path.join(DATA_DIR, "ax-assignments.json"); const THUMBNAIL_JOB_DB_PATH = path.join(DATA_DIR, "thumbnail-jobs.json"); @@ -312,6 +368,8 @@ const opsAuth = require("./ops-auth")(DATA_DIR, { }); } }, + isSessionRevoked: async ({ email, iatMs }) => + isOpsSessionRevoked(pgPool, DATA_DIR, email, iatMs), }); const syncLecturesToPostgres = async (lectures) => { @@ -511,38 +569,260 @@ const uploadAiSuccessPdf = multer({ }, }); +function meetingMinutesUserUploadDestination(req, _, cb) { + const email = req.meetingUserEmail || "unknown"; + const safe = String(email).replace(/[^a-zA-Z0-9@._-]/g, "_").slice(0, 200); + const dir = path.join(MEETING_MINUTES_UPLOAD_DIR, safe); + fsSync.mkdirSync(dir, { recursive: true }); + cb(null, dir); +} + +function meetingMinutesAudioTimestampFilename(req, file, cb) { + const email = req.meetingUserEmail || "user"; + const safe = String(email).replace(/[^a-zA-Z0-9@._-]/g, "_").slice(0, 200); + const now = new Date(); + const p = (n) => String(n).padStart(2, "0"); + const yyyyMMdd = `${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}`; + const HHmmss = `${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`; + const ext = (path.extname(file.originalname) || ".webm").toLowerCase(); + cb(null, `${safe}_${yyyyMMdd}_${HHmmss}${ext}`); +} + +function meetingMinutesAudioJobPrepareFilename(req, file, cb) { + const ext = (path.extname(file.originalname) || ".webm").toLowerCase(); + cb(null, `${req.meetingAudioJobId}${ext}`); +} + +function meetingMinutesAudioMulterFileFilter(_, file, cb) { + const ext = path.extname(file.originalname).toLowerCase(); + const allowed = [".mp3", ".mp4", ".mpeg", ".mpga", ".m4a", ".wav", ".webm", ".ogg", ".flac"]; + if (!allowed.includes(ext)) { + cb(new Error(`지원 음성 형식: ${allowed.join(", ")}`)); + return; + } + cb(null, true); +} + +function assignMeetingAudioJobId(req, res, next) { + req.meetingAudioJobId = uuidv4(); + next(); +} + +function isMeetingAudioJobId(raw) { + return ( + typeof raw === "string" && + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(raw.trim()) + ); +} + +function ensureMeetingAudioJobMetaDirSync() { + fsSync.mkdirSync(MEETING_AUDIO_JOB_META_DIR, { recursive: true }); +} + +function meetingAudioJobMetaPath(jobId) { + return path.join(MEETING_AUDIO_JOB_META_DIR, `${jobId}.json`); +} + +/** candidate가 parentDir 안의 파일(직계·하위)인지 검사합니다. symbolic link는 고려하지 않습니다. */ +function isResolvedPathStrictlyInside(parentDirAbs, candidatePathAbs) { + const parent = path.resolve(parentDirAbs); + const child = path.resolve(candidatePathAbs); + const rel = path.relative(parent, child); + return rel !== "" && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel); +} + +function resolvedMeetingMinutesUploadAudioPath(candidatePath) { + const resolved = path.resolve(candidatePath); + if (!isResolvedPathStrictlyInside(path.resolve(MEETING_MINUTES_UPLOAD_DIR), resolved)) { + throw new Error("음성 업로드 경로가 허용 루트 밖입니다."); + } + return resolved; +} + const meetingMinutesAudioStorage = multer.diskStorage({ - destination: (req, _, cb) => { - const email = req.meetingUserEmail || "unknown"; - const safe = String(email).replace(/[^a-zA-Z0-9@._-]/g, "_").slice(0, 200); - const dir = path.join(MEETING_MINUTES_UPLOAD_DIR, safe); - fsSync.mkdirSync(dir, { recursive: true }); - cb(null, dir); - }, - filename: (req, file, cb) => { - const email = req.meetingUserEmail || "user"; - const safe = String(email).replace(/[^a-zA-Z0-9@._-]/g, "_").slice(0, 200); - const now = new Date(); - const p = (n) => String(n).padStart(2, "0"); - const yyyyMMdd = `${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}`; - const HHmmss = `${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`; - const ext = (path.extname(file.originalname) || ".webm").toLowerCase(); - cb(null, `${safe}_${yyyyMMdd}_${HHmmss}${ext}`); - }, + destination: meetingMinutesUserUploadDestination, + filename: meetingMinutesAudioTimestampFilename, }); const uploadMeetingAudio = multer({ storage: meetingMinutesAudioStorage, limits: { fileSize: MEETING_AUDIO_MAX_BYTES }, - fileFilter: (_, file, cb) => { - const ext = path.extname(file.originalname).toLowerCase(); - const allowed = [".mp3", ".mp4", ".mpeg", ".mpga", ".m4a", ".wav", ".webm", ".ogg", ".flac"]; - if (!allowed.includes(ext)) { - cb(new Error(`지원 음성 형식: ${allowed.join(", ")}`)); + fileFilter: meetingMinutesAudioMulterFileFilter, +}); +const meetingMinutesAudioPrepStorage = multer.diskStorage({ + destination: meetingMinutesUserUploadDestination, + filename: meetingMinutesAudioJobPrepareFilename, +}); +const uploadMeetingAudioPrepare = multer({ + storage: meetingMinutesAudioPrepStorage, + limits: { fileSize: MEETING_AUDIO_MAX_BYTES }, + fileFilter: meetingMinutesAudioMulterFileFilter, +}); +function writeMeetingMinutesAudioSsePreamble(res) { + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + // Nginx 전용 버퍼 비활성화. Apache(mod_proxy_http)는 이 헤더를 해석하지 않음. 장시간 SSE는 ProxyTimeout 등으로 조정. + res.setHeader("X-Accel-Buffering", "no"); + res.setHeader("Connection", "keep-alive"); + res.flushHeaders(); + for (let i = 0; i < 48; i += 1) { + res.write(`: mm-sse-padding-${i}-${"#".repeat(40)}\n`); + } + res.write("\n"); + if (typeof res.flush === "function") res.flush(); +} + +/** @typedef {{ email:string, fileAbsPath?:string|null, originalName:string, title:string, model:string, whisperModel:string, meetingDateRaw: unknown, unlinkOnFailure?: boolean }} MeetingAudioSseCtx */ + +/** @param {import("express").Response} res @param {MeetingAudioSseCtx} ctx */ +async function runMeetingMinutesGenerateAudioSsePipeline(res, ctx) { + let completedOk = false; + let heartbeatIv = /** @type {ReturnType|null} */ (null); + function sendEvent(event, data) { + if (res.writableEnded) return; + res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + if (typeof res.flush === "function") res.flush(); + } + const stopHeartbeat = () => { + if (heartbeatIv) { + clearInterval(heartbeatIv); + heartbeatIv = null; + } + }; + const safeUnlinkIfStaged = () => { + if (!ctx.unlinkOnFailure || !ctx.fileAbsPath) return; + try { + if (fsSync.existsSync(ctx.fileAbsPath)) fsSync.unlinkSync(ctx.fileAbsPath); + } catch (_) { + /* ignore */ + } + }; + + try { + if (!OPENAI_API_KEY) { + sendEvent("error", { message: "OPENAI_API_KEY가 설정되지 않았습니다." }); + res.end(); return; } - cb(null, true); - }, -}); + + heartbeatIv = setInterval(() => { + sendEvent("heartbeat", { t: Date.now() }); + }, 9000); + + sendEvent("accepted", { + stage: "accepted", + message: "서버에 파일 접수 후 설정을 불러오는 중입니다.", + }); + + await ensureMeetingUserAndDefaultPrompt(ctx.email); + const title = (ctx.title || "").toString().trim().slice(0, 500); + const model = (ctx.model || "gpt-5-mini").toString().trim(); + const whisperModel = (ctx.whisperModel || meetingMinutesLib.DEFAULT_TRANSCRIPTION_MODEL).toString().trim(); + const mdParsed = parseMeetingDateIso(ctx.meetingDateRaw); + if (mdParsed.error) { + sendEvent("error", { message: mdParsed.error }); + res.end(); + return; + } + const filePath = ctx.fileAbsPath; + if (!filePath || !fsSync.existsSync(filePath)) { + sendEvent("error", { message: "음성 파일을 찾을 수 없습니다." }); + res.end(); + return; + } + if (!MEETING_MINUTES_ALLOWED_MODELS.has(model)) { + sendEvent("error", { message: "지원 모델: gpt-5-mini, gpt-5.4" }); + res.end(); + return; + } + if (!meetingMinutesLib.TRANSCRIPTION_UI_MODELS.has(whisperModel)) { + sendEvent("error", { + message: "지원 전사 모델: gpt-4o-mini-transcribe, gpt-4o-transcribe", + }); + res.end(); + return; + } + const relPath = `/uploads/${path.relative(UPLOAD_DIR, filePath).split(path.sep).join("/")}`; + const pr = await meetingAiStore.getPromptRow(pgPool, ctx.email); + const promptRow = mapRowToMeetingPrompt(pr); + const systemPrompt = meetingMinutesLib.buildMeetingMinutesSystemPrompt(promptRow); + const openai = new OpenAI({ apiKey: OPENAI_API_KEY }); + + sendEvent("progress", { + stage: "prep", + done: 0, + total: 1, + message: + "전사 준비 중(음성 구간 처리·API 접속 단계입니다. 완료까지 수 분 걸릴 수 있습니다)", + }); + + let transcript = ""; + try { + transcript = await meetingMinutesLib.transcribeMeetingAudio( + openai, + filePath, + whisperModel, + (progress) => sendEvent("progress", progress) + ); + } catch (te) { + const tmsg = te?.response?.data?.error?.message || te?.message || "전사 실패"; + sendEvent("error", { message: `Whisper 전사 실패: ${tmsg}` }); + res.end(); + return; + } + if (!transcript.trim()) { + sendEvent("error", { message: "전사 결과가 비어 있습니다." }); + res.end(); + return; + } + + sendEvent("generating", { message: "회의록을 작성하고 있습니다…" }); + + const generated = await meetingMinutesLib.generateMeetingMinutes(openai, { + systemPrompt, + userContent: transcript, + uiModel: model, + resolveApiModel: resolveOpenAiApiModel, + omitMeetingChecklistSection: promptRow?.includeChecklist !== true, + }); + const ins = await meetingAiStore.insertMeetingAudio(pgPool, { + email: ctx.email, + title, + transcript, + generated, + relPath, + originalName: ctx.originalName, + model, + whisperModel, + meetingDate: mdParsed.value, + }); + let checklistSync = { imported: 0 }; + try { + checklistSync = await syncAutoChecklistFromMeetingMinutes(openai, { + pgPool, + email: ctx.email, + meetingId: ins.id, + generatedMinutes: generated, + uiModel: model, + }); + } catch (e) { + checklistSync = { imported: 0, extractError: e?.message }; + } + const meetingOut = mapRowToMeeting(ins); + if (checklistSync.snapshot) meetingOut.checklistSnapshot = checklistSync.snapshot; + completedOk = true; + sendEvent("done", { meeting: meetingOut, checklistSync }); + res.end(); + } catch (err) { + const msg = err?.response?.data?.error?.message || err?.message || "생성 실패"; + if (!res.writableEnded) { + sendEvent("error", { message: msg }); + res.end(); + } + } finally { + stopHeartbeat(); + if (!completedOk && ctx.unlinkOnFailure) safeUnlinkIfStaged(); + } +} const MGMT_PERF_UPLOAD_DIR = path.join(ROOT_DIR, "uploads", "mgmt-perf"); const mgmtPerfStorage = multer.diskStorage({ @@ -570,6 +850,72 @@ const uploadMgmtPerfExcel = multer({ }, }); +const uploadUseCaseStorage = multer.diskStorage({ + destination: (req, file, cb) => { + const id = req._useCaseSubmissionId; + if (!id) { + cb(new Error("internal: submission id missing")); + return; + } + const sub = file.fieldname === "thumbnail" ? "thumb" : "files"; + const d = path.join(UPLOAD_DIR, "ai-use-case-submissions", id, sub); + fsSync.mkdirSync(d, { recursive: true }); + cb(null, d); + }, + filename: (req, file, cb) => { + const orig = decodeUploadFilename(file.originalname); + const safe = `${Date.now()}-${String(orig).replace(/[^a-zA-Z0-9._\-\uAC00-\uD7A3]/g, "_")}`; + cb(null, safe.slice(0, 200)); + }, +}); +const uploadUseCase = multer({ + storage: uploadUseCaseStorage, + limits: { fileSize: Math.max(aiUseCaseSubmissions.MAX_ATTACH_BYTES, aiUseCaseSubmissions.MAX_THUMB_BYTES) }, + fileFilter: (req, file, cb) => { + if (file.fieldname === "thumbnail") { + if (!/^image\//.test(file.mimetype)) { + cb(new Error("썸네일은 이미지 파일만 업로드할 수 있습니다.")); + return; + } + } + cb(null, true); + }, +}); +const uploadUseCaseFields = uploadUseCase.fields([ + { name: "thumbnail", maxCount: aiUseCaseSubmissions.MAX_THUMB_COUNT }, + { name: "attachments", maxCount: aiUseCaseSubmissions.MAX_ATTACH_COUNT }, +]); + +const uploadPromptCommunityStorage = multer.diskStorage({ + destination: (req, file, cb) => { + const id = req._promptBundleId; + if (!id) { + cb(new Error("internal: prompt bundle id missing")); + return; + } + const sub = file.fieldname === "resultFiles" ? "result" : "prompt"; + const d = path.join(UPLOAD_DIR, "prompt-community", id, sub); + fsSync.mkdirSync(d, { recursive: true }); + cb(null, d); + }, + filename: (req, file, cb) => { + const orig = decodeUploadFilename(file.originalname); + const safe = `${Date.now()}-${String(orig).replace(/[^a-zA-Z0-9._\uAC00-\uD7A3]/g, "_")}`; + cb(null, safe.slice(0, 200)); + }, +}); +const uploadPromptCommunity = multer({ + storage: uploadPromptCommunityStorage, + limits: { fileSize: promptLibrary.MAX_ATTACH_BYTES }, + fileFilter: (req, file, cb) => { + cb(null, true); + }, +}); +const uploadPromptCommunityFields = uploadPromptCommunity.fields([ + { name: "promptFiles", maxCount: promptLibrary.MAX_ATTACH_PER_GROUP }, + { name: "resultFiles", maxCount: promptLibrary.MAX_ATTACH_PER_GROUP }, +]); + const mapRowToAxAssignment = (row) => ({ id: row.id, department: row.department || "", @@ -960,7 +1306,19 @@ function isChatGptAllowed(req, res) { return false; } -/** AI 성공 사례 상세: DEV일 때만 관리자 모드로 제한. PROD·SUPER는 상세 열람 가능 */ +/** AI 활용 사례 글쓰기: OPS 이메일 로그인, 로컬(DEV)에서는 관리자+MEETING_DEV_EMAIL */ +function getAiUseCaseSubmitterEmail(req, res) { + if (res.locals.opsUserEmail) { + return String(res.locals.opsUserEmail).trim().toLowerCase(); + } + if (isOpsStateDev() && res.locals.adminMode) { + const dev = (process.env.MEETING_DEV_EMAIL || "").trim().toLowerCase(); + if (dev) return dev; + } + return null; +} + +/** AI 활용 사례 상세: DEV일 때만 관리자 모드로 제한. PROD·SUPER는 상세 열람 가능 */ function isAiSuccessStoryDetailAllowed(req, res) { if (!isOpsStateDev()) return true; return !!res.locals.adminMode; @@ -977,6 +1335,16 @@ function isAiExploreDevGuestRestricted(req, res) { return isOpsStateDev() && !res.locals.adminMode; } +/** 프롬프트 라이브러리 API: OPS(또는 DEV 관리자+MEETING_DEV_EMAIL) */ +function requirePromptLibEmail(req, res, next) { + const email = getAiUseCaseSubmitterEmail(req, res); + if (!email) { + return res.status(401).json({ error: "이메일(OPS) 로그인이 필요합니다." }); + } + req.promptLibEmail = email; + next(); +} + /** `.env` DASHBOARD_MENU_ALLOWED_EMAILS (쉼표 구분, 소문자 정규화) */ function parseDashboardMenuAllowlist() { return String(process.env.DASHBOARD_MENU_ALLOWED_EMAILS || "") @@ -1010,6 +1378,63 @@ function computeDashboardMenuAllowed(req, res) { return DASHBOARD_MENU_ALLOWLIST.includes(email); } +/** 가이드봇·WM 메뉴: 관리자 또는 허용 OPS 이메일(기본 dsyoon@ncue.net) */ +function parseGuideBotMenuAllowlist() { + return String(process.env.GUIDE_BOT_MENU_ALLOWED_EMAILS || "dsyoon@ncue.net") + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); +} +const GUIDE_BOT_MENU_ALLOWLIST = parseGuideBotMenuAllowlist(); + +function computeGuideBotMenuAllowed(req, res) { + if (res.locals.adminMode) return true; + const email = getEmailForDashboardAccess(req, res); + if (!email) return false; + return GUIDE_BOT_MENU_ALLOWLIST.includes(email); +} + +function requireGuideBotMenuAccess(req, res, next) { + if (computeGuideBotMenuAllowed(req, res)) return next(); + if (String(req.path || "").startsWith("/api/")) { + return res.status(403).json({ error: "가이드봇·WM 접근 권한이 없습니다." }); + } + return res + .status(403) + .type("html") + .send( + `권한 없음

가이드봇·WM은 허용된 계정 또는 관리자만 이용할 수 있습니다.

AI 탐색으로

` + ); +} + +/** 업무 체크리스트 AI: 허용 OPS 이메일만(기본 dsyoon@ncue.net, 관리자 예외 없음) */ +function parseTaskChecklistAllowlist() { + return String(process.env.TASK_CHECKLIST_ALLOWED_EMAILS || "dsyoon@ncue.net") + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); +} +const TASK_CHECKLIST_ALLOWLIST = parseTaskChecklistAllowlist(); + +function computeTaskChecklistMenuAllowed(req, res) { + const email = getEmailForDashboardAccess(req, res); + if (!email) return false; + return TASK_CHECKLIST_ALLOWLIST.includes(email); +} + +function requireTaskChecklistAccess(req, res, next) { + if (computeTaskChecklistMenuAllowed(req, res)) return next(); + if (String(req.path || "").startsWith("/api/")) { + return res.status(403).json({ error: "업무 체크리스트 AI 접근 권한이 없습니다." }); + } + return res + .status(403) + .type("html") + .send( + `권한 없음

업무 체크리스트 AI는 허용된 계정만 이용할 수 있습니다.

AI 탐색으로

` + ); +} + function requireDashboardAccess(req, res, next) { if (computeDashboardMenuAllowed(req, res)) return next(); if (String(req.path || "").startsWith("/api/")) { @@ -1027,7 +1452,7 @@ function requireDashboardAccess(req, res, next) { function getMeetingMinutesUserEmail(req, res) { if (res.locals.opsUserEmail) return String(res.locals.opsUserEmail).trim().toLowerCase(); if (isOpsStateDev() && res.locals.adminMode) { - return (process.env.MEETING_DEV_EMAIL || "dev@xavis.co.kr").trim().toLowerCase(); + return (process.env.MEETING_DEV_EMAIL || "dev@ncue.net").trim().toLowerCase(); } if (isOpsStateSuper()) { return (process.env.MEETING_SUPER_EMAIL || process.env.MEETING_DEV_EMAIL || "demo@xavis.local") @@ -1091,7 +1516,7 @@ function mapRowToMeeting(row) { generatedMinutes: meetingMinutesLib.prepareMeetingMinutesForApi(row.generated_minutes || ""), audioFilePath: row.audio_file_path || "", audioOriginalName: row.audio_original_name || "", - chatModel: row.chat_model || "gpt-5-mini", + chatModel: row.chat_model || "gpt-5.4", transcriptionModel: row.transcription_model || null, meetingDate: row.meeting_date != null ? formatMeetingDateOnly(row.meeting_date) : null, createdAt: row.created_at ? new Date(row.created_at).toISOString() : null, @@ -1213,6 +1638,8 @@ app.use("/resources/ax-apply", express.static(RESOURCES_AX_APPLY_DIR)); app.use(opsAuth.middleware); app.use((req, res, next) => { res.locals.dashboardMenuAllowed = computeDashboardMenuAllowed(req, res); + res.locals.guideBotMenuAllowed = computeGuideBotMenuAllowed(req, res); + res.locals.taskChecklistMenuAllowed = computeTaskChecklistMenuAllowed(req, res); next(); }); opsAuth.registerRoutes(app); @@ -1280,10 +1707,320 @@ app.delete("/api/mgmt-perf/upload/:id", requireDashboardAccess, async (req, res) } }); +function filePathToPublicUploadsUrl(absolutePath) { + const rel = path.relative(UPLOAD_DIR, absolutePath).split(path.sep).join("/"); + if (rel.startsWith("..") || !rel) throw new Error("잘못된 업로드 경로"); + return `/uploads/${rel}`; +} + +app.post( + "/api/ai-use-case-submissions", + (req, res, next) => { + if (!getAiUseCaseSubmitterEmail(req, res)) { + return res.status(403).json({ error: "이메일 로그인(OPS) 후 이용해 주세요." }); + } + if (!pgPool) { + return res.status(503).json({ error: "PostgreSQL 연결이 필요합니다." }); + } + req._useCaseSubmissionId = uuidv4(); + next(); + }, + (req, res, next) => { + uploadUseCaseFields(req, res, (err) => { + if (err) { + return res.status(400).json({ error: err.message || "파일 업로드에 실패했습니다." }); + } + next(); + }); + }, + async (req, res) => { + try { + const { sanitizeUseCaseBody } = require("./lib/sanitize-use-case-body"); + const email = getAiUseCaseSubmitterEmail(req, res); + const subId = req._useCaseSubmissionId; + const title = String(req.body.title || "").trim(); + const situation = sanitizeUseCaseBody(String(req.body.situation || "")); + const taskGoal = sanitizeUseCaseBody(String(req.body.taskGoal || "")); + const actionTaken = sanitizeUseCaseBody(String(req.body.actionTaken || "")); + const resultOutcome = sanitizeUseCaseBody(String(req.body.resultOutcome || "")); + const tags = String(req.body.tags || "") + .split(/[,#]/) + .map((t) => t.trim()) + .filter(Boolean) + .slice(0, 40); + if (!title) { + return res.status(400).json({ error: "제목을 입력해 주세요." }); + } + const thumbs = (req.files && req.files["thumbnail"]) || []; + if (!thumbs.length) { + return res.status(400).json({ error: "썸네일 이미지를 1개 이상 업로드해 주세요. (최대 5개, 개당 5MB)" }); + } + if (thumbs.length > aiUseCaseSubmissions.MAX_THUMB_COUNT) { + return res.status(400).json({ + error: `썸네일은 최대 ${aiUseCaseSubmissions.MAX_THUMB_COUNT}개까지 업로드할 수 있습니다.`, + }); + } + const thumbMeta = []; + for (let ti = 0; ti < thumbs.length; ti += 1) { + const tpath = thumbs[ti].path; + const tsize = fsSync.statSync(tpath).size; + if (tsize > aiUseCaseSubmissions.MAX_THUMB_BYTES) { + return res.status(400).json({ error: `썸네일 ${ti + 1}번째 파일은 5MB 이하여야 합니다.` }); + } + thumbMeta.push({ + originalName: decodeUploadFilename(thumbs[ti].originalname), + relativePath: filePathToPublicUploadsUrl(tpath), + size: tsize, + }); + } + const atts = (req.files && req.files["attachments"]) || []; + if (atts.length > aiUseCaseSubmissions.MAX_ATTACH_COUNT) { + return res.status(400).json({ + error: `첨부 파일은 최대 ${aiUseCaseSubmissions.MAX_ATTACH_COUNT}개까지 업로드할 수 있습니다.`, + }); + } + for (let i = 0; i < atts.length; i += 1) { + const sz = fsSync.statSync(atts[i].path).size; + if (sz > aiUseCaseSubmissions.MAX_ATTACH_BYTES) { + return res.status(400).json({ error: `첨부 파일은 개당 20MB 이하여야 합니다. (${i + 1}번째)` }); + } + } + const attachMeta = atts.map((f) => ({ + originalName: decodeUploadFilename(f.originalname), + relativePath: filePathToPublicUploadsUrl(f.path), + size: fsSync.statSync(f.path).size, + })); + await aiUseCaseSubmissions.insertSubmission(pgPool, { + id: subId, + submitterEmail: email, + title, + situation, + taskGoal, + actionTaken, + resultOutcome, + tags, + thumbnailRelativePath: aiUseCaseSubmissions.primaryThumbnailPath(thumbMeta), + thumbnailFiles: thumbMeta, + attachmentFiles: attachMeta, + }); + return res.json({ ok: true, id: subId, message: "제출이 저장되었습니다." }); + } catch (err) { + if (err && err.code === "MODULE_NOT_FOUND" && String(err.message || "").includes("sanitize-html")) { + return res.status(503).json({ + error: "서버에 npm 패키지(sanitize-html)가 없습니다. 프로젝트 루트에서 npm install 후 PM2를 재시작하세요.", + }); + } + console.error("[ai-use-case-submissions]", err); + return res.status(500).json({ error: err.message || "저장에 실패했습니다." }); + } + } +); + +const UUID_RE_SUBMISSION = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +app.put( + "/api/ai-use-case-submissions/:id", + (req, res, next) => { + if (!getAiUseCaseSubmitterEmail(req, res)) { + return res.status(403).json({ error: "이메일 로그인(OPS) 후 이용해 주세요." }); + } + if (!pgPool) { + return res.status(503).json({ error: "PostgreSQL 연결이 필요합니다." }); + } + const id = (req.params.id || "").trim(); + if (!UUID_RE_SUBMISSION.test(id)) { + return res.status(400).json({ error: "잘못된 id입니다." }); + } + req._useCaseSubmissionId = id; + next(); + }, + (req, res, next) => { + uploadUseCaseFields(req, res, (err) => { + if (err) { + return res.status(400).json({ error: err.message || "파일 업로드에 실패했습니다." }); + } + next(); + }); + }, + async (req, res) => { + try { + const { sanitizeUseCaseBody } = require("./lib/sanitize-use-case-body"); + const id = (req.params.id || "").trim(); + const requestEmail = getAiUseCaseSubmitterEmail(req, res); + const row = await aiUseCaseSubmissions.getSubmissionById(pgPool, id); + if (!row) { + return res.status(404).json({ error: "사례를 찾을 수 없습니다." }); + } + if ( + !aiUseCaseSubmissions.canUserEditSubmission( + row.submitter_email, + requestEmail, + !!res.locals.adminMode + ) + ) { + return res.status(403).json({ error: "이 사례를 수정할 권한이 없습니다." }); + } + const title = String(req.body.title || "").trim(); + const situation = sanitizeUseCaseBody(String(req.body.situation || "")); + const taskGoal = sanitizeUseCaseBody(String(req.body.taskGoal || "")); + const actionTaken = sanitizeUseCaseBody(String(req.body.actionTaken || "")); + const resultOutcome = sanitizeUseCaseBody(String(req.body.resultOutcome || "")); + const tags = String(req.body.tags || "") + .split(/[,#]/) + .map((t) => t.trim()) + .filter(Boolean) + .slice(0, 40); + if (!title) { + return res.status(400).json({ error: "제목을 입력해 주세요." }); + } + const thumbs = (req.files && req.files["thumbnail"]) || []; + const oldThumbs = aiUseCaseSubmissions.parseThumbnailFiles(row); + let removeThumbPaths = []; + try { + const rawThumbR = req.body.removeThumbnailPaths; + if (rawThumbR) { + const parsed = typeof rawThumbR === "string" ? JSON.parse(rawThumbR) : rawThumbR; + if (Array.isArray(parsed)) removeThumbPaths = parsed; + } + } catch { + removeThumbPaths = []; + } + const removeThumbRaw = String(req.body.removeThumbnail || "").toLowerCase(); + const wantRemoveAllThumbs = + removeThumbRaw === "1" || removeThumbRaw === "true" || removeThumbRaw === "on"; + if (wantRemoveAllThumbs && !removeThumbPaths.length) { + removeThumbPaths = oldThumbs.map((t) => t.relativePath); + } + const removeThumbSet = new Set( + removeThumbPaths.map((x) => String(x || "").trim()).filter(Boolean) + ); + const oldThumbsFiltered = oldThumbs.filter( + (t) => t && t.relativePath && !removeThumbSet.has(t.relativePath) + ); + const newThumbMeta = []; + for (let ti = 0; ti < thumbs.length; ti += 1) { + const tpath = thumbs[ti].path; + const tsize = fsSync.statSync(tpath).size; + if (tsize > aiUseCaseSubmissions.MAX_THUMB_BYTES) { + return res.status(400).json({ error: `썸네일 ${ti + 1}번째 파일은 5MB 이하여야 합니다.` }); + } + newThumbMeta.push({ + originalName: decodeUploadFilename(thumbs[ti].originalname), + relativePath: filePathToPublicUploadsUrl(tpath), + size: tsize, + }); + } + const mergedThumbs = oldThumbsFiltered.concat(newThumbMeta).slice(0, aiUseCaseSubmissions.MAX_THUMB_COUNT); + if (oldThumbsFiltered.length + newThumbMeta.length > aiUseCaseSubmissions.MAX_THUMB_COUNT) { + return res.status(400).json({ + error: `썸네일은 최대 ${aiUseCaseSubmissions.MAX_THUMB_COUNT}개까지 등록할 수 있습니다.`, + }); + } + if (!mergedThumbs.length) { + return res.status(400).json({ error: "썸네일은 1개 이상 필요합니다." }); + } + const atts = (req.files && req.files["attachments"]) || []; + if (atts.length > aiUseCaseSubmissions.MAX_ATTACH_COUNT) { + return res.status(400).json({ + error: `첨부 파일은 최대 ${aiUseCaseSubmissions.MAX_ATTACH_COUNT}개까지 업로드할 수 있습니다.`, + }); + } + for (let i = 0; i < atts.length; i += 1) { + const sz = fsSync.statSync(atts[i].path).size; + if (sz > aiUseCaseSubmissions.MAX_ATTACH_BYTES) { + return res.status(400).json({ error: `첨부 파일은 개당 20MB 이하여야 합니다. (${i + 1}번째)` }); + } + } + const newAttachMeta = atts.map((f) => ({ + originalName: decodeUploadFilename(f.originalname), + relativePath: filePathToPublicUploadsUrl(f.path), + size: fsSync.statSync(f.path).size, + })); + let oldFiles = []; + try { + const raw = row.attachment_files; + oldFiles = typeof raw === "string" ? JSON.parse(raw) : raw || []; + if (!Array.isArray(oldFiles)) oldFiles = []; + } catch { + oldFiles = []; + } + let removePaths = []; + try { + const rawR = req.body.removeAttachmentPaths; + if (rawR) { + const parsed = typeof rawR === "string" ? JSON.parse(rawR) : rawR; + if (Array.isArray(parsed)) removePaths = parsed; + } + } catch { + removePaths = []; + } + const removeSet = new Set( + removePaths.map((x) => String(x || "").trim()).filter(Boolean) + ); + const oldFiltered = oldFiles.filter((f) => f && f.relativePath && !removeSet.has(f.relativePath)); + const mergedFilesCandidate = oldFiltered.concat(newAttachMeta); + if (mergedFilesCandidate.length > aiUseCaseSubmissions.MAX_ATTACH_COUNT) { + return res.status(400).json({ + error: `첨부 파일은 최대 ${aiUseCaseSubmissions.MAX_ATTACH_COUNT}개까지 등록할 수 있습니다. 기존 첨부를 제거한 뒤 추가해 주세요.`, + }); + } + const mergedFiles = mergedFilesCandidate; + await aiUseCaseSubmissions.updateSubmission(pgPool, { + id, + title, + situation, + taskGoal, + actionTaken, + resultOutcome, + tags, + thumbnailFilesJson: JSON.stringify(mergedThumbs), + attachmentFilesJson: JSON.stringify(mergedFiles), + }); + return res.json({ ok: true, id, message: "수정이 저장되었습니다." }); + } catch (err) { + if (err && err.code === "MODULE_NOT_FOUND" && String(err.message || "").includes("sanitize-html")) { + return res.status(503).json({ + error: "서버에 npm 패키지(sanitize-html)가 없습니다. 프로젝트 루트에서 npm install 후 PM2를 재시작하세요.", + }); + } + console.error("[ai-use-case-submissions PUT]", err); + return res.status(500).json({ error: err.message || "저장에 실패했습니다." }); + } + } +); + +app.post( + "/api/ai-use-case-submissions/:id/like/toggle", + requirePromptLibEmail, + express.json(), + async (req, res) => { + if (!pgPool) { + return res.status(503).json({ error: "DB를 사용할 수 없습니다." }); + } + try { + const id = String(req.params.id || "").trim(); + const out = await aiUseCaseSubmissions.toggleSubmissionLike(pgPool, id, req.promptLibEmail); + return res.json({ ok: true, ...out }); + } catch (err) { + if (err && err.code === "VALIDATION") { + return res.status(400).json({ error: err.message || "잘못된 요청입니다." }); + } + if (err && err.code === "NOT_FOUND") { + return res.status(404).json({ error: err.message || "사례를 찾을 수 없습니다." }); + } + console.error("[ai-use-case-submissions/like/toggle]", err); + return res.status(500).json({ error: err?.message || "처리에 실패했습니다." }); + } + } +); + const pageRouter = express.Router(); -pageRouter.get("/chat", (req, res) => +pageRouter.get("/guide-bot", requireGuideBotMenuAccess, (req, res) => res.redirect(GUIDE_BOT_URL)); +pageRouter.get("/chat", requireGuideBotMenuAccess, (req, res) => res.redirect(GUIDE_BOT_URL)); +pageRouter.get("/wm", requireGuideBotMenuAccess, (req, res) => res.redirect(COMPANY_WM_URL)); +pageRouter.get("/ai-explore/chat", (req, res) => res.render("chat", { - activeMenu: "chat", + activeMenu: "ai-explore", chatGptAllowed: isChatGptAllowed(req, res), opsState: normalizeOpsState(), adminMode: res.locals.adminMode, @@ -1298,9 +2035,24 @@ pageRouter.get("/ai-explore", (req, res) => aiExploreDevGuestRestricted: isAiExploreDevGuestRestricted(req, res), }) ); -pageRouter.get("/ai-explore/prompts", (req, res) => - res.render("ai-prompts", { activeMenu: "ai-explore", prompts: loadCompanyPrompts() }) -); +pageRouter.get("/ai-explore/prompts", async (req, res, next) => { + try { + const officialPrompts = loadCompanyPrompts(); + const uem = getAiUseCaseSubmitterEmail(req, res); + const libData = await promptLibrary.getLibraryData(pgPool, officialPrompts, uem); + res.render("ai-prompts", { + activeMenu: "prompts", + adminMode: res.locals.adminMode, + opsUserEmail: !!res.locals.opsUserEmail, + userEmail: uem || "", + officialPrompts, + libData, + openaiPolishAvailable: OPENAI_API_KEY.length > 0, + }); + } catch (e) { + next(e); + } +}); pageRouter.get("/ai-explore/meeting-minutes", (req, res) => res.render("meeting-minutes", { activeMenu: "ai-explore", @@ -1309,7 +2061,7 @@ pageRouter.get("/ai-explore/meeting-minutes", (req, res) => opsState: normalizeOpsState(), }) ); -pageRouter.get("/ai-explore/task-checklist", (req, res) => +pageRouter.get("/ai-explore/task-checklist", requireTaskChecklistAccess, (req, res) => res.render("task-checklist", { activeMenu: "ai-explore", adminMode: res.locals.adminMode, @@ -1317,6 +2069,13 @@ pageRouter.get("/ai-explore/task-checklist", (req, res) => opsState: normalizeOpsState(), }) ); +pageRouter.get("/ai-explore/fscan", (req, res) => + res.render("ai-fscan", { + activeMenu: "ai-explore", + adminMode: res.locals.adminMode, + opsUserEmail: !!res.locals.opsUserEmail, + }) +); pageRouter.get("/dashboard", requireDashboardAccess, (req, res) => res.render("dashboard", { activeMenu: "dashboard", @@ -1419,7 +2178,7 @@ const AI_SUCCESS_ADMIN_LIST_PAGE_SIZE = 5; pageRouter.get("/ai-cases/write", (req, res) => { if (!res.locals.adminMode) { return res.status(403).send( - "권한 없음

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

AI 성공 사례 목록으로

" + "권한 없음

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

AI 활용 사례 목록으로

" ); } const editSlug = (req.query.edit || "").trim(); @@ -1458,36 +2217,150 @@ pageRouter.get("/ai-cases/write", (req, res) => { }); }); +pageRouter.get("/ai-cases/compose", async (req, res, next) => { + if (!getAiUseCaseSubmitterEmail(req, res)) { + return res + .status(403) + .type("html") + .send( + "권한 없음

글쓰기

이메일 로그인(OPS) 후 이용해 주세요.

AI 활용 사례 목록으로

" + ); + } + const ph = { ...aiUseCaseSubmissions.PLACEHOLDERS }; + const flatPh = { + situation: (ph.situation || "").replace(/\s+/g, " ").trim(), + task: (ph.task || "").replace(/\s+/g, " ").trim(), + action: (ph.action || "").replace(/\s+/g, " ").trim(), + result: (ph.result || "").replace(/\s+/g, " ").trim(), + }; + const editParam = (req.query.edit || "").toString().trim(); + const uuidRe = UUID_RE_SUBMISSION; + let editPayload = null; + if (editParam && pgPool) { + if (!uuidRe.test(editParam)) { + return res.status(400).type("html").send("

잘못된 id

목록

"); + } + try { + const srow = await aiUseCaseSubmissions.getSubmissionById(pgPool, editParam); + if (!srow) { + return res.status(404).type("html").send("

사례를 찾을 수 없습니다

목록

"); + } + const can = aiUseCaseSubmissions.canUserEditSubmission( + srow.submitter_email, + getAiUseCaseSubmitterEmail(req, res), + !!res.locals.adminMode + ); + if (!can) { + return res + .status(403) + .type("html") + .send( + "

수정 권한이 없습니다

본인이 제출한 사례이거나 관리자만 편집할 수 있습니다.

목록

" + ); + } + let attExisting = []; + try { + const rawA = srow.attachment_files; + const p = typeof rawA === "string" ? JSON.parse(rawA) : rawA; + if (Array.isArray(p)) attExisting = p; + } catch { + attExisting = []; + } + editPayload = { + id: String(srow.id), + title: (srow.title || "").trim(), + html: aiUseCaseSubmissions.buildComposeEditorHtml( + srow.situation, + srow.task_goal, + srow.action_taken, + srow.result_outcome + ), + tags: (srow.tags || []).join(", "), + existingThumbnails: aiUseCaseSubmissions.parseThumbnailFiles(srow).map((t) => ({ + originalName: String(t.originalName || "").trim() || "thumbnail", + relativePath: String(t.relativePath).trim(), + })), + existingAttachments: attExisting + .filter((a) => a && String(a.relativePath || "").trim()) + .map((a) => ({ + originalName: String(a.originalName || "").trim() || "파일", + relativePath: String(a.relativePath).trim(), + })), + }; + } catch (e) { + return next(e); + } + } else if (editParam && !pgPool) { + return res.status(503).type("html").send("

DB를 사용할 수 없습니다.

목록

"); + } + res.render("ai-cases-compose", { + activeMenu: "ai-cases", + adminMode: res.locals.adminMode, + submitterEmail: getAiUseCaseSubmitterEmail(req, res), + placeholders: flatPh, + maxBodyTotal: aiUseCaseSubmissions.MAX_BODY_TOTAL, + maxThumbCount: aiUseCaseSubmissions.MAX_THUMB_COUNT, + maxAttachCount: aiUseCaseSubmissions.MAX_ATTACH_COUNT, + editPayload: editPayload || null, + }); +}); + pageRouter.get("/ai-cases", async (req, res, next) => { try { const q = (req.query.q || "").trim(); const tag = (req.query.tag || "").trim(); + const submittedOk = (req.query.submitted || "").toString() === "1"; const meta = loadAiSuccessStoriesMeta(); - const filtered = filterAiSuccessStories(meta, q, tag); - const tags = allAiSuccessStoryTags(meta); - /** 목록에서도 카드 썸네일을 쓰기 위해 PDF→슬라이드가 없으면 생성 시도(상세와 동일 소스) */ + const jsonList = meta.map((m) => { + const d = new Date(m.publishedAt || m.updatedAt || m.createdAt || 0); + return { + ...m, + _source: "json", + _dateSort: Number.isNaN(d.getTime()) ? new Date(0) : d, + }; + }); + let subCards = []; + if (pgPool) { + try { + subCards = await aiUseCaseSubmissions.listForPublicList(pgPool, 500); + } catch (e) { + console.warn("[ai-cases] 제출 사례 목록 로드 실패:", e?.message || e); + } + } + const merged = [...subCards, ...jsonList]; + const tags = allTagsFromUnifiedAiCases(merged); + const filtered = filterUnifiedAiCases(merged, q, tag); + /** JSON 사례만: 카드 썸네일용 PDF→슬라이드(상세와 동일) */ await Promise.all( - filtered.slice(0, 24).map(async (m) => { - const pdfUrl = (m.pdfUrl || "").trim(); - if (!pdfUrl) return; - if (getAiSuccessSlideImageUrls(m.slug).length > 0) return; - try { - await ensureAiSuccessStorySlides(m.slug, pdfUrl); - } catch (err) { - console.warn("[ai-cases] 슬라이드 워밍 실패:", m.slug, err?.message || err); - } - }) + filtered + .filter((m) => m._source === "json") + .slice(0, 24) + .map(async (m) => { + const pdfUrl = (m.pdfUrl || "").trim(); + if (!pdfUrl) return; + if (getAiSuccessSlideImageUrls(m.slug).length > 0) return; + try { + await ensureAiSuccessStorySlides(m.slug, pdfUrl); + } catch (err) { + console.warn("[ai-cases] 슬라이드 워밍 실패:", m.slug, err?.message || err); + } + }) ); - const stories = filtered.map((m) => { - const slideUrls = getAiSuccessSlideImageUrls(m.slug); + const stories = filtered.map((s) => { + if (s._source === "submission") { + return { ...s, coverImageUrl: (s.coverImageUrl || "").trim() }; + } + const slideUrls = getAiSuccessSlideImageUrls(s.slug); const coverImageUrl = slideUrls.length > 0 ? slideUrls[0] : ""; - return { ...m, coverImageUrl }; + return { ...s, coverImageUrl }; }); res.render("ai-cases", { activeMenu: "ai-cases", adminMode: res.locals.adminMode, opsUserEmail: !!res.locals.opsUserEmail, + canComposeUseCase: !!getAiUseCaseSubmitterEmail(req, res), successStoryDetailAllowed: isAiSuccessStoryDetailAllowed(req, res), + submittedOk, stories, filters: { q, tag }, availableTags: tags, @@ -1497,11 +2370,88 @@ pageRouter.get("/ai-cases", async (req, res, next) => { } }); +pageRouter.get("/ai-cases/submit/:id", async (req, res, next) => { + try { + if (!isAiSuccessStoryDetailAllowed(req, res)) { + return res.status(403).send( + "상세 열람 불가

상세 열람 불가

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

AI 활용 사례 목록으로

" + ); + } + if (!pgPool) { + return res.status(503).send("서버 DB를 사용할 수 없습니다."); + } + const id = (req.params.id || "").trim(); + const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (!uuidRe.test(id)) { + return res.status(404).send("사례를 찾을 수 없습니다."); + } + const { sanitizeUseCaseBody } = require("./lib/sanitize-use-case-body"); + const row = await aiUseCaseSubmissions.getSubmissionById(pgPool, id); + if (!row) { + return res.status(404).send("사례를 찾을 수 없습니다."); + } + const email = (row.submitter_email || "").trim(); + const author = email && email.indexOf("@") > 0 ? email.split("@")[0] : email || "제출자"; + const created = row.created_at ? new Date(row.created_at) : null; + const publishedAt = created && !Number.isNaN(created.getTime()) ? created.toISOString().slice(0, 10) : ""; + const attach = (() => { + try { + return typeof row.attachment_files === "string" + ? JSON.parse(row.attachment_files) + : row.attachment_files || []; + } catch { + return []; + } + })(); + const requestEm = getAiUseCaseSubmitterEmail(req, res); + const canEditSubmission = aiUseCaseSubmissions.canUserEditSubmission( + email, + requestEm, + !!res.locals.adminMode + ); + const viewCount = await aiUseCaseSubmissions.recordViewFromOther( + pgPool, + id, + requestEm, + email + ); + const engagement = await aiUseCaseSubmissions.getSubmissionEngagement(pgPool, id, requestEm); + const thumbFiles = aiUseCaseSubmissions.parseThumbnailFiles(row); + const updatedOk = (req.query.updated || "").toString() === "1"; + res.render("ai-use-case-submission-detail", { + activeMenu: "ai-cases", + adminMode: res.locals.adminMode, + opsUserEmail: !!res.locals.opsUserEmail, + submissionId: id, + title: (row.title || "").trim() || "제목 없음", + submitterEmail: email, + author, + department: aiUseCaseSubmissions.SUBMISSION_DEPT, + publishedAt, + tags: Array.isArray(row.tags) ? row.tags : [], + coverImageUrl: aiUseCaseSubmissions.primaryThumbnailPath(thumbFiles) || "", + thumbnailImages: thumbFiles, + situationHtml: sanitizeUseCaseBody(String(row.situation || "")), + taskHtml: sanitizeUseCaseBody(String(row.task_goal || "")), + actionHtml: sanitizeUseCaseBody(String(row.action_taken || "")), + resultHtml: sanitizeUseCaseBody(String(row.result_outcome || "")), + attachments: Array.isArray(attach) ? attach : [], + canEditSubmission, + updatedOk, + viewCount: typeof viewCount === "number" ? viewCount : engagement.viewCount, + likeCount: engagement.likeCount, + myLike: engagement.myLike, + }); + } catch (err) { + next(err); + } +}); + pageRouter.get("/ai-cases/:slug", async (req, res, next) => { try { if (!isAiSuccessStoryDetailAllowed(req, res)) { return res.status(403).send( - "상세 열람 불가

상세 열람 불가

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

AI 성공 사례 목록으로

" + "상세 열람 불가

상세 열람 불가

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

AI 활용 사례 목록으로

" ); } const slug = (req.params.slug || "").trim(); @@ -1951,85 +2901,208 @@ app.post("/api/meeting-minutes/generate-text", requireMeetingMinutesEmail, expre } }); + + +/** 음성: (1) prepare-audio = multipart 업로드 후 즉시 { jobId } JSON (2) stream-audio = GET SSE 실시간 진행 — 업로드+SSE 단일 연결(generate-audio)은 프록시/브라우저가 SSE 본문을 버퍼링하면 UI가 멈춘 것처럼 보일 수 있음 */ +app.post( + "/api/meeting-minutes/prepare-audio", + requireMeetingMinutesEmail, + assignMeetingAudioJobId, + (req, res, next) => { + uploadMeetingAudioPrepare.single("audio")(req, res, (err) => { + if (err) { + return res.status(400).json({ error: err.message || "파일 업로드 실패" }); + } + next(); + }); + }, + async (req, res) => { + try { + if (!req.file || !req.file.path) { + res.status(400).json({ error: "음성 파일이 없습니다." }); + return; + } + const jobId = req.meetingAudioJobId; + if (!jobId) { + res.status(500).json({ error: "jobId 할당 오류" }); + return; + } + let fileAbs; + try { + fileAbs = resolvedMeetingMinutesUploadAudioPath(req.file.path); + } catch (pe) { + res.status(500).json({ error: pe?.message || "경로 검증 실패" }); + try { + if (req.file?.path && fsSync.existsSync(req.file.path)) fsSync.unlinkSync(req.file.path); + } catch (_) { + /* ignore */ + } + return; + } + ensureMeetingAudioJobMetaDirSync(); + const mp = meetingAudioJobMetaPath(jobId); + const meta = { + email: req.meetingUserEmail, + fileAbsPath: fileAbs, + originalName: decodeUploadFilename(req.file.originalname || ""), + title: (req.body?.title || "").toString().trim().slice(0, 500), + model: (req.body?.model || "gpt-5-mini").toString().trim(), + whisperModel: ( + req.body?.whisperModel || meetingMinutesLib.DEFAULT_TRANSCRIPTION_MODEL + ).toString().trim(), + meetingDateRaw: req.body?.meetingDate, + createdAtMs: Date.now(), + }; + const tmpMp = `${mp}.tmp`; + await fs.writeFile(tmpMp, JSON.stringify(meta), "utf-8"); + await fs.rename(tmpMp, mp); + res.json({ jobId }); + } catch (e) { + try { + if (req.file?.path && fsSync.existsSync(req.file.path)) fsSync.unlinkSync(req.file.path); + } catch (_) { + /* ignore */ + } + res.status(500).json({ error: e?.message || "준비 저장 실패" }); + } + } +); + +app.get("/api/meeting-minutes/stream-audio/:jobId", requireMeetingMinutesEmail, async (req, res) => { + const rawParam = req.params.jobId != null ? String(req.params.jobId).trim() : ""; + let unlinkMetaAfterRun = /** @type {string|null} */ (null); + try { + if (!isMeetingAudioJobId(rawParam)) { + res.status(400).json({ error: "유효하지 않은 작업 번호입니다." }); + return; + } + const metaPath = meetingAudioJobMetaPath(rawParam); + if (!fsSync.existsSync(metaPath)) { + res.status(404).json({ error: "작업 정보를 찾을 수 없거나 만료되었습니다." }); + return; + } + let meta; + try { + meta = JSON.parse(fsSync.readFileSync(metaPath, "utf-8")); + } catch (_parseErr) { + try { + fsSync.unlinkSync(metaPath); + } catch (_) { + /* ignore */ + } + res.status(400).json({ error: "손상된 작업 정보입니다." }); + return; + } + if (!meta || typeof meta.email !== "string") { + res.status(404).json({ error: "손상된 작업 정보입니다." }); + return; + } + if (meta.email !== req.meetingUserEmail) { + res.status(403).json({ error: "이 작업에 접근할 권한이 없습니다." }); + return; + } + const created = typeof meta.createdAtMs === "number" ? meta.createdAtMs : 0; + const age = Date.now() - created; + if (age > MEETING_AUDIO_JOB_MAX_AGE_MS) { + try { + fsSync.unlinkSync(metaPath); + } catch (_) { + /* ignore */ + } + try { + if (meta.fileAbsPath && fsSync.existsSync(meta.fileAbsPath)) fsSync.unlinkSync(meta.fileAbsPath); + } catch (_) { + /* ignore */ + } + res.status(410).json({ error: "작업이 너무 오래되어 만료되었습니다." }); + return; + } + let fileAbs; + try { + fileAbs = resolvedMeetingMinutesUploadAudioPath(meta.fileAbsPath); + } catch (pathErr) { + try { + fsSync.unlinkSync(metaPath); + } catch (_) { + /* ignore */ + } + res.status(500).json({ error: pathErr?.message || "파일 경로 오류" }); + return; + } + if (!fsSync.existsSync(fileAbs)) { + try { + fsSync.unlinkSync(metaPath); + } catch (_) { + /* ignore */ + } + res.status(404).json({ error: "음성 파일이 없습니다." }); + return; + } + + unlinkMetaAfterRun = metaPath; + if (req.socket) req.socket.setNoDelay(true); + writeMeetingMinutesAudioSsePreamble(res); + await runMeetingMinutesGenerateAudioSsePipeline(res, { + email: meta.email, + fileAbsPath: fileAbs, + originalName: (meta.originalName || "").toString().trim().slice(0, 500), + title: (meta.title || "").toString().trim().slice(0, 500), + model: (meta.model || "gpt-5-mini").toString().trim(), + whisperModel: (meta.whisperModel || meetingMinutesLib.DEFAULT_TRANSCRIPTION_MODEL).toString().trim(), + meetingDateRaw: meta.meetingDateRaw, + unlinkOnFailure: false, + }); + } catch (err) { + const msg = err?.response?.data?.error?.message || err?.message || "생성 실패"; + if (!res.headersSent) { + res.status(500).json({ error: msg }); + } + } finally { + if (unlinkMetaAfterRun && fsSync.existsSync(unlinkMetaAfterRun)) { + try { + fsSync.unlinkSync(unlinkMetaAfterRun); + } catch (_) { + /* ignore */ + } + } + } +}); + app.post( "/api/meeting-minutes/generate-audio", requireMeetingMinutesEmail, (req, res, next) => { uploadMeetingAudio.single("audio")(req, res, (err) => { - if (err) return res.status(400).json({ error: err.message || "파일 업로드 실패" }); + if (err) { + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("X-Accel-Buffering", "no"); + res.flushHeaders(); + res.write(`event: error\ndata: ${JSON.stringify({ message: err.message || "파일 업로드 실패" })}\n\n`); + return res.end(); + } next(); }); }, async (req, res) => { - if (!OPENAI_API_KEY) return res.status(503).json({ error: "OPENAI_API_KEY가 설정되지 않았습니다." }); - try { - await ensureMeetingUserAndDefaultPrompt(req.meetingUserEmail); - const title = (req.body?.title || "").toString().trim().slice(0, 500); - const model = (req.body?.model || "gpt-5-mini").toString().trim(); - const whisperModel = (req.body?.whisperModel || meetingMinutesLib.DEFAULT_TRANSCRIPTION_MODEL).toString().trim(); - const mdParsed = parseMeetingDateIso(req.body?.meetingDate); - if (mdParsed.error) return res.status(400).json({ error: mdParsed.error }); - if (!req.file) return res.status(400).json({ error: "음성 파일을 선택해 주세요." }); - if (!MEETING_MINUTES_ALLOWED_MODELS.has(model)) { - return res.status(400).json({ error: "지원 모델: gpt-5-mini, gpt-5.4" }); - } - if (!meetingMinutesLib.TRANSCRIPTION_UI_MODELS.has(whisperModel)) { - return res.status(400).json({ error: "지원 전사 모델: gpt-4o-mini-transcribe, gpt-4o-transcribe" }); - } - const relPath = `/uploads/${path.relative(UPLOAD_DIR, req.file.path).split(path.sep).join("/")}`; - const pr = await meetingAiStore.getPromptRow(pgPool, req.meetingUserEmail); - const promptRow = mapRowToMeetingPrompt(pr); - const systemPrompt = meetingMinutesLib.buildMeetingMinutesSystemPrompt(promptRow); - const openai = new OpenAI({ apiKey: OPENAI_API_KEY }); - let transcript = ""; - try { - transcript = await meetingMinutesLib.transcribeMeetingAudio(openai, req.file.path, whisperModel); - } catch (te) { - const tmsg = te?.response?.data?.error?.message || te?.message || "전사 실패"; - return res.status(500).json({ error: `Whisper 전사 실패: ${tmsg}` }); - } - if (!transcript.trim()) return res.status(400).json({ error: "전사 결과가 비어 있습니다." }); - const generated = await meetingMinutesLib.generateMeetingMinutes(openai, { - systemPrompt, - userContent: transcript, - uiModel: model, - resolveApiModel: resolveOpenAiApiModel, - omitMeetingChecklistSection: promptRow?.includeChecklist !== true, - }); - const ins = await meetingAiStore.insertMeetingAudio(pgPool, { - email: req.meetingUserEmail, - title, - transcript, - generated, - relPath, - originalName: req.file.originalname, - model, - whisperModel, - meetingDate: mdParsed.value, - }); - let checklistSync = { imported: 0 }; - try { - checklistSync = await syncAutoChecklistFromMeetingMinutes(openai, { - pgPool, - email: req.meetingUserEmail, - meetingId: ins.id, - generatedMinutes: generated, - uiModel: model, - }); - } catch (e) { - checklistSync = { imported: 0, extractError: e?.message }; - } - const meetingOut = mapRowToMeeting(ins); - if (checklistSync.snapshot) meetingOut.checklistSnapshot = checklistSync.snapshot; - res.json({ meeting: meetingOut, checklistSync }); - } catch (err) { - const msg = err?.response?.data?.error?.message || err?.message || "생성 실패"; - res.status(500).json({ error: msg }); - } + if (req.socket) req.socket.setNoDelay(true); + writeMeetingMinutesAudioSsePreamble(res); + await runMeetingMinutesGenerateAudioSsePipeline(res, { + email: req.meetingUserEmail, + fileAbsPath: req.file && req.file.path, + originalName: (req.file && req.file.originalname) || "", + title: (req.body?.title || "").toString().trim().slice(0, 500), + model: (req.body?.model || "gpt-5-mini").toString().trim(), + whisperModel: ( + req.body?.whisperModel || meetingMinutesLib.DEFAULT_TRANSCRIPTION_MODEL + ).toString().trim(), + meetingDateRaw: req.body?.meetingDate, + unlinkOnFailure: false, + }); } ); -app.get("/api/task-checklist/items", requireMeetingMinutesEmail, async (req, res) => { +app.get("/api/task-checklist/items", requireTaskChecklistAccess, requireMeetingMinutesEmail, async (req, res) => { try { await ensureMeetingUserAndDefaultPrompt(req.meetingUserEmail); const completed = req.query.completed; @@ -2060,7 +3133,7 @@ app.get("/api/task-checklist/items", requireMeetingMinutesEmail, async (req, res } }); -app.post("/api/task-checklist/items", requireMeetingMinutesEmail, express.json(), async (req, res) => { +app.post("/api/task-checklist/items", requireTaskChecklistAccess, requireMeetingMinutesEmail, express.json(), async (req, res) => { try { await ensureMeetingUserAndDefaultPrompt(req.meetingUserEmail); const item = await taskChecklistStore.insertItem(pgPool, req.meetingUserEmail, { @@ -2079,7 +3152,7 @@ app.post("/api/task-checklist/items", requireMeetingMinutesEmail, express.json() } }); -app.patch("/api/task-checklist/items/:id", requireMeetingMinutesEmail, express.json(), async (req, res) => { +app.patch("/api/task-checklist/items/:id", requireTaskChecklistAccess, requireMeetingMinutesEmail, express.json(), async (req, res) => { try { const id = (req.params.id || "").trim(); const item = await taskChecklistStore.updateItem(pgPool, id, req.meetingUserEmail, req.body || {}); @@ -2090,7 +3163,7 @@ app.patch("/api/task-checklist/items/:id", requireMeetingMinutesEmail, express.j } }); -app.delete("/api/task-checklist/items/:id", requireMeetingMinutesEmail, async (req, res) => { +app.delete("/api/task-checklist/items/:id", requireTaskChecklistAccess, requireMeetingMinutesEmail, async (req, res) => { try { const id = (req.params.id || "").trim(); const result = await taskChecklistStore.deleteItem(pgPool, id, req.meetingUserEmail); @@ -2101,7 +3174,7 @@ app.delete("/api/task-checklist/items/:id", requireMeetingMinutesEmail, async (r } }); -app.post("/api/task-checklist/import/:meetingId", requireMeetingMinutesEmail, async (req, res) => { +app.post("/api/task-checklist/import/:meetingId", requireTaskChecklistAccess, requireMeetingMinutesEmail, async (req, res) => { try { await ensureMeetingUserAndDefaultPrompt(req.meetingUserEmail); const meetingId = (req.params.meetingId || "").trim(); @@ -2128,7 +3201,7 @@ app.post("/api/task-checklist/import/:meetingId", requireMeetingMinutesEmail, as }); /** 모든 회의록에 대해 규칙 기반 가져오기(회의별 중복은 기존과 동일하게 스킵) */ -app.post("/api/task-checklist/import-all", requireMeetingMinutesEmail, async (req, res) => { +app.post("/api/task-checklist/import-all", requireTaskChecklistAccess, requireMeetingMinutesEmail, async (req, res) => { try { await ensureMeetingUserAndDefaultPrompt(req.meetingUserEmail); const mode = (req.query.mode || "checklist").toString().trim() === "actions" ? "actions" : "checklist"; @@ -2178,6 +3251,177 @@ app.get("/api/chat/config", (req, res) => { }); }); +app.post( + "/api/prompts/likes/toggle", + requirePromptLibEmail, + express.json(), + async (req, res) => { + if (!pgPool) { + return res.status(503).json({ error: "DB를 사용할 수 없습니다." }); + } + try { + const officialList = loadCompanyPrompts(); + const officialIdSet = new Set((officialList || []).map((p) => p && p.id).filter(Boolean)); + const kind = String(req.body?.kind || req.body?.targetKind || "").trim(); + const id = String(req.body?.id || req.body?.targetId || "").trim(); + const out = await promptLibrary.toggleLike( + pgPool, + req.promptLibEmail, + kind, + id, + officialIdSet + ); + return res.json({ ok: true, ...out }); + } catch (err) { + if (err && err.code === "VALIDATION") { + return res.status(400).json({ error: err.message || "잘못된 요청입니다." }); + } + if (err && err.code === "NOT_FOUND") { + return res.status(404).json({ error: err.message || "없습니다." }); + } + console.error("[prompt-likes/toggle]", err); + return res.status(500).json({ error: err?.message || "처리에 실패했습니다." }); + } + } +); + +app.post( + "/api/prompts/community", + requirePromptLibEmail, + (req, res, next) => { + if (!pgPool) { + return res.status(503).json({ error: "DB를 사용할 수 없습니다." }); + } + req._promptBundleId = uuidv4(); + next(); + }, + (req, res, next) => { + uploadPromptCommunityFields(req, res, (err) => { + if (err) { + return res.status(400).json({ error: err.message || "파일 업로드에 실패했습니다." }); + } + next(); + }); + }, + async (req, res) => { + try { + const { title, description, body, tag } = req.body || {}; + const promptF = (req.files && req.files["promptFiles"]) || []; + const resultF = (req.files && req.files["resultFiles"]) || []; + for (let i = 0; i < promptF.length; i += 1) { + const sz = fsSync.statSync(promptF[i].path).size; + if (sz > promptLibrary.MAX_ATTACH_BYTES) { + return res.status(400).json({ error: `프롬프트 첨부는 개당 ${Math.round(promptLibrary.MAX_ATTACH_BYTES / 1024 / 1024)}MB 이하입니다. (${i + 1}번째)` }); + } + } + for (let i = 0; i < resultF.length; i += 1) { + const sz = fsSync.statSync(resultF[i].path).size; + if (sz > promptLibrary.MAX_ATTACH_BYTES) { + return res.status(400).json({ error: `결과(샘플) 첨부는 개당 ${Math.round(promptLibrary.MAX_ATTACH_BYTES / 1024 / 1024)}MB 이하입니다. (${i + 1}번째)` }); + } + } + const promptAttachments = promptF.map((f) => ({ + originalName: decodeUploadFilename(f.originalname), + relativePath: filePathToPublicUploadsUrl(f.path), + size: fsSync.statSync(f.path).size, + })); + const resultSampleAttachments = resultF.map((f) => ({ + originalName: decodeUploadFilename(f.originalname), + relativePath: filePathToPublicUploadsUrl(f.path), + size: fsSync.statSync(f.path).size, + })); + const created = await promptLibrary.createCommunityEntry(pgPool, { + authorEmail: req.promptLibEmail, + title, + description, + body, + tag, + promptAttachments, + resultSampleAttachments, + }); + return res.json({ + ok: true, + id: created.id, + createdAt: created.createdAt, + promptFiles: created.promptFiles, + resultFiles: created.resultFiles, + }); + } catch (err) { + if (err && err.code === "VALIDATION") { + return res.status(400).json({ error: err.message || "입력을 확인해 주세요." }); + } + if (err && err.code === "23505") { + return res.status(409).json({ error: "이미 등록되었을 수 있습니다." }); + } + console.error("[prompts/community]", err); + return res.status(500).json({ error: err?.message || "저장에 실패했습니다." }); + } + } +); + +app.delete( + "/api/prompts/community/:id", + requirePromptLibEmail, + async (req, res) => { + if (!pgPool) { + return res.status(503).json({ error: "DB를 사용할 수 없습니다." }); + } + try { + await promptLibrary.softDeleteCommunityEntry(pgPool, String(req.params.id || "").trim(), req.promptLibEmail); + return res.json({ ok: true }); + } catch (err) { + if (err && err.code === "VALIDATION") { + return res.status(400).json({ error: err.message || "잘못된 요청입니다." }); + } + if (err && err.code === "NOT_FOUND") { + return res.status(404).json({ error: err.message || "없습니다." }); + } + console.error("[prompts/community delete]", err); + return res.status(500).json({ error: err?.message || "삭제에 실패했습니다." }); + } + } +); + +app.post( + "/api/prompts/polish-workflow", + requirePromptLibEmail, + express.json({ limit: "128kb" }), + async (req, res) => { + if (!OPENAI_API_KEY) { + return res.status(503).json({ error: "OpenAI API 키가 설정되지 않았습니다." }); + } + const draft = String((req.body && req.body.draft) || "").trim(); + if (draft.length < 5) { + return res.status(400).json({ error: "워크플로·요구사항을 조금 더 입력해 주세요." }); + } + if (draft.length > 14_000) { + return res.status(400).json({ error: "입력이 너무 깁니다." }); + } + try { + const openai = new OpenAI({ apiKey: OPENAI_API_KEY }); + const apiModel = resolveOpenAiApiModel("gpt-5-mini"); + const completion = await openai.chat.completions.create({ + model: apiModel, + messages: [ + { + role: "system", + content: + "너는 사내 직장인이 AI에 넣을 '프롬프트(지시문)'을 완성하도록 돕는 어시스턴트다. 사용자의 초안(업무 맥락, 입·출력, 제약)을 한 편의 실행 가능한 프롬프트로 정리한다. 채워야 할 항목은 [대괄호]로 남겨 둬도 된다. 한국어. 서론·잡담 없이 프롬프트 본문만 출력해도 좋다.", + }, + { role: "user", content: draft }, + ], + max_tokens: 2048, + }); + const text = completion.choices?.[0]?.message?.content || ""; + return res.json({ text: String(text).trim() }); + } catch (err) { + const apiMsg = + err?.error?.message || err?.response?.data?.error?.message || err?.message || "OpenAI 오류"; + return res.status(500).json({ error: apiMsg }); + } + } +); + app.post("/api/chat", async (req, res) => { if (!isChatGptAllowed(req, res)) { res.status(403).json({ error: "허용된 사용자가 아닙니다." }); @@ -3825,7 +5069,7 @@ app.get("/admin/users", async (req, res) => { try { if (pgPool) { const r = await pgPool.query( - `SELECT email, last_login_at, first_seen_at, login_count + `SELECT email, last_login_at, first_seen_at, login_count, sessions_revoked_at FROM ops_email_users ORDER BY last_login_at DESC NULLS LAST` ); @@ -3834,6 +5078,7 @@ app.get("/admin/users", async (req, res) => { lastLoginAt: row.last_login_at, firstSeenAt: row.first_seen_at, loginCount: row.login_count, + sessionsRevokedAt: row.sessions_revoked_at, })); } } catch (err) { @@ -3850,6 +5095,38 @@ app.get("/admin/users", async (req, res) => { }); }); +/** 관리자: 특정 OPS 사용자의 모든 디바이스 세션 무효화(전체 로그아웃) */ +app.post("/api/admin/users/revoke-sessions", async (req, res) => { + if (!res.locals.adminMode) { + return res.status(403).json({ error: "관리자만 세션을 만료시킬 수 있습니다." }); + } + + const email = String(req.body?.email || "") + .trim() + .toLowerCase() + .slice(0, 320); + if (!email || !email.endsWith("@ncue.net")) { + return res.status(400).json({ error: "유효한 @ncue.net 이메일이 필요합니다." }); + } + + try { + const { revokedAtMs } = await revokeAllOpsSessionsForEmail(pgPool, DATA_DIR, email); + await recordOpsEmailAuthEvent({ + email, + eventType: "sessions_revoked", + req, + }); + res.json({ + ok: true, + email, + revokedAt: new Date(revokedAtMs).toISOString(), + }); + } catch (err) { + console.error("[admin/revoke-sessions]", err); + res.status(500).json({ error: err?.message || "세션 만료 처리 실패" }); + } +}); + app.get("/admin", async (req, res) => { const ctx = await buildLectureListContext(req, { basePath: "/admin", diff --git a/views/admin-users.ejs b/views/admin-users.ejs index c4750cf..617d29c 100644 --- a/views/admin-users.ejs +++ b/views/admin-users.ejs @@ -18,8 +18,12 @@

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

+

+ 전체 로그아웃을 실행하면 해당 사용자의 모든 디바이스에서 OPS 세션이 즉시 무효화됩니다. 이후 다시 매직 링크로 로그인해야 합니다. +

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

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

<% } else if (!pgConnected) { %> @@ -33,11 +37,13 @@ 이메일 최근 접속일 + 세션 무효화 + 작업 <% users.forEach(function (u) { %> - + <%= u.email %> <% if (u.lastLoginAt) { %> @@ -46,6 +52,22 @@ — <% } %> + + <% if (u.sessionsRevokedAt) { %> + <%= new Date(u.sessionsRevokedAt).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul' }) %> + <% } else { %> + — + <% } %> + + + + <% }); %> @@ -57,5 +79,54 @@
+ diff --git a/views/ai-case-detail.ejs b/views/ai-case-detail.ejs index faa65ae..d379108 100644 --- a/views/ai-case-detail.ejs +++ b/views/ai-case-detail.ejs @@ -4,7 +4,7 @@ <%- include('partials/favicon') %> - <%= story.title %> - AI 성공 사례 - XAVIS + <%= story.title %> - AI 활용 사례 - XAVIS @@ -16,7 +16,7 @@
<% if (showPdfViewer) { %>
- ← AI 성공 사례로 돌아가기 + ← AI 활용 사례로 돌아가기

<%= story.title %>

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

@@ -69,7 +69,7 @@
<% } else { %>
- ← AI 성공 사례로 돌아가기 + ← AI 활용 사례로 돌아가기

<%= story.title %>

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

diff --git a/views/ai-cases-compose.ejs b/views/ai-cases-compose.ejs new file mode 100644 index 0000000..4dc2d5c --- /dev/null +++ b/views/ai-cases-compose.ejs @@ -0,0 +1,771 @@ + + + + + + <%- include('partials/favicon') %> + AI 활용 사례 > 작성하기 - XAVIS + + + + +
+ <%- include('partials/nav', { activeMenu: 'ai-cases' }) %> +
+
+

AI 활용 사례 > <%= editPayload ? "수정" : "작성" %>

+ 목록 +
+
+

+ 작성자: <%= typeof submitterEmail !== "undefined" ? submitterEmail : "" %> +

+
+
+ + +
+
+ +

한 편집기 안에서 1.Situation → 4.Result 순서로 작성합니다.
구역을 나누는 제목·블록을 삭제하면 저장이 실패할 수 있습니다.

+
+
+

0 / <%= maxBodyTotal %>자

+
+
+ + +

활용한 AI 툴을 입력해주세요.

+
+
+ 썸네일 이미지 <% if (!editPayload) { %>*<% } else { %>(변경·추가 시에만)<% } %> +

최대 <%= maxThumbCount %>개, 개당 5MB, 가급 1:1 비율 권장

+ <% if (typeof editPayload !== "undefined" && editPayload) { %> +

이미지 우측 상단 X로 삭제할 수 있습니다. 새 이미지는 아래에서 고르면 목록 끝에 추가됩니다(합계 최대 <%= maxThumbCount %>개).

+ <% } %> +
+ + +
+ +
+
+ 첨부 파일 (선택, 최대 <%= maxAttachCount %>개·20MB) + <% if (typeof editPayload !== "undefined" && editPayload) { %> +

수정 시: 아래 기존 첨부를 체크해 삭제한 뒤 새 파일을 선택할 수 있습니다(최대 <%= maxAttachCount %>개).

+ <% } %> + + <% if (typeof editPayload !== "undefined" && editPayload && editPayload.existingAttachments && editPayload.existingAttachments.length) { %> +

기존 첨부 — 제거하려면 체크

+
    + <% editPayload.existingAttachments.forEach(function (a) { var fn = (a.originalName || "").replace(/[<>"]/g, "") || (a.relativePath && a.relativePath.split("/").pop()) || "첨부"; %> +
  • + +
  • + <% }); %> +
+ <% } %> + +
+
+
+ 취소 + +
+ + <% if (editPayload) { %> + + <% } %> +
+
+
+
+ + + + + diff --git a/views/ai-cases-write.ejs b/views/ai-cases-write.ejs index e3f40e6..7de4478 100644 --- a/views/ai-cases-write.ejs +++ b/views/ai-cases-write.ejs @@ -4,7 +4,7 @@ <%- include('partials/favicon') %> - AI 성공 사례 관리 - XAVIS + AI 활용 사례 관리 - XAVIS