diff --git a/.gitignore b/.gitignore index af2446f..758552b 100644 --- a/.gitignore +++ b/.gitignore @@ -40,8 +40,6 @@ yarn-error.log* *.tsbuildinfo next-env.d.ts -/src/generated/prisma - # Uploads /uploads diff --git a/Dockerfile b/Dockerfile index 29e1970..7d837c5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,7 +21,6 @@ COPY --from=builder /app/public ./public COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/prisma ./prisma -COPY --from=builder /app/src/generated ./src/generated RUN mkdir -p uploads && chown nextjs:nodejs uploads diff --git a/README.md b/README.md index 294a02c..2ac8e1e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ --- -## 현재 기능 (Phase 0 + 0.5) +## 현재 기능 (Phase 0 + 0.5 + 1 + 1.5 + 2) ### 녹음 & 전사 - 브라우저 실시간 음성 인식 (Web Speech API · Chrome `ko-KR`) @@ -40,12 +40,38 @@ - **표준** — 맥락이 이해될 정도 - **상세** — 세부사항·수치 누락 없이 보존 +### 액션 아이템 (Phase 2) +- 회의록 저장 시 마크다운의 `- [ ]` 항목을 자동으로 추출 → DB에 구조화 저장 +- 상세 페이지에 **체크리스트 섹션** — 체크박스 토글로 완료 표시 +- 개별 항목 삭제, 마크다운 편집 후 **🔄 재추출** 버튼으로 동기화 +- `/meetings` 상단에 "내 미완료 액션 (최근 5)" 위젯 +- 회의 카드에 미완료 카운트 배지 (예: `✅ 3 미완료`) +- **Google Docs 호환 복사** — `📋 Docs용` 버튼으로 서식 유지 (heading/list/bold/code) 채로 클립보드에 복사 → Docs/Word/Notion에 그대로 붙여넣기 + +### Gemini 키 웹 설정 (Phase 1.5) +- **`/settings` 페이지** — 헤더 ⚙️ 링크로 진입 +- 브라우저 LocalStorage에 저장 (서버 DB 미사용) +- 마스킹된 현재 키 표시 (`AIza••••XYZ12`), 보이기/숨기기 토글 +- **🧪 테스트 호출** — 가벼운 Gemini 응답으로 키 유효성 검증 +- 우선순위: 브라우저 키 → 환경변수 → 단순 변환 폴백 +- 키 미설정 시에도 단순 변환 모드로 회의록은 항상 생성됨 + +### 저장·조회 워크스페이스 (Phase 1) +- **`📌 저장` 버튼** — 생성된 회의록을 DB에 영속 저장 +- **`/meetings` 목록 페이지** — 카드 그리드, 페이지네이션, 빈 상태 UI +- **`/meetings/[id]` 상세 페이지** — Markdown 렌더 + 인라인 편집 + 삭제 + - 편집 모드는 좌(textarea) · 우(live preview) 분할 + - 참석자/태그도 상세 페이지에서 편집 +- **검색 바** — 제목·transcript·본문 부분 검색 (300ms debounce, URL 동기화) +- **Markdown 라이브러리** — `marked` + `isomorphic-dompurify`로 안전한 HTML 렌더 (표/리스트/인용 등 모두 정상) + ### 화면 - 녹음 시 **좌(실시간 텍스트) · 우(실시간 회의록)** 분할 뷰 - 확정 전 interim 텍스트는 회색 이탤릭 + 깜빡이는 커서 - 새 내용 도착 시 자동 스크롤 - 진행률 바 (첫 요약까지 단어 수), 쿨다운 카운터 - 템플릿/강도 변경 시 라이브 요약도 즉시 반영 +- 메인 화면 상단 우측 `📚 저장된 회의록` 링크 → 목록으로 이동 --- @@ -56,8 +82,9 @@ | 프레임워크 | Next.js 16 (App Router) + TypeScript | | STT (실시간) | Web Speech API — Chrome 내장, 무료 | | AI 요약 | Gemini 2.5 Flash Lite — 무료 등급 15 RPM / 1000 RPD | -| DB | PostgreSQL 16 + Prisma 7 (스키마 준비, Phase 1에서 활용 예정) | -| 테스트 | Vitest (71 tests, jsdom) | +| DB | PostgreSQL 16 + Prisma 7 (driver adapter `@prisma/adapter-pg`) | +| Markdown | `marked` + `isomorphic-dompurify` | +| 테스트 | Vitest (102 tests, jsdom) | | 배포 | Docker Compose (app + db + test profile) | --- @@ -72,8 +99,11 @@ cd meeting-minutes cp .env.example .env ``` -`.env`에서 **`GEMINI_API_KEY`** 설정 ([Google AI Studio](https://aistudio.google.com/apikey)에서 무료 발급). -> 키가 없어도 "단순 변환" 모드는 정상 동작. +**Gemini API 키 설정 — 두 가지 방법 중 선택**: +- (A) **웹 UI** — 앱 기동 후 `/settings` 페이지에서 입력 (브라우저 LocalStorage 저장, 추천) +- (B) **`.env` 환경변수** — `GEMINI_API_KEY=AIza...` 작성 ([Google AI Studio](https://aistudio.google.com/apikey)에서 무료 발급) + +> 키가 없어도 "단순 변환" 모드는 정상 동작. Gemini 모드를 쓰려면 둘 중 하나는 필요. ### 2. DB 마이그레이션 (최초 1회 + 스키마 변경 시) @@ -91,13 +121,15 @@ docker compose up -d --build 브라우저에서 [http://localhost:3000](http://localhost:3000) 접속 → **Chrome 권장** (Web Speech API). -### 3. 확인 방법 +### 4. 확인 방법 1. 회의 제목 입력 (선택) -2. **Gemini AI 요약** 모드 (기본값) +2. **Gemini AI 요약** 모드 + 원하는 **템플릿** / **강도** 선택 3. **녹음 시작** → 마이크 권한 허용 4. 말하기 시작 → 좌측 실시간 텍스트, 25단어 이상 쌓이면 우측에 중간 회의록 생성 5. **녹음 중지** → 최종 회의록이 페이지 하단에 자동 생성됨 +6. **`📌 저장`** 버튼 → `/meetings/[id]`로 영속화 +7. 상단 **`📚 저장된 회의록`** 링크 → 목록·검색·편집 ### 서비스 관리 @@ -138,10 +170,29 @@ src/ │ ├── api/ │ │ ├── upload/route.ts # 파일 업로드 │ │ ├── summarize/route.ts # 최종 회의록 (Gemini + simple fallback) -│ │ └── summarize-live/route.ts # 실시간 중간 요약 -│ ├── page.tsx # 메인 UI +│ │ ├── summarize-live/route.ts # 실시간 중간 요약 +│ │ ├── meetings/ +│ │ │ ├── route.ts # GET 목록 (q 검색) / POST 저장 +│ │ │ └── [id]/ +│ │ │ ├── route.ts # GET / PUT / DELETE 상세 +│ │ │ └── reparse-action-items/route.ts # POST 마크다운 재파싱 +│ │ ├── action-items/ +│ │ │ └── [id]/route.ts # PATCH 토글 / DELETE +│ │ └── settings/ +│ │ ├── status/route.ts # 환경변수 키 설정 여부 +│ │ └── test/route.ts # 키 유효성 검증 (가벼운 Gemini 호출) +│ ├── meetings/ +│ │ ├── page.tsx # 저장된 회의록 목록 + 검색 +│ │ └── [id]/page.tsx # 상세 + 편집 + 삭제 +│ ├── settings/page.tsx # 키 설정 페이지 (LocalStorage) +│ ├── page.tsx # 메인 UI (녹음/요약) │ └── layout.tsx ├── lib/ +│ ├── db.ts # Prisma 싱글톤 (pg adapter) +│ ├── markdown.ts # marked + DOMPurify 렌더 + Docs용 서식 복사 +│ ├── action-items.ts # 마크다운 - [ ] 휴리스틱 파서 +│ ├── api-keys.ts # 서버: 요청 키 → env fallback 우선순위 +│ ├── api-key-storage.ts # 클라이언트: LocalStorage 헬퍼 + 마스킹 │ ├── audio-validation.ts │ ├── upload-handler.ts │ ├── transcript-formatter.ts @@ -155,15 +206,21 @@ src/ ├── components/ │ ├── upload/AudioUploader.tsx │ ├── recorder/LiveRecorder.tsx # 좌우 분할 뷰 -│ └── minutes/MinutesViewer.tsx -└── __tests__/ # 71 tests +│ ├── minutes/MinutesViewer.tsx # 미리보기/원문 토글 + 저장 +│ └── meeting/ +│ ├── MeetingCard.tsx # 미완료 액션 카운트 배지 포함 +│ ├── MeetingSearchBar.tsx # debounce + URL 동기화 +│ ├── MeetingDetail.tsx # 상세/편집 UI + Docs용 복사 +│ └── ActionItemList.tsx # 체크박스 토글 + 재추출 + 삭제 +└── __tests__/ # 102 tests docs/ ├── ROADMAP.md # 개발 로드맵 (Phase 0~4) └── CREATE_ISSUES.sh # gh CLI용 이슈 자동 생성 스크립트 prisma/ -└── schema.prisma # Meeting 모델 (Phase 1에서 활용) +├── schema.prisma # Meeting 모델 +└── migrations/ # 마이그레이션 히스토리 ``` --- @@ -176,8 +233,9 @@ prisma/ |---|---|---| | **0** | 실시간 전사 + 롤링 요약 + 자동 최종 생성 | ✅ 완료 | | **0.5** | 템플릿 (6종) + 강도 조절 (3단계) + 커스텀 프롬프트 | ✅ 완료 | -| **1** | 회의 저장/조회/검색 워크스페이스 | 📋 기획됨 | -| **2** | 구조화된 액션 아이템 (Gemini JSON + 체크리스트) | 📋 기획됨 | +| **1** | 회의 저장/조회/검색 워크스페이스 | ✅ 완료 | +| **1.5** | 웹에서 Gemini 키 설정 (LocalStorage) | ✅ 완료 | +| **2** | 액션 아이템 추출/체크리스트 + Google Docs 복사 | ✅ 완료 | | **3** | 참석자 + 태그 시스템 | 📋 기획됨 | | **4** | 차별화 기능 (캘린더, AI Q&A, 블록 에디터) | 💡 선택 | @@ -193,5 +251,5 @@ prisma/ - **Chrome 전용**: Web Speech API 비표준 — Safari/Firefox는 제한적 - **화자 구분 불가**: 의도적 비지원 (개인용 범위를 넘음) -- **녹음 저장 없음**: 현재 페이지 세션 내에서만 유지됨. Phase 1에서 DB 영속화 - **Gemini 무료 등급**: 15 RPM / 1000 RPD — 개인 사용에 충분하나 팀 단위는 유료 전환 권장 +- **검색**: 현재 PostgreSQL `ILIKE`(contains) 방식. 수천 건 이상 저장 시 `tsvector` GIN 인덱스로 후속 업그레이드 예정 diff --git a/docs/PR_DRAFT_PHASE_1_5.md b/docs/PR_DRAFT_PHASE_1_5.md new file mode 100644 index 0000000..77dc5cf --- /dev/null +++ b/docs/PR_DRAFT_PHASE_1_5.md @@ -0,0 +1,79 @@ +# Phase 1.5 — 웹에서 Gemini API 키 설정 (LocalStorage) + +`.env`를 만지지 않고도 브라우저 `/settings` 페이지에서 Gemini API 키를 입력·저장·테스트할 수 있도록 함. + +## Base +- 🧱 **Base**: `feat/phase-1-ui` (PR #2 위에 스택) +- main 직접 머지 시 PR#1 / PR#2 / 본 PR 변경이 합쳐짐 + +## 설계 의도 (왜 LocalStorage?) +- **개인용 단일 인스턴스 가정** — 여러 사용자나 디바이스 간 동기화 불필요 +- 키가 서버 DB에 남지 않음 → 백업·로그·덤프 노출 위험 없음 +- 마이그레이션·테이블 추가 불필요 +- 단점: 브라우저마다 다시 입력 / 시크릿 모드·캐시 정리 시 사라짐 (개인용으로 수용 가능) + +DB 저장 + env fallback 방식은 의도적으로 선택하지 않음. + +## 키 우선순위 +``` +1. 요청 body의 apiKey (브라우저 LocalStorage에서 옴) + ↓ 없으면 +2. process.env.GEMINI_API_KEY (서버 환경변수 fallback) + ↓ 없으면 +3. summarize → 단순 변환 폴백 (warning 표시) + summarize-live → 503 (라이브 요약은 키 필수) +``` + +## 변경 파일 + +### 신규 +- `src/lib/api-keys.ts` — `resolveGeminiApiKey()`, `isEnvKeyConfigured()` +- `src/lib/api-key-storage.ts` — `getStoredApiKey`, `setStoredApiKey`, `clearStoredApiKey`, `maskApiKey` +- `src/app/api/settings/status/route.ts` — 환경변수 키 존재 여부만 노출 (값 X) +- `src/app/api/settings/test/route.ts` — 키로 짧은 Gemini 호출 → 결과 분류 +- `src/app/settings/page.tsx` — 입력/저장/삭제/테스트 UI +- `src/__tests__/api-keys.test.ts` — 7 tests +- `src/__tests__/api-key-storage.test.ts` — 7 tests + +### 수정 +- `src/app/api/summarize/route.ts` — `body.apiKey` 수용, 키 없을 시 단순 변환 폴백 +- `src/app/api/summarize-live/route.ts` — `body.apiKey` 수용 +- `src/hooks/useLiveSummary.ts` — 매 요청 시 LocalStorage 키 첨부 +- `src/app/page.tsx` — generateMinutes에 LocalStorage 키 첨부 + 헤더에 ⚙️ 링크 +- `README.md` / `docs/ROADMAP.md` + +## UX + +### 설정 페이지 (`/settings`) +- 현재 키 소스 배지: `브라우저 (LocalStorage)` / `환경변수 (서버)` / `없음` +- 마스킹된 현재 키 표시 (`AIza••••XYZ12`) +- 입력 필드 (보이기/숨기기 토글) +- 버튼: 💾 저장, 🧪 테스트 호출, 🗑️ 삭제 +- 테스트 결과: ✓ 정상 / ✗ 401·403·429 등 분류된 메시지 +- Google AI Studio 무료 발급 링크 + +### 안전장치 +- 키 GET 응답에서 절대 풀 키 노출 안 함 +- 테스트 호출은 `"OK"` 단어 한 개만 요청 → 토큰 거의 안 씀 +- HTTPS 권장 안내 (localhost는 예외) + +## 테스트 +- 단위 테스트 14개 추가 (서버 헬퍼 7 + LocalStorage 7) +- 전체: **94 tests passing** + +## 확인 방법 +```bash +docker compose up -d --build app +``` +1. `http://localhost:3000` → 헤더 `⚙️ 설정` +2. 키 입력 → 💾 저장 → 마스킹된 키가 "현재 키 소스 = 브라우저"로 표시 +3. 🧪 테스트 호출 → "키가 정상적으로 동작합니다." + Gemini 응답 미리보기 +4. 홈으로 돌아가 녹음 → Gemini 요약이 LocalStorage 키로 동작 +5. 🗑️ 삭제 → 환경변수 fallback (있으면) 또는 단순 변환으로 자동 전환 + +## Breaking changes +없음. 기존 `.env` 사용자는 변경 없이 동작. + +## 후속 (이 PR 범위 아님) +- 다중 키 (예: OpenAI 키 추가) — 동일 패턴으로 확장 가능 +- 키 로테이션 알림 — Google이 만료 정책 도입 시 diff --git a/docs/PR_DRAFT_PHASE_1_UI.md b/docs/PR_DRAFT_PHASE_1_UI.md new file mode 100644 index 0000000..420d0a1 --- /dev/null +++ b/docs/PR_DRAFT_PHASE_1_UI.md @@ -0,0 +1,89 @@ +# Phase 1 UI — 저장/목록/상세 + Markdown + 검색 + +ROADMAP Phase 1의 #3~#7 완료. PR #1 (`feat/phase-1-foundation`) 위에 스택됨. + +## Base +- 🧱 **Base branch**: `feat/phase-1-foundation` (병합 전이라면 PR#1을 먼저 머지) +- Main에 직접 머지하고 싶다면 base를 `main`으로 바꾸면 두 PR의 변경이 합쳐져서 올라감 + +## 포함된 변경 + +### 📦 의존성 +- `marked@^16.4.0` +- `isomorphic-dompurify@^2.29.0` +- `@prisma/adapter-pg@^7.7.0`, `pg@^8.13.1`, `@types/pg` + (Prisma 7의 `prisma-client` 엔진은 driver adapter 필수) + +### 🎨 Markdown 라이브러리 (#6) +- `src/lib/markdown.ts` — `renderMarkdownToSafeHtml`, `markdownToPlainText` +- 기존 커스텀 파서(`export-minutes.ts`) 교체 +- 표/리스트/인용/코드블록 정상 렌더, XSS sanitize 테스트 포함 +- `globals.css`에 `.prose-minutes` 공통 스타일 + +### 💾 저장 버튼 (#3) +- `MinutesViewer`에 `📌 저장` → `POST /api/meetings` +- 생성 당시 설정(template/depth/mode/customPrompt) 함께 저장 +- 저장 성공 시 `/meetings/[id]` 이동 배너 +- 미리보기 ↔ 원문 토글 + +### 📚 목록 페이지 `/meetings` (#4) +- 서버 컴포넌트 (Prisma 직접 조회) +- `MeetingCard` — 제목/날짜/템플릿 배지/참석자/태그/preview +- 빈 상태 UI (검색 시 vs 최초) +- prev/next 페이지네이션 + +### 📄 상세 페이지 `/meetings/[id]` (#5) +- 서버 컴포넌트 + 클라이언트 편집 island +- 제목/본문/참석자/태그 인라인 편집 +- 편집 모드는 좌(textarea) · 우(live preview) 분할 +- 삭제 confirm +- 원본 transcript는 `
`로 접어둠 + +### 🔍 검색 (#7) +- `MeetingSearchBar` 클라이언트 컴포넌트 +- 300ms debounce + `useTransition` + URL query 동기화 +- 서버에서 `ILIKE contains` — title/transcript/markdown +- `tsvector` GIN 인덱스는 후속 (개인용 규모에서 `ILIKE`로 충분) + +### 🔧 Prisma 7 호환 +- `generator client` → `prisma-client-js` (안정적) +- `@prisma/adapter-pg`로 driver adapter 사용 +- `src/lib/db.ts`: `DATABASE_URL` 누락 시 placeholder fallback (build-time 안전) +- DB 접근 페이지/라우트에 `export const dynamic = 'force-dynamic'` +- Dockerfile에서 `src/generated` 복사 제거 + +### 🧪 테스트 +- `src/__tests__/markdown.test.ts` 9개 추가 (렌더/sanitize/plain text) +- 전체: **80 tests passing** (이전 71 + 9) + +## 확인 방법 + +```bash +# DB + migrate 전제 (PR#1 참고) +docker compose up -d db +docker compose --profile tools run --rm migrate + +# 빌드 + 기동 +docker compose up -d --build app +``` + +### 사용 흐름 +1. `http://localhost:3000`에서 녹음/업로드 → 회의록 생성 +2. `📌 저장` 버튼 클릭 → 배너에 `/meetings/[id]` 링크 +3. 링크 클릭 → 상세 페이지 → `✏️ 편집` → 마크다운/참석자/태그 수정 → 저장 +4. 상단 `← 목록으로` → `/meetings` 카드 그리드 +5. 검색창에 키워드 입력 → 300ms 후 필터링 +6. 삭제는 🗑️ 아이콘 (confirm 후 목록으로 복귀) + +## 스크린샷 + +사용자가 직접 찍어서 추가 예정. + +## Breaking changes +- 기존 녹음/요약 흐름은 유지 +- Prisma 6에서 올라온 환경은 `@prisma/adapter-pg` 추가 설치 필요 (package.json에 이미 반영) +- Dockerfile의 `COPY src/generated` 제거 — 기존 이미지 캐시는 재빌드 필요 + +## 남은 Phase 1 후속 (이 PR 범위 아님) +- tsvector GIN 인덱스 (성능 이슈 체감 시) +- 태그/참석자 필터 UI (검색바 외 패싯) — Phase 3에서 다룰 예정 diff --git a/docs/PR_DRAFT_PHASE_2.md b/docs/PR_DRAFT_PHASE_2.md new file mode 100644 index 0000000..672f455 --- /dev/null +++ b/docs/PR_DRAFT_PHASE_2.md @@ -0,0 +1,108 @@ +# Phase 2 — 액션 아이템 추출/체크리스트 + Google Docs 호환 복사 + +ROADMAP Phase 2 (#8~#11) + 사용자 추가 요청. + +## Base +- 🧱 **Base**: `feat/web-api-key-settings` (PR #3 위에 스택) + +## 설계 결정 — 휴리스틱 vs Gemini JSON + +ROADMAP은 Gemini JSON 출력 + zod 검증을 명시했지만 **마크다운 체크박스 휴리스틱**으로 갈음: + +| 방식 | 장점 | 단점 | +|---|---|---| +| **휴리스틱 (채택)** | Gemini 호출 0, 모든 템플릿 호환, 즉시 동작 | assignee/dueDate 미추출 | +| Gemini JSON | 구조화된 출력, 정확한 메타 | 호출 추가, 프롬프트 재작성, zod 검증 | + +후속에 정확도 부족 체감 시 JSON 모드로 업그레이드 가능 (테이블 스키마는 이미 확장 가능). + +## 변경 + +### 신규 파일 +- `prisma/migrations/20260428071709_add_action_items/` +- `src/lib/action-items.ts` — `parseActionItems(markdown)` +- `src/app/api/action-items/[id]/route.ts` — PATCH/DELETE +- `src/app/api/meetings/[id]/reparse-action-items/route.ts` — POST +- `src/components/meeting/ActionItemList.tsx` — 체크리스트 UI +- `src/__tests__/action-items.test.ts` — 8 tests + +### 수정 +- `prisma/schema.prisma` — ActionItem 모델 +- `src/app/api/meetings/route.ts` — POST 시 자동 추출, GET에 미완료 카운트 +- `src/app/api/meetings/[id]/route.ts` — actionItems include +- `src/app/meetings/page.tsx` — 미완료 위젯 (최근 5) +- `src/app/meetings/[id]/page.tsx` — actionItems 전달 +- `src/components/meeting/MeetingCard.tsx` — 미완료 배지 +- `src/components/meeting/MeetingDetail.tsx` — ActionItemList 통합 + 📋 Docs용 버튼 +- `src/components/minutes/MinutesViewer.tsx` — 📋 Docs용 버튼 +- `src/lib/markdown.ts` — `copyMarkdownAsRichText()` 추가 + +## API 시그니처 + +```http +POST /api/meetings +{ title, markdownMinutes, ... } +→ 201 { ...meeting, actionItems: [{ id, task, isDone, position }] } + +PATCH /api/action-items/[id] +{ isDone?: boolean, task?: string } +→ 200 { ...item } + +DELETE /api/action-items/[id] +→ 204 + +POST /api/meetings/[id]/reparse-action-items +→ 200 { actionItems: [...], replaced: N } +``` + +## UX 흐름 + +1. 회의록 생성 → `📌 저장` → 마크다운의 `- [ ]` 자동 추출 +2. `/meetings/[id]` 상세 → 본문 아래 ✅ **액션 아이템** 섹션 +3. 체크박스 클릭 (낙관적 업데이트, 서버 PATCH) +4. 본문 편집 후 새 항목 추가 시 → `🔄 재추출` 버튼으로 동기화 + ⚠️ 재추출은 토글 상태를 모두 초기화 (confirm 표시) +5. `/meetings` 상단에서 "내 미완료 액션 (최근 5)" 한눈에 파악 + +## Google Docs 복사 (사용자 추가 요청) + +`copyMarkdownAsRichText(markdown)`: +- 1차: `navigator.clipboard.write([new ClipboardItem({ 'text/html', 'text/plain' })])` (모던 브라우저) +- 폴백: 임시 contenteditable + `document.execCommand('copy')` + +→ Google Docs / Word / Notion에 붙여넣으면 **헤딩/리스트/굵게/코드/표 서식 유지**. + +버튼 라벨링 정리: +- `📋 .md` — 마크다운 원문 복사 +- `📋 Docs용` — 서식 유지 복사 (파랑색 강조) +- `⬇ .md` / `⬇ .html` — 파일 다운로드 + +## 테스트 +- 102 tests passing (이전 94 + 8 action-items) + +## 확인 방법 + +```bash +# 마이그레이션 적용 (action_items 테이블 추가) +docker compose --profile tools run --rm migrate + +# 빌드 + 기동 +docker compose up -d --build app +``` + +체크 시나리오: +1. 회의 녹음 → 마크다운에 `- [ ] 항목` 자동 포함된 상태로 생성 +2. 📌 저장 → /meetings/[id] 이동 +3. 체크박스 토글 → 진행률 변화, 새로고침해도 유지 +4. 본문 편집 → `- [ ] 새 항목` 추가 → 저장 → 🔄 재추출 → 새 항목 등장 +5. 📋 Docs용 → Google Docs에 붙여넣어 서식 확인 +6. /meetings → 카드 배지 + 상단 위젯 노출 확인 + +## Breaking changes +- 마이그레이션 필요 (`action_items` 테이블) +- 기존 회의록은 액션 아이템이 비어있음 — 상세에서 🔄 재추출 한 번 누르면 채워짐 + +## 후속 (이 PR 범위 아님) +- Gemini JSON 출력 모드 (assignee/dueDate 정확 추출) +- 홈 페이지를 대시보드로 승격 +- 액션 아이템 직접 추가 (체크리스트 UI에서 +) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5372486..34bfca5 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -46,13 +46,32 @@ --- -## 🎯 Phase 1 — 회의 저장/조회 (1~2주) +## ✅ Phase 1.5 — 웹 기반 API 키 설정 (완료) -노션의 **Meeting Database** 수준. 생성한 회의록을 저장하고 다시 찾아볼 수 있게 함. +`.env`를 만지지 않고도 브라우저에서 Gemini 키를 설정할 수 있도록 함. + +### 구현됨 +- [x] `/settings` 페이지 — 키 입력 / 저장 / 삭제 / 마스킹된 현재 키 표시 +- [x] LocalStorage 저장 (서버 DB에 보관 X — 개인용/단일 인스턴스 가정) +- [x] `/api/settings/status` — 환경변수 키 존재 여부 (값 노출 X) +- [x] `/api/settings/test` — 키로 가벼운 Gemini 호출 시도 → 200/401/429 등 분류 +- [x] `resolveGeminiApiKey()` 우선순위: 요청 body 키 → 환경변수 → null +- [x] summarize / summarize-live 라우트가 body의 `apiKey` 사용 +- [x] 단위 테스트 14개 (api-keys 7 + api-key-storage 7) + +### 의도된 비목표 +- 다중 사용자 / 권한 관리 — 개인용 단일 인스턴스 +- 키 암호화 — 평문 LocalStorage (현재 .env 신뢰 모델과 동일) + +--- + +## ✅ Phase 1 — 회의 저장/조회 (완료) + +노션의 **Meeting Database** 수준. 생성한 회의록을 저장하고 다시 찾아볼 수 있음. ### Issues - [x] **#1 Prisma 연결 + 마이그레이션 파이프라인** — PR `feat/phase-1-foundation` - - [x] `src/lib/db.ts` 싱글톤 Prisma 클라이언트 + - [x] `src/lib/db.ts` 싱글톤 Prisma 클라이언트 (Prisma 7 driver adapter 경유) - [x] 첫 마이그레이션 생성 (`prisma migrate dev`) - [x] docker-compose `tools` 프로필에 `migrate` 서비스 추가 - [x] Meeting 스키마 확장 (attendees/tags/template/depth/summaryMode/customPrompt) @@ -62,44 +81,52 @@ - [x] `GET /api/meetings/[id]` — 상세 - [x] `PUT /api/meetings/[id]` — markdown/title/attendees/tags 편집 - [x] `DELETE /api/meetings/[id]` -- [ ] **#3 MinutesViewer에 "저장" 버튼** - - 생성된 회의록 현재 세션에서 저장 → 토스트 알림 - - 저장 성공 시 `/meetings/[id]` 링크 제공 -- [ ] **#4 회의 목록 페이지** (`/meetings`) - - 카드 그리드 (제목, 날짜, 앞 2줄 미리보기, 태그) - - 빈 상태 UI ("첫 회의를 녹음해보세요") -- [ ] **#5 회의 상세 페이지** (`/meetings/[id]`) - - Transcript + Markdown 렌더링 - - 인라인 편집 (markdown editor 도입 — 아래 #6) - - 삭제 버튼 -- [ ] **#6 Markdown 렌더링 라이브러리 도입** - - `marked` + `isomorphic-dompurify` - - 기존 커스텀 파서(`export-minutes.ts`) 교체 — 표/리스트/인라인 스타일 정상 렌더 -- [ ] **#7 전역 검색** - - Postgres `tsvector` 전체 텍스트 인덱스 (transcript + markdown) - - `/api/meetings?q=...` 엔드포인트 - - 헤더에 검색 바 추가 +- [x] **#3 MinutesViewer에 "저장" 버튼** — PR `feat/phase-1-ui` + - [x] 생성된 회의록 세션에서 저장 → 배너 + 상세 링크 + - [x] 미리보기 / 원문 토글 +- [x] **#4 회의 목록 페이지** (`/meetings`) — PR `feat/phase-1-ui` + - [x] 카드 그리드 (제목, 날짜, preview, 태그, 참석자, 템플릿 배지) + - [x] 빈 상태 UI + - [x] 페이지네이션 +- [x] **#5 회의 상세 페이지** (`/meetings/[id]`) — PR `feat/phase-1-ui` + - [x] Markdown 렌더링 (sanitize) + - [x] 인라인 편집 (제목/본문/참석자/태그) + 좌우 미리보기 + - [x] 삭제 버튼 +- [x] **#6 Markdown 렌더링 라이브러리 도입** — PR `feat/phase-1-ui` + - [x] `marked` + `isomorphic-dompurify` + - [x] `src/lib/markdown.ts` 공용 유틸 + - [x] 기존 커스텀 파서 교체, `.prose-minutes` 공통 스타일 +- [x] **#7 전역 검색** — PR `feat/phase-1-ui` + - [x] 검색 바 (debounce 300ms, URL query 동기화) + - [x] `/api/meetings?q=...` 부분 검색 (title/transcript/markdown) + - [ ] Postgres `tsvector` GIN 인덱스 — 현재 ILIKE로 개인용 규모 충분, 필요 시 후속 --- -## 🎯 Phase 2 — 구조화된 액션 아이템 (1주) +## ✅ Phase 2 — 구조화된 액션 아이템 (완료) -회의가 끝나면 **"내 할 일"이 자동으로 추출되어 체크리스트로 남음**. 노션 AI의 핵심 가치. +회의가 끝나면 **"내 할 일"이 자동으로 추출되어 체크리스트로 남음**. -### Issues -- [ ] **#8 Gemini 프롬프트 개선 — JSON 출력** - - `generateGeminiMinutes`가 마크다운 대신 `{ summary, discussion, actionItems: [{ assignee, due, task }], decisions }` 반환 - - JSON schema validation (zod) -- [ ] **#9 ActionItem 테이블 + 관계** - - `Meeting` ↔ `ActionItem` (1:N) - - 필드: `id`, `meetingId`, `assignee`, `task`, `dueDate`, `isDone`, `createdAt` -- [ ] **#10 체크리스트 UI** - - 회의 상세 페이지에 별도 섹션 - - 체크박스 토글 → `PATCH /api/action-items/[id]` -- [ ] **#11 대시보드 페이지** (`/`) - - "내 미완료 액션 아이템" 리스트 - - 최근 회의 3건 미리보기 - - 홈으로 승격, 녹음 UI는 `/record`로 이동 +### 구현됨 +- [x] **#8 액션 아이템 추출** — Gemini JSON 재구성 대신 마크다운 휴리스틱 채택 + (모든 템플릿이 이미 `- [ ]` 출력 → 추가 호출 비용 0, 후속에 JSON 모드 가능) +- [x] **#9 ActionItem 테이블** — `Meeting` ↔ `ActionItem` 1:N (cascade delete) + - 필드: `id`, `meetingId`, `task`, `isDone`, `position`, `createdAt`, `updatedAt` + - assignee/dueDate는 추후 JSON 출력 모드 도입 시 추가 +- [x] **#10 체크리스트 UI** — 상세 페이지 별도 섹션 + - 체크박스 토글 (낙관적 업데이트) → `PATCH /api/action-items/[id]` + - 개별 삭제 (hover 시 ✕ 노출) + - 🔄 재추출 버튼 (마크다운 변경 후 수동 동기화) +- [x] **#11 대시보드 위젯** — `/meetings` 상단에 "내 미완료 액션 (최근 5)" + - 회의 카드 우상단에 미완료 카운트 배지 + - 홈 페이지 자체 승격은 보류 (현재 녹음 UI 유지) +- [x] **부가** — Google Docs 호환 서식 복사 (`text/html` + `text/plain` ClipboardItem) + - MinutesViewer / MeetingDetail 둘 다 `📋 Docs용` 버튼 + +### 비목표 (이번 단계) +- Gemini JSON 출력 + zod 검증 — 정확도 부족 시 후속 도입 +- assignee/dueDate 필드 — JSON 출력 도입과 함께 +- 홈 페이지 대시보드 승격 — 녹음 흐름 보존이 우선 --- diff --git a/package-lock.json b/package-lock.json index faa53e5..a197a76 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,12 @@ "name": "meeting-minutes", "version": "0.1.0", "dependencies": { + "@prisma/adapter-pg": "^7.7.0", "@prisma/client": "^7.7.0", + "isomorphic-dompurify": "^2.29.0", + "marked": "^16.4.0", "next": "16.2.3", + "pg": "^8.13.1", "prisma": "^7.7.0", "react": "19.2.4", "react-dom": "19.2.4" @@ -19,6 +23,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@types/node": "^20", + "@types/pg": "^8.11.10", "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^6.0.1", @@ -31,6 +36,12 @@ "vitest": "^4.1.4" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "license": "MIT" + }, "node_modules/@adobe/css-tools": { "version": "4.4.4", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", @@ -55,7 +66,6 @@ "version": "5.1.9", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.9.tgz", "integrity": "sha512-zd9c/Wdso6v1U7v6w3i/hbAr4K7NaSHImdpvmLt+Y9ea5BhilnIGNkfhOJ7FEIuPipAnE9tZeDOll05WDT0kgg==", - "dev": true, "license": "MIT", "dependencies": { "@csstools/css-calc": "^3.1.1", @@ -87,7 +97,6 @@ "version": "2.3.9", "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true, "license": "MIT" }, "node_modules/@babel/code-frame": { @@ -354,7 +363,6 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", - "dev": true, "license": "MIT", "dependencies": { "css-tree": "^3.0.0" @@ -367,7 +375,6 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", - "dev": true, "funding": [ { "type": "github", @@ -387,7 +394,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", - "dev": true, "funding": [ { "type": "github", @@ -411,7 +417,6 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", - "dev": true, "funding": [ { "type": "github", @@ -439,7 +444,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", - "dev": true, "funding": [ { "type": "github", @@ -462,7 +466,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.2.tgz", "integrity": "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==", - "dev": true, "funding": [ { "type": "github", @@ -487,7 +490,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", - "dev": true, "funding": [ { "type": "github", @@ -711,7 +713,6 @@ "version": "1.15.0", "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", - "dev": true, "license": "MIT", "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" @@ -1526,6 +1527,18 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@prisma/adapter-pg": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.7.0.tgz", + "integrity": "sha512-q33Ta8sKbgzEpAy0lx45tAq//yMv0qcb+8nj+TCA3P4wiAY+OBFEFk/NDkZncAfHaNJeGo5WJpJdpbL+ijYx8g==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/driver-adapter-utils": "7.7.0", + "@types/pg": "^8.16.0", + "pg": "^8.16.3", + "postgres-array": "3.0.4" + } + }, "node_modules/@prisma/client": { "version": "7.7.0", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.7.0.tgz", @@ -1605,6 +1618,15 @@ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", "license": "MIT" }, + "node_modules/@prisma/driver-adapter-utils": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.7.0.tgz", + "integrity": "sha512-gZXREeu6mOk7zXfGFJgh86p7Vhj0sXNKp+4Cg1tWYo7V2dfncP2qxS2BiTmbIIha8xPqItkl0WSw38RuSq1HoQ==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.7.0" + } + }, "node_modules/@prisma/engines": { "version": "7.7.0", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.7.0.tgz", @@ -2398,6 +2420,70 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.8.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.8.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", @@ -2595,12 +2681,22 @@ "version": "20.19.39", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz", "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -2620,6 +2716,13 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.58.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz", @@ -3389,6 +3492,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -3730,7 +3842,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "dev": true, "license": "MIT", "dependencies": { "require-from-string": "^2.0.2" @@ -4038,7 +4149,6 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", - "dev": true, "license": "MIT", "dependencies": { "mdn-data": "2.27.1", @@ -4055,6 +4165,30 @@ "dev": true, "license": "MIT" }, + "node_modules/cssstyle": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz", + "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -4072,7 +4206,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", - "dev": true, "license": "MIT", "dependencies": { "whatwg-mimetype": "^5.0.0", @@ -4140,7 +4273,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -4158,7 +4290,6 @@ "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, "license": "MIT" }, "node_modules/deep-is": { @@ -4276,6 +4407,15 @@ "license": "MIT", "peer": true }, + "node_modules/dompurify": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.0.tgz", + "integrity": "sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -4354,7 +4494,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -5584,7 +5723,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", - "dev": true, "license": "MIT", "dependencies": { "@exodus/bytes": "^1.6.0" @@ -5600,12 +5738,38 @@ "dev": true, "license": "MIT" }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/http-status-codes": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", "license": "MIT" }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -5958,7 +6122,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, "license": "MIT" }, "node_modules/is-property": { @@ -6125,6 +6288,81 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/isomorphic-dompurify": { + "version": "2.36.0", + "resolved": "https://registry.npmjs.org/isomorphic-dompurify/-/isomorphic-dompurify-2.36.0.tgz", + "integrity": "sha512-E8YkGyPY3a/U5s0WOoc8Ok+3SWL/33yn2IHCoxCFLBUUPVy9WGa++akJZFxQCcJIhI+UvYhbrbnTIFQkHKZbgA==", + "license": "MIT", + "dependencies": { + "dompurify": "^3.3.1", + "jsdom": "^28.0.0" + }, + "engines": { + "node": ">=20.19.5" + } + }, + "node_modules/isomorphic-dompurify/node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/isomorphic-dompurify/node_modules/jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.21.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/isomorphic-dompurify/node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -6760,6 +6998,18 @@ "node": ">=10" } }, + "node_modules/marked": { + "version": "16.4.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz", + "integrity": "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -6774,7 +7024,6 @@ "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "dev": true, "license": "CC0-1.0" }, "node_modules/merge2": { @@ -6838,7 +7087,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mysql2": { @@ -7275,7 +7523,6 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", - "dev": true, "license": "MIT", "dependencies": { "entities": "^6.0.0" @@ -7322,6 +7569,104 @@ "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", "license": "MIT" }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-types/node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -7404,6 +7749,45 @@ "url": "https://github.com/sponsors/porsager" } }, + "node_modules/postgres-array": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", + "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -7518,7 +7902,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -7882,7 +8265,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, "license": "ISC", "dependencies": { "xmlchars": "^2.2.0" @@ -8144,6 +8526,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sqlstring": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", @@ -8390,7 +8781,6 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, "license": "MIT" }, "node_modules/tailwindcss": { @@ -8492,7 +8882,6 @@ "version": "7.0.28", "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.28.tgz", "integrity": "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==", - "dev": true, "license": "MIT", "dependencies": { "tldts-core": "^7.0.28" @@ -8505,7 +8894,6 @@ "version": "7.0.28", "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.28.tgz", "integrity": "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==", - "dev": true, "license": "MIT" }, "node_modules/to-regex-range": { @@ -8525,7 +8913,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", - "dev": true, "license": "BSD-3-Clause", "dependencies": { "tldts": "^7.0.5" @@ -8538,7 +8925,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", - "dev": true, "license": "MIT", "dependencies": { "punycode": "^2.3.1" @@ -8744,7 +9130,6 @@ "version": "7.24.7", "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.7.tgz", "integrity": "sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=20.18.1" @@ -8754,7 +9139,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unrs-resolver": { @@ -9045,7 +9429,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "dev": true, "license": "MIT", "dependencies": { "xml-name-validator": "^5.0.0" @@ -9058,7 +9441,6 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=20" @@ -9068,7 +9450,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", - "dev": true, "license": "MIT", "engines": { "node": ">=20" @@ -9078,7 +9459,6 @@ "version": "16.0.1", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", - "dev": true, "license": "MIT", "dependencies": { "@exodus/bytes": "^1.11.0", @@ -9224,7 +9604,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=18" @@ -9234,9 +9613,17 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, "license": "MIT" }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index 96f9089..67b96f8 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,12 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { + "@prisma/adapter-pg": "^7.7.0", "@prisma/client": "^7.7.0", + "isomorphic-dompurify": "^2.29.0", + "marked": "^16.4.0", "next": "16.2.3", + "pg": "^8.13.1", "prisma": "^7.7.0", "react": "19.2.4", "react-dom": "19.2.4" @@ -23,6 +27,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@types/node": "^20", + "@types/pg": "^8.11.10", "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^6.0.1", diff --git a/prisma/migrations/20260428071709_add_action_items/migration.sql b/prisma/migrations/20260428071709_add_action_items/migration.sql new file mode 100644 index 0000000..981f175 --- /dev/null +++ b/prisma/migrations/20260428071709_add_action_items/migration.sql @@ -0,0 +1,21 @@ +-- CreateTable +CREATE TABLE "action_items" ( + "id" TEXT NOT NULL, + "meetingId" TEXT NOT NULL, + "task" TEXT NOT NULL, + "isDone" BOOLEAN NOT NULL DEFAULT false, + "position" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "action_items_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "action_items_meetingId_position_idx" ON "action_items"("meetingId", "position"); + +-- CreateIndex +CREATE INDEX "action_items_isDone_idx" ON "action_items"("isDone"); + +-- AddForeignKey +ALTER TABLE "action_items" ADD CONSTRAINT "action_items_meetingId_fkey" FOREIGN KEY ("meetingId") REFERENCES "meetings"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5f8baf9..445cd8c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1,6 +1,5 @@ generator client { - provider = "prisma-client" - output = "../src/generated/prisma" + provider = "prisma-client-js" } datasource db { @@ -31,10 +30,29 @@ model Meeting { status MeetingStatus @default(COMPLETED) + actionItems ActionItem[] + @@index([createdAt]) @@map("meetings") } +model ActionItem { + id String @id @default(cuid()) + meetingId String + meeting Meeting @relation(fields: [meetingId], references: [id], onDelete: Cascade) + + task String + isDone Boolean @default(false) + position Int @default(0) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([meetingId, position]) + @@index([isDone]) + @@map("action_items") +} + enum MeetingStatus { CREATED UPLOADING diff --git a/src/__tests__/action-items.test.ts b/src/__tests__/action-items.test.ts new file mode 100644 index 0000000..fea706a --- /dev/null +++ b/src/__tests__/action-items.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from 'vitest' +import { parseActionItems } from '@/lib/action-items' + +describe('parseActionItems', () => { + it('마크다운에서 - [ ] / - [x] 항목을 추출한다', () => { + const md = `## 액션 아이템 +- [ ] QA 일정 확정 +- [x] PR 머지 +- [ ] 다음 주 발표 자료 준비` + const result = parseActionItems(md) + expect(result).toHaveLength(3) + expect(result[0]).toEqual({ task: 'QA 일정 확정', isDone: false }) + expect(result[1]).toEqual({ task: 'PR 머지', isDone: true }) + expect(result[2]).toEqual({ + task: '다음 주 발표 자료 준비', + isDone: false, + }) + }) + + it('대문자 X 도 done으로 인식', () => { + const result = parseActionItems('- [X] 완료') + expect(result).toEqual([{ task: '완료', isDone: true }]) + }) + + it('* 불릿도 인식한다', () => { + const result = parseActionItems('* [ ] 별표 항목') + expect(result).toEqual([{ task: '별표 항목', isDone: false }]) + }) + + it('체크박스 없는 일반 불릿은 무시한다', () => { + const md = `- 그냥 불릿 +- [ ] 액션 아이템 +- 또 일반 불릿` + const result = parseActionItems(md) + expect(result).toHaveLength(1) + expect(result[0].task).toBe('액션 아이템') + }) + + it('빈 task / 매우 짧은 task / 점만 있는 task 는 제외', () => { + const md = `- [ ] +- [ ] a +- [ ] ... +- [ ] 정상 항목` + const result = parseActionItems(md) + expect(result).toHaveLength(1) + expect(result[0].task).toBe('정상 항목') + }) + + it('섹션과 무관하게 모든 체크박스를 수집', () => { + const md = `## 요약 +이것저것 + +## 회의 내용 +- [ ] 본문에 있는 항목 + +## 다른 섹션 +- [x] 다른 섹션의 항목` + const result = parseActionItems(md) + expect(result).toHaveLength(2) + }) + + it('빈 문자열은 빈 배열', () => { + expect(parseActionItems('')).toEqual([]) + expect(parseActionItems('# 제목만 있음')).toEqual([]) + }) + + it('들여쓰기된 체크박스도 인식', () => { + const result = parseActionItems(' - [ ] 들여쓰기') + expect(result).toEqual([{ task: '들여쓰기', isDone: false }]) + }) +}) diff --git a/src/__tests__/api-key-storage.test.ts b/src/__tests__/api-key-storage.test.ts new file mode 100644 index 0000000..48fac54 --- /dev/null +++ b/src/__tests__/api-key-storage.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { + getStoredApiKey, + setStoredApiKey, + clearStoredApiKey, + maskApiKey, +} from '@/lib/api-key-storage' + +describe('LocalStorage api key helpers', () => { + beforeEach(() => { + window.localStorage.clear() + }) + + it('저장 후 동일 키를 반환한다', () => { + setStoredApiKey('AIza-test-key') + expect(getStoredApiKey()).toBe('AIza-test-key') + }) + + it('미설정 시 null 반환', () => { + expect(getStoredApiKey()).toBeNull() + }) + + it('빈 문자열 저장 시 null로 취급', () => { + setStoredApiKey('') + expect(getStoredApiKey()).toBeNull() + }) + + it('clear 시 삭제된다', () => { + setStoredApiKey('AIza-test-key') + clearStoredApiKey() + expect(getStoredApiKey()).toBeNull() + }) +}) + +describe('maskApiKey', () => { + it('긴 키는 앞 4자 + ••• + 뒤 4자', () => { + expect(maskApiKey('AIzaSyABCDEFGHIJKLMNOPQRSTUVWXYZ1234')).toBe( + 'AIza••••1234', + ) + }) + + it('8자 이하는 전부 마스킹', () => { + expect(maskApiKey('short')).toBe('••••') + expect(maskApiKey('12345678')).toBe('••••') + }) + + it('정확히 9자는 마스킹 적용', () => { + expect(maskApiKey('123456789')).toBe('1234••••6789') + }) +}) diff --git a/src/__tests__/api-keys.test.ts b/src/__tests__/api-keys.test.ts new file mode 100644 index 0000000..f0e19fa --- /dev/null +++ b/src/__tests__/api-keys.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { resolveGeminiApiKey, isEnvKeyConfigured } from '@/lib/api-keys' + +describe('resolveGeminiApiKey', () => { + const originalEnv = process.env.GEMINI_API_KEY + + beforeEach(() => { + delete process.env.GEMINI_API_KEY + }) + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.GEMINI_API_KEY + } else { + process.env.GEMINI_API_KEY = originalEnv + } + }) + + it('요청 키가 우선이다', () => { + process.env.GEMINI_API_KEY = 'env-key' + expect(resolveGeminiApiKey('request-key')).toBe('request-key') + }) + + it('요청 키가 없으면 환경변수 키로 폴백한다', () => { + process.env.GEMINI_API_KEY = 'env-key' + expect(resolveGeminiApiKey()).toBe('env-key') + expect(resolveGeminiApiKey(null)).toBe('env-key') + expect(resolveGeminiApiKey(undefined)).toBe('env-key') + expect(resolveGeminiApiKey('')).toBe('env-key') + expect(resolveGeminiApiKey(' ')).toBe('env-key') + }) + + it('둘 다 비어있으면 null', () => { + expect(resolveGeminiApiKey()).toBeNull() + expect(resolveGeminiApiKey('')).toBeNull() + }) + + it('요청 키 앞뒤 공백을 제거한다', () => { + expect(resolveGeminiApiKey(' abc ')).toBe('abc') + }) + + it('환경변수 키 앞뒤 공백을 제거한다', () => { + process.env.GEMINI_API_KEY = ' env-key ' + expect(resolveGeminiApiKey()).toBe('env-key') + }) +}) + +describe('isEnvKeyConfigured', () => { + const originalEnv = process.env.GEMINI_API_KEY + + afterEach(() => { + if (originalEnv === undefined) { + delete process.env.GEMINI_API_KEY + } else { + process.env.GEMINI_API_KEY = originalEnv + } + }) + + it('환경변수가 설정되어 있으면 true', () => { + process.env.GEMINI_API_KEY = 'AIza...' + expect(isEnvKeyConfigured()).toBe(true) + }) + + it('비어있거나 공백만 있으면 false', () => { + delete process.env.GEMINI_API_KEY + expect(isEnvKeyConfigured()).toBe(false) + process.env.GEMINI_API_KEY = '' + expect(isEnvKeyConfigured()).toBe(false) + process.env.GEMINI_API_KEY = ' ' + expect(isEnvKeyConfigured()).toBe(false) + }) +}) diff --git a/src/__tests__/markdown.test.ts b/src/__tests__/markdown.test.ts new file mode 100644 index 0000000..fbaf848 --- /dev/null +++ b/src/__tests__/markdown.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest' +import { + renderMarkdownToSafeHtml, + markdownToPlainText, +} from '@/lib/markdown' + +describe('renderMarkdownToSafeHtml', () => { + it('헤딩/리스트/굵은 글씨를 HTML로 변환한다', () => { + const html = renderMarkdownToSafeHtml(`# 제목 +- 항목1 +- **중요** 항목2`) + expect(html).toContain('

제목

') + expect(html).toContain('