mirror of
https://github.com/nad4-su/meeting-minutes.git
synced 2026-10-11 15:50:13 +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:
11 files changed
+615
-15
No files matched your search
@@ -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