mirror of
https://github.com/nad4-su/meeting-minutes.git
synced 2026-08-12 22:33:25 +09:00
Phase 1 foundation: Prisma + /api/meetings CRUD (#1)
* 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 본문 드래프트 추가
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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/<id>
|
||||
```
|
||||
|
||||
## 이 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`로 초기화 필요.
|
||||
+11
-11
@@ -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]` 링크 제공
|
||||
|
||||
@@ -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");
|
||||
@@ -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"
|
||||
+10
-1
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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<typeof listGet>[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)
|
||||
})
|
||||
})
|
||||
@@ -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<string, unknown> = {}
|
||||
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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user