From cf6613bb946c76d18e377ee0081b40383506ca67 Mon Sep 17 00:00:00 2001 From: "N@D4" Date: Tue, 28 Apr 2026 16:38:09 +0900 Subject: [PATCH] Phase 1 foundation: Prisma + /api/meetings CRUD (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(phase-1): Prisma 연동 + 회의록 CRUD API Phase 1 foundation (ROADMAP #1~#3): - src/lib/db.ts: Prisma 클라이언트 싱글톤 (dev hot-reload 대응) - Meeting 스키마 확장: attendees/tags (String[]), template/depth/ summaryMode/customPrompt (향후 참조 복원용), createdAt 인덱스 - 첫 마이그레이션 `20260421104750_init` 생성 - /api/meetings (GET 목록 + POST 생성) - q 파라미터로 제목/transcript/markdown 부분 검색 - limit (max 100), offset 페이지네이션 - attendees/tags 배열 타입 정규화 - /api/meetings/[id] (GET/PUT/DELETE) - PUT은 markdownMinutes/title/attendees/tags만 편집 허용 - 존재하지 않는 id에 대해 404 - docker-compose.yml: tools 프로필에 migrate 서비스 추가 (app 컨테이너 bloat 없이 마이그레이션 실행) - Prisma mock 기반 API 테스트 14개 신규 (총 71 tests) * docs: migrate 실행 단계 추가, ROADMAP의 #1/#2 완료 체크 * docs: PR 본문 드래프트 추가 --- README.md | 14 +- docker-compose.yml | 15 ++ docs/PR_DRAFT_PHASE_1_FOUNDATION.md | 79 ++++++ docs/ROADMAP.md | 22 +- .../20260421104750_init/migration.sql | 28 +++ prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 11 +- src/__tests__/meetings-api.test.ts | 230 ++++++++++++++++++ src/app/api/meetings/[id]/route.ts | 97 ++++++++ src/app/api/meetings/route.ts | 119 +++++++++ src/lib/db.ts | 12 + 11 files changed, 615 insertions(+), 15 deletions(-) create mode 100644 docs/PR_DRAFT_PHASE_1_FOUNDATION.md create mode 100644 prisma/migrations/20260421104750_init/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 src/__tests__/meetings-api.test.ts create mode 100644 src/app/api/meetings/[id]/route.ts create mode 100644 src/app/api/meetings/route.ts create mode 100644 src/lib/db.ts diff --git a/README.md b/README.md index a31c8af..294a02c 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ | STT (실시간) | Web Speech API — Chrome 내장, 무료 | | AI 요약 | Gemini 2.5 Flash Lite — 무료 등급 15 RPM / 1000 RPD | | DB | PostgreSQL 16 + Prisma 7 (스키마 준비, Phase 1에서 활용 예정) | -| 테스트 | Vitest (57 tests, jsdom) | +| 테스트 | Vitest (71 tests, jsdom) | | 배포 | Docker Compose (app + db + test profile) | --- @@ -75,7 +75,15 @@ cp .env.example .env `.env`에서 **`GEMINI_API_KEY`** 설정 ([Google AI Studio](https://aistudio.google.com/apikey)에서 무료 발급). > 키가 없어도 "단순 변환" 모드는 정상 동작. -### 2. 기동 +### 2. DB 마이그레이션 (최초 1회 + 스키마 변경 시) + +```bash +docker compose --profile tools run --rm migrate +``` + +이 명령은 `db` 컨테이너를 자동 기동하고 `prisma migrate deploy`로 테이블을 생성합니다. + +### 3. 기동 ```bash docker compose up -d --build @@ -148,7 +156,7 @@ src/ │ ├── upload/AudioUploader.tsx │ ├── recorder/LiveRecorder.tsx # 좌우 분할 뷰 │ └── minutes/MinutesViewer.tsx -└── __tests__/ # 57 tests +└── __tests__/ # 71 tests docs/ ├── ROADMAP.md # 개발 로드맵 (Phase 0~4) diff --git a/docker-compose.yml b/docker-compose.yml index 8d206da..8725c4c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,20 @@ services: timeout: 5s retries: 5 + migrate: + profiles: ["tools"] + image: node:22-alpine + working_dir: /app + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-meetinguser}:${POSTGRES_PASSWORD:-meetingpass}@db:5432/${POSTGRES_DB:-meetingminutes} + volumes: + - .:/app + - migrate_node_modules:/app/node_modules + depends_on: + db: + condition: service_healthy + command: sh -c "npm ci && npx prisma migrate deploy" + app: build: context: . @@ -47,3 +61,4 @@ volumes: pgdata: uploads: test_node_modules: + migrate_node_modules: diff --git a/docs/PR_DRAFT_PHASE_1_FOUNDATION.md b/docs/PR_DRAFT_PHASE_1_FOUNDATION.md new file mode 100644 index 0000000..ede6a68 --- /dev/null +++ b/docs/PR_DRAFT_PHASE_1_FOUNDATION.md @@ -0,0 +1,79 @@ +# Phase 1 Foundation — Prisma 연동 + 회의록 CRUD API + +ROADMAP Phase 1의 #1 (Prisma 셋업), #2 (회의 저장 API) 완료. + +## 포함된 변경 + +### 🔧 인프라 +- `src/lib/db.ts` — Prisma 클라이언트 싱글톤 (dev HMR 대응) +- `prisma/schema.prisma` — Meeting 모델 확장 + - `attendees`, `tags`: `String[]` (Phase 3에서 UI 노출 예정) + - `template`, `depth`, `summaryMode`, `customPrompt`: 생성 당시 설정 보존 (재생성/디버깅용) + - `createdAt` 인덱스 (목록 쿼리 최적화) +- 첫 마이그레이션 `20260421104750_init` 생성·커밋 +- `docker-compose.yml` — `tools` 프로필에 `migrate` 서비스 추가 + (app 이미지에 prisma CLI를 포함시키지 않고 독립 실행) + +### 🔌 API +- `GET /api/meetings` + - 쿼리 파라미터: `q` (제목/transcript/markdown 부분 검색), `limit` (default 20, max 100), `offset` + - 응답: `{ meetings, total, limit, offset }` (카드 렌더용 필드만 select) +- `POST /api/meetings` + - 필수: `title`, `markdownMinutes` + - 선택: `rawTranscript`, `summaryMode`, `template`, `depth`, `customPrompt`, `attendees[]`, `tags[]` + - 배열 타입 외 값은 빈 배열로 정규화 +- `GET /api/meetings/[id]` — 상세, 404 +- `PUT /api/meetings/[id]` — `title`/`markdownMinutes`/`attendees`/`tags`만 편집 허용 +- `DELETE /api/meetings/[id]` — 204 / 404 + +### ✅ 테스트 +- `src/__tests__/meetings-api.test.ts` — Prisma mock 기반 14개 케이스 추가 +- 전체: **71 tests passing** (이전 57 + 14) + +### 📝 문서 +- `README.md` — "DB 마이그레이션 실행" 단계 추가 +- `docs/ROADMAP.md` — Phase 1 #1/#2 체크박스 완료 표시 + +## 확인 방법 + +```bash +# 1. DB 기동 +docker compose up -d db + +# 2. 마이그레이션 적용 (최초 1회) +docker compose --profile tools run --rm migrate + +# 3. 테스트 +docker compose --profile tools run --rm test + +# 4. 앱 기동 +docker compose up -d --build app +``` + +`curl`로 API 스모크 테스트: +```bash +# 빈 목록 +curl http://localhost:3000/api/meetings + +# 생성 +curl -X POST http://localhost:3000/api/meetings \ + -H 'Content-Type: application/json' \ + -d '{"title":"테스트","markdownMinutes":"# 내용"}' + +# 조회 / 수정 / 삭제 +curl http://localhost:3000/api/meetings/ +``` + +## 이 PR에 없는 것 (다음 PR 예정) + +- [ ] #3 `marked` + `DOMPurify` 도입 (기존 커스텀 markdown 파서 교체) +- [ ] #4 MinutesViewer에 "저장" 버튼 — 생성 결과를 DB에 저장하는 UI +- [ ] #5 `/meetings` 목록 페이지 +- [ ] #6 `/meetings/[id]` 상세 + 인라인 편집 +- [ ] #7 전역 검색 (tsvector 기반, 지금은 단순 `contains`) + +## Breaking changes +없음. 기존 녹음/요약 흐름은 그대로 동작. + +## 마이그레이션 주의 +첫 실행 시 `meetings` 테이블이 생성됨. 기존 개발 DB가 있다면 `prisma migrate resolve` 또는 `docker compose down -v`로 초기화 필요. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 73302e3..5372486 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -51,17 +51,17 @@ 노션의 **Meeting Database** 수준. 생성한 회의록을 저장하고 다시 찾아볼 수 있게 함. ### Issues -- [ ] **#1 Prisma 연결 + 마이그레이션 파이프라인** - - `src/lib/db.ts` 싱글톤 Prisma 클라이언트 - - 첫 마이그레이션 생성 (`prisma migrate dev`) - - Dockerfile에 `prisma migrate deploy` 추가 - - Meeting 스키마에 `attendees` (String[]), `tags` (String[]) 필드 추가 -- [ ] **#2 회의 저장 API** - - `POST /api/meetings` — transcript + markdown 저장 - - `GET /api/meetings` — 목록 조회 (최신순, 페이지네이션) - - `GET /api/meetings/[id]` — 상세 - - `PUT /api/meetings/[id]` — markdown 편집 저장 - - `DELETE /api/meetings/[id]` +- [x] **#1 Prisma 연결 + 마이그레이션 파이프라인** — PR `feat/phase-1-foundation` + - [x] `src/lib/db.ts` 싱글톤 Prisma 클라이언트 + - [x] 첫 마이그레이션 생성 (`prisma migrate dev`) + - [x] docker-compose `tools` 프로필에 `migrate` 서비스 추가 + - [x] Meeting 스키마 확장 (attendees/tags/template/depth/summaryMode/customPrompt) +- [x] **#2 회의 저장 API** — PR `feat/phase-1-foundation` + - [x] `POST /api/meetings` — transcript + markdown 저장 + - [x] `GET /api/meetings` — 목록 조회 (최신순, 페이지네이션, q 부분검색) + - [x] `GET /api/meetings/[id]` — 상세 + - [x] `PUT /api/meetings/[id]` — markdown/title/attendees/tags 편집 + - [x] `DELETE /api/meetings/[id]` - [ ] **#3 MinutesViewer에 "저장" 버튼** - 생성된 회의록 현재 세션에서 저장 → 토스트 알림 - 저장 성공 시 `/meetings/[id]` 링크 제공 diff --git a/prisma/migrations/20260421104750_init/migration.sql b/prisma/migrations/20260421104750_init/migration.sql new file mode 100644 index 0000000..8a33188 --- /dev/null +++ b/prisma/migrations/20260421104750_init/migration.sql @@ -0,0 +1,28 @@ +-- CreateEnum +CREATE TYPE "MeetingStatus" AS ENUM ('CREATED', 'UPLOADING', 'TRANSCRIBING', 'SUMMARIZING', 'COMPLETED', 'FAILED'); + +-- CreateTable +CREATE TABLE "meetings" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "audioFileName" TEXT, + "audioMimeType" TEXT, + "audioDuration" INTEGER, + "rawTranscript" TEXT, + "markdownMinutes" TEXT, + "geminiSummary" TEXT, + "summaryMode" TEXT, + "template" TEXT, + "depth" TEXT, + "customPrompt" TEXT, + "attendees" TEXT[], + "tags" TEXT[], + "status" "MeetingStatus" NOT NULL DEFAULT 'COMPLETED', + + CONSTRAINT "meetings_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "meetings_createdAt_idx" ON "meetings"("createdAt"); diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 10f8515..5f8baf9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -21,8 +21,17 @@ model Meeting { markdownMinutes String? geminiSummary String? - status MeetingStatus @default(CREATED) + summaryMode String? + template String? + depth String? + customPrompt String? + attendees String[] + tags String[] + + status MeetingStatus @default(COMPLETED) + + @@index([createdAt]) @@map("meetings") } diff --git a/src/__tests__/meetings-api.test.ts b/src/__tests__/meetings-api.test.ts new file mode 100644 index 0000000..68e7620 --- /dev/null +++ b/src/__tests__/meetings-api.test.ts @@ -0,0 +1,230 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' + +const findManyMock = vi.fn() +const countMock = vi.fn() +const createMock = vi.fn() +const findUniqueMock = vi.fn() +const updateMock = vi.fn() +const deleteMock = vi.fn() + +vi.mock('@/lib/db', () => ({ + prisma: { + meeting: { + findMany: findManyMock, + count: countMock, + create: createMock, + findUnique: findUniqueMock, + update: updateMock, + delete: deleteMock, + }, + }, +})) + +const { GET: listGet, POST: listPost } = await import( + '@/app/api/meetings/route' +) +const { + GET: detailGet, + PUT: detailPut, + DELETE: detailDelete, +} = await import('@/app/api/meetings/[id]/route') + +function makeRequest(url = 'http://localhost/api/meetings', init?: RequestInit) { + return new Request(url, init) as unknown as Parameters[0] +} + +beforeEach(() => { + findManyMock.mockReset() + countMock.mockReset() + createMock.mockReset() + findUniqueMock.mockReset() + updateMock.mockReset() + deleteMock.mockReset() +}) + +describe('GET /api/meetings (list)', () => { + it('기본 파라미터로 목록과 total을 반환한다', async () => { + findManyMock.mockResolvedValue([{ id: 'a', title: '회의 1' }]) + countMock.mockResolvedValue(1) + + const res = await listGet(makeRequest('http://localhost/api/meetings')) + const data = await res.json() + + expect(res.status).toBe(200) + expect(data.meetings).toHaveLength(1) + expect(data.total).toBe(1) + expect(data.limit).toBe(20) + expect(data.offset).toBe(0) + }) + + it('q 파라미터가 있으면 검색 조건이 적용된다', async () => { + findManyMock.mockResolvedValue([]) + countMock.mockResolvedValue(0) + + await listGet(makeRequest('http://localhost/api/meetings?q=sprint')) + + const call = findManyMock.mock.calls[0][0] + expect(call.where.OR).toBeDefined() + expect(call.where.OR[0].title.contains).toBe('sprint') + }) + + it('limit 상한(100)을 초과하면 잘린다', async () => { + findManyMock.mockResolvedValue([]) + countMock.mockResolvedValue(0) + + const res = await listGet( + makeRequest('http://localhost/api/meetings?limit=500'), + ) + const data = await res.json() + expect(data.limit).toBe(100) + }) +}) + +describe('POST /api/meetings (create)', () => { + it('유효한 입력으로 회의록을 생성한다', async () => { + createMock.mockResolvedValue({ id: 'new-id', title: '새 회의' }) + + const res = await listPost( + makeRequest('http://localhost/api/meetings', { + method: 'POST', + body: JSON.stringify({ + title: '새 회의', + markdownMinutes: '# 내용', + template: 'meeting', + depth: 'standard', + attendees: ['Alice'], + tags: ['sprint'], + }), + }), + ) + + const data = await res.json() + expect(res.status).toBe(201) + expect(data.id).toBe('new-id') + expect(createMock).toHaveBeenCalledOnce() + }) + + it('제목이 없으면 400을 반환한다', async () => { + const res = await listPost( + makeRequest('http://localhost/api/meetings', { + method: 'POST', + body: JSON.stringify({ markdownMinutes: '# 내용' }), + }), + ) + expect(res.status).toBe(400) + expect(createMock).not.toHaveBeenCalled() + }) + + it('본문이 없으면 400을 반환한다', async () => { + const res = await listPost( + makeRequest('http://localhost/api/meetings', { + method: 'POST', + body: JSON.stringify({ title: '제목만' }), + }), + ) + expect(res.status).toBe(400) + }) + + it('attendees/tags가 배열이 아니면 빈 배열로 정규화한다', async () => { + createMock.mockResolvedValue({ id: 'x' }) + + await listPost( + makeRequest('http://localhost/api/meetings', { + method: 'POST', + body: JSON.stringify({ + title: 'A', + markdownMinutes: '# B', + attendees: 'not-array', + tags: 42, + }), + }), + ) + + const call = createMock.mock.calls[0][0] + expect(call.data.attendees).toEqual([]) + expect(call.data.tags).toEqual([]) + }) +}) + +describe('GET /api/meetings/[id]', () => { + it('존재하면 200과 본문을 반환한다', async () => { + findUniqueMock.mockResolvedValue({ id: 'abc', title: '존재' }) + + const res = await detailGet( + makeRequest('http://localhost/api/meetings/abc'), + { params: Promise.resolve({ id: 'abc' }) }, + ) + expect(res.status).toBe(200) + const data = await res.json() + expect(data.id).toBe('abc') + }) + + it('없으면 404를 반환한다', async () => { + findUniqueMock.mockResolvedValue(null) + const res = await detailGet( + makeRequest('http://localhost/api/meetings/none'), + { params: Promise.resolve({ id: 'none' }) }, + ) + expect(res.status).toBe(404) + }) +}) + +describe('PUT /api/meetings/[id]', () => { + it('markdownMinutes만 수정할 수 있다', async () => { + updateMock.mockResolvedValue({ id: 'abc', markdownMinutes: '수정됨' }) + const res = await detailPut( + makeRequest('http://localhost/api/meetings/abc', { + method: 'PUT', + body: JSON.stringify({ markdownMinutes: '수정됨' }), + }), + { params: Promise.resolve({ id: 'abc' }) }, + ) + expect(res.status).toBe(200) + const call = updateMock.mock.calls[0][0] + expect(call.data).toEqual({ markdownMinutes: '수정됨' }) + }) + + it('수정할 필드가 없으면 400을 반환한다', async () => { + const res = await detailPut( + makeRequest('http://localhost/api/meetings/abc', { + method: 'PUT', + body: JSON.stringify({}), + }), + { params: Promise.resolve({ id: 'abc' }) }, + ) + expect(res.status).toBe(400) + expect(updateMock).not.toHaveBeenCalled() + }) + + it('없는 id면 404를 반환한다', async () => { + updateMock.mockRejectedValue(new Error('Record not found')) + const res = await detailPut( + makeRequest('http://localhost/api/meetings/none', { + method: 'PUT', + body: JSON.stringify({ title: '새 제목' }), + }), + { params: Promise.resolve({ id: 'none' }) }, + ) + expect(res.status).toBe(404) + }) +}) + +describe('DELETE /api/meetings/[id]', () => { + it('성공 시 204를 반환한다', async () => { + deleteMock.mockResolvedValue({ id: 'abc' }) + const res = await detailDelete( + makeRequest('http://localhost/api/meetings/abc', { method: 'DELETE' }), + { params: Promise.resolve({ id: 'abc' }) }, + ) + expect(res.status).toBe(204) + }) + + it('없으면 404를 반환한다', async () => { + deleteMock.mockRejectedValue(new Error('Record not found')) + const res = await detailDelete( + makeRequest('http://localhost/api/meetings/none', { method: 'DELETE' }), + { params: Promise.resolve({ id: 'none' }) }, + ) + expect(res.status).toBe(404) + }) +}) diff --git a/src/app/api/meetings/[id]/route.ts b/src/app/api/meetings/[id]/route.ts new file mode 100644 index 0000000..a434563 --- /dev/null +++ b/src/app/api/meetings/[id]/route.ts @@ -0,0 +1,97 @@ +import { NextRequest } from 'next/server' +import { prisma } from '@/lib/db' + +interface RouteContext { + params: Promise<{ id: string }> +} + +export async function GET(_request: NextRequest, context: RouteContext) { + try { + const { id } = await context.params + const meeting = await prisma.meeting.findUnique({ where: { id } }) + + if (!meeting) { + return Response.json( + { error: '회의록을 찾을 수 없습니다.' }, + { status: 404 }, + ) + } + + return Response.json(meeting) + } catch (err) { + const message = err instanceof Error ? err.message : '알 수 없는 오류' + return Response.json( + { error: `회의록 조회 실패: ${message}` }, + { status: 500 }, + ) + } +} + +export async function PUT(request: NextRequest, context: RouteContext) { + try { + const { id } = await context.params + const body = await request.json() + const { title, markdownMinutes, attendees, tags } = body + + const data: Record = {} + if (typeof title === 'string' && title.trim().length > 0) { + data.title = title.trim() + } + if (typeof markdownMinutes === 'string') { + data.markdownMinutes = markdownMinutes + } + if (Array.isArray(attendees)) { + data.attendees = attendees.filter((a) => typeof a === 'string') + } + if (Array.isArray(tags)) { + data.tags = tags.filter((t) => typeof t === 'string') + } + + if (Object.keys(data).length === 0) { + return Response.json( + { error: '수정할 내용이 없습니다.' }, + { status: 400 }, + ) + } + + try { + const meeting = await prisma.meeting.update({ + where: { id }, + data, + }) + return Response.json(meeting) + } catch { + return Response.json( + { error: '회의록을 찾을 수 없습니다.' }, + { status: 404 }, + ) + } + } catch (err) { + const message = err instanceof Error ? err.message : '알 수 없는 오류' + return Response.json( + { error: `회의록 수정 실패: ${message}` }, + { status: 500 }, + ) + } +} + +export async function DELETE(_request: NextRequest, context: RouteContext) { + try { + const { id } = await context.params + try { + await prisma.meeting.delete({ where: { id } }) + return new Response(null, { status: 204 }) + } catch { + return Response.json( + { error: '회의록을 찾을 수 없습니다.' }, + { status: 404 }, + ) + } + } catch (err) { + const message = err instanceof Error ? err.message : '알 수 없는 오류' + return Response.json( + { error: `회의록 삭제 실패: ${message}` }, + { status: 500 }, + ) + } +} diff --git a/src/app/api/meetings/route.ts b/src/app/api/meetings/route.ts new file mode 100644 index 0000000..b934cbc --- /dev/null +++ b/src/app/api/meetings/route.ts @@ -0,0 +1,119 @@ +import { NextRequest } from 'next/server' +import { prisma } from '@/lib/db' + +const DEFAULT_LIMIT = 20 +const MAX_LIMIT = 100 + +export async function GET(request: NextRequest) { + try { + const url = new URL(request.url) + const q = url.searchParams.get('q')?.trim() ?? '' + const limit = Math.min( + MAX_LIMIT, + Math.max(1, Number(url.searchParams.get('limit')) || DEFAULT_LIMIT), + ) + const offset = Math.max(0, Number(url.searchParams.get('offset')) || 0) + + const where = q + ? { + OR: [ + { title: { contains: q, mode: 'insensitive' as const } }, + { rawTranscript: { contains: q, mode: 'insensitive' as const } }, + { markdownMinutes: { contains: q, mode: 'insensitive' as const } }, + ], + } + : undefined + + const [meetings, total] = await Promise.all([ + prisma.meeting.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take: limit, + skip: offset, + select: { + id: true, + title: true, + createdAt: true, + updatedAt: true, + tags: true, + attendees: true, + template: true, + summaryMode: true, + status: true, + }, + }), + prisma.meeting.count({ where }), + ]) + + return Response.json({ + meetings, + total, + limit, + offset, + }) + } catch (err) { + const message = err instanceof Error ? err.message : '알 수 없는 오류' + return Response.json( + { error: `회의록 목록 조회 실패: ${message}` }, + { status: 500 }, + ) + } +} + +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const { + title, + rawTranscript, + markdownMinutes, + summaryMode, + template, + depth, + customPrompt, + attendees, + tags, + } = body + + if (!title || typeof title !== 'string' || title.trim().length === 0) { + return Response.json( + { error: '제목은 필수입니다.' }, + { status: 400 }, + ) + } + + if (!markdownMinutes || typeof markdownMinutes !== 'string') { + return Response.json( + { error: '회의록 본문은 필수입니다.' }, + { status: 400 }, + ) + } + + const meeting = await prisma.meeting.create({ + data: { + title: title.trim(), + rawTranscript: typeof rawTranscript === 'string' ? rawTranscript : null, + markdownMinutes, + summaryMode: typeof summaryMode === 'string' ? summaryMode : null, + template: typeof template === 'string' ? template : null, + depth: typeof depth === 'string' ? depth : null, + customPrompt: typeof customPrompt === 'string' ? customPrompt : null, + attendees: Array.isArray(attendees) + ? attendees.filter((a) => typeof a === 'string') + : [], + tags: Array.isArray(tags) + ? tags.filter((t) => typeof t === 'string') + : [], + status: 'COMPLETED', + }, + }) + + return Response.json(meeting, { status: 201 }) + } catch (err) { + const message = err instanceof Error ? err.message : '알 수 없는 오류' + return Response.json( + { error: `회의록 생성 실패: ${message}` }, + { status: 500 }, + ) + } +} diff --git a/src/lib/db.ts b/src/lib/db.ts new file mode 100644 index 0000000..6405e27 --- /dev/null +++ b/src/lib/db.ts @@ -0,0 +1,12 @@ +import { PrismaClient } from '@/generated/prisma/client' + +const globalForPrisma = globalThis as unknown as { + prisma?: PrismaClient +} + +export const prisma: PrismaClient = + globalForPrisma.prisma ?? new PrismaClient() + +if (process.env.NODE_ENV !== 'production') { + globalForPrisma.prisma = prisma +}