550 lines
24 KiB
Markdown
550 lines
24 KiB
Markdown
# InvoiceLink MVP PRD
|
||
|
||
> **버전**: 0.1.0 | **작성일**: 2026-03-14 | **작성자**: 1인 개발
|
||
|
||
---
|
||
|
||
## 1. 배경 및 문제 정의
|
||
|
||
### Pain Point
|
||
|
||
| # | 현재 방식 | 문제 |
|
||
|---|-----------|------|
|
||
| 1 | 이메일 첨부 (Word/HWP) | 파일 버전 불일치, 수정 후 재발송 필요, 모바일에서 열기 어려움 |
|
||
| 2 | 엑셀 공유 (Google Sheets 링크) | 브랜딩 부재, 인쇄 레이아웃 깨짐, 실수로 수정 가능 |
|
||
| 3 | 구두/카카오톡 문자 전달 | 공식 증빙 불가, 분쟁 발생 시 근거 없음, PDF 재요청 빈번 |
|
||
|
||
### 타겟 페르소나
|
||
|
||
- **노션을 이미 사용하는 프리랜서 / 소규모 에이전시** (1~5인)
|
||
- 월 10~50건 견적서 발행
|
||
- 별도 ERP 도입 비용을 감당하기 어렵고, 노션 DB를 이미 운영 중
|
||
|
||
### 핵심 문제 정의
|
||
|
||
> 노션에 입력한 견적 데이터를 클라이언트에게 **전문적인 웹 뷰어**로 즉시 공유하고, **단일 PDF**로 제공하는 통합 수단이 없다.
|
||
|
||
**구현 시 주의사항**: 문제 범위를 "노션 사용자"로 명시적으로 한정함으로써 범용 견적 SaaS와의 차별점을 유지한다.
|
||
|
||
---
|
||
|
||
## 2. 목표 (Goals & Non-Goals)
|
||
|
||
### MVP Goals
|
||
|
||
| # | 목표 | 측정 기준 |
|
||
|---|------|-----------|
|
||
| G1 | 견적서 발행에서 클라이언트 PDF 수령까지 **5분 이내** | 사용자 테스트 3명 중 3명 달성 |
|
||
| G2 | 노션 DB 속성 매핑 오류율 **0%** (필수 속성 10개 기준) | QA 체크리스트 통과 |
|
||
| G3 | PDF 생성 응답 시간 **10초 이내** (A4 1페이지 기준) | Vercel 함수 p95 지표 |
|
||
|
||
### Non-Goals (MVP 제외)
|
||
|
||
- 클라이언트 전자 서명 / 결재 기능
|
||
- 다국어 지원 (한국어 단일)
|
||
- Notion OAuth 앱 등록 (Public Integration)
|
||
- 반복 견적 템플릿 / 자동 발송 스케줄러
|
||
- 결제 연동 (세금계산서 발행, PG 연동)
|
||
- 멀티 워크스페이스 지원
|
||
|
||
**구현 시 주의사항**: Non-Goal은 스코프 크리프 방어선이다. 기능 요청이 들어오면 이 목록을 먼저 확인한다.
|
||
|
||
---
|
||
|
||
## 3. 사용자 스토리
|
||
|
||
### 공급자 (Supplier)
|
||
|
||
**S1. 노션 연동**
|
||
> 나는 공급자로서, 기존 노션 DB를 그대로 사용하기 위해 Integration Token을 InvoiceLink에 등록할 수 있다.
|
||
|
||
- AC1: Token 저장 후 "연결 테스트" 버튼 클릭 시 DB 접근 가능 여부를 3초 이내에 표시한다.
|
||
- AC2: 토큰이 유효하지 않으면 구체적인 오류 메시지(인증 실패 / 권한 없음)를 표시한다.
|
||
- AC3: Token은 암호화되어 Supabase에 저장되며 UI에서 마스킹(`ntn_****`)으로 표시된다.
|
||
|
||
**S2. 견적서 발행**
|
||
> 나는 공급자로서, 노션 DB 항목을 선택하여 고유 링크를 즉시 생성할 수 있다.
|
||
|
||
- AC1: 필수 속성 10개가 모두 있을 때만 "발행" 버튼이 활성화된다.
|
||
- AC2: 발행 클릭 시 `/q/[uuid-v4]` 형식의 URL이 클립보드에 복사되고 토스트 알림이 표시된다.
|
||
- AC3: 발행된 견적서는 대시보드 목록에 `sent` 상태로 즉시 반영된다.
|
||
|
||
**S3. 링크 관리**
|
||
> 나는 공급자로서, 발행한 링크를 비활성화하여 클라이언트가 더 이상 열람하지 못하게 할 수 있다.
|
||
|
||
- AC1: 대시보드에서 토글 클릭 → 1초 이내 상태가 `expired`로 변경된다.
|
||
- AC2: 비활성화된 링크 접근 시 클라이언트에게 "만료된 견적서" 안내 페이지를 표시한다.
|
||
- AC3: 비활성화는 토글로 재활성화할 수 있다.
|
||
|
||
**S4. 열람/다운로드 추적**
|
||
> 나는 공급자로서, 클라이언트가 견적서를 열람했는지 PDF를 다운로드했는지 확인할 수 있다.
|
||
|
||
- AC1: 클라이언트가 견적서 링크를 열면 대시보드의 상태가 `viewed`로 변경된다.
|
||
- AC2: PDF 다운로드 시 대시보드에 "다운로드 완료" 배지가 표시된다.
|
||
- AC3: 최초 열람 일시가 KST 기준으로 표시된다.
|
||
|
||
### 클라이언트 (Client)
|
||
|
||
**C1. 견적서 열람**
|
||
> 나는 클라이언트로서, 링크 하나만으로 로그인 없이 견적서를 확인할 수 있다.
|
||
|
||
- AC1: 링크 접근 시 로그인 요구 없이 견적서 페이지가 로드된다.
|
||
- AC2: 모바일(375px)에서 품목 테이블이 가로 스크롤로 열람 가능하다.
|
||
- AC3: 유효기간이 지난 견적서는 만료 안내만 표시하고 내용은 노출하지 않는다.
|
||
|
||
**C2. PDF 다운로드**
|
||
> 나는 클라이언트로서, 견적서를 PDF로 저장하여 내부 결재에 사용할 수 있다.
|
||
|
||
- AC1: "PDF 다운로드" 버튼 클릭 시 10초 이내에 파일이 저장된다.
|
||
- AC2: 파일명은 `견적서_{견적번호}_{YYYYMMDD}.pdf` 규칙을 따른다.
|
||
- AC3: A4 1페이지에 모든 내용이 인쇄 최적화된 레이아웃으로 출력된다.
|
||
|
||
**C3. 견적서 진위 확인**
|
||
> 나는 클라이언트로서, 견적서가 공식적으로 발행된 것임을 신뢰할 수 있다.
|
||
|
||
- AC1: 견적서 페이지에 발행 일시와 견적번호가 명시된다.
|
||
- AC2: 링크가 만료되었거나 비활성화된 경우 변조 가능성 없이 안내 메시지만 표시한다.
|
||
|
||
**구현 시 주의사항**: 클라이언트는 계정이 없으므로 모든 클라이언트 기능은 Public 라우트로 설계하되, uuid-v4로 추측 공격을 방어한다.
|
||
|
||
---
|
||
|
||
## 4. 기능 명세
|
||
|
||
### F1. 노션 연동
|
||
|
||
#### 연동 방식
|
||
|
||
- **Notion Internal Integration Token** 사용 (OAuth Public App은 Non-Goal)
|
||
- 설정 위치: 대시보드 → 설정 → 노션 연동
|
||
- Token 저장: Supabase `suppliers.notion_token` 컬럼 (AES-256 암호화, 서버 사이드 복호화)
|
||
|
||
#### 환경변수 (서버 전용)
|
||
|
||
```env
|
||
NOTION_TOKEN=ntn_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||
```
|
||
|
||
```typescript
|
||
// nuxt.config.ts
|
||
runtimeConfig: {
|
||
notionToken: process.env.NOTION_TOKEN, // 서버 전용, public 미노출
|
||
}
|
||
```
|
||
|
||
#### SDK 초기화
|
||
|
||
```typescript
|
||
// server/utils/notion.ts
|
||
import { Client } from '@notionhq/client'
|
||
|
||
export function createNotionClient(token: string) {
|
||
return new Client({ auth: token, timeoutMs: 30_000 })
|
||
}
|
||
```
|
||
|
||
#### 필수 데이터베이스 속성 매핑
|
||
|
||
| 노션 속성명 | 노션 타입 | InvoiceLink 필드 | 추출 방법 | 필수 여부 |
|
||
|-------------|-----------|------------------|-----------|-----------|
|
||
| 견적번호 | `title` | `quote_number` | `prop.title.map(t => t.plain_text).join('')` | 필수 |
|
||
| 발행일 | `date` | `issued_at` | `prop.date.start` | 필수 |
|
||
| 유효기간 | `date` | `expires_at` | `prop.date.start` | 필수 |
|
||
| 수신자명 | `rich_text` | `client_name` | `prop.rich_text.map(t => t.plain_text).join('')` | 필수 |
|
||
| 수신자 이메일 | `email` | `client_email` | `prop.email \|\| ''` | 선택 |
|
||
| 항목명 | `rich_text` | `item_name` | `prop.rich_text.map(t => t.plain_text).join('')` | 필수 |
|
||
| 수량 | `number` | `quantity` | `prop.number` | 필수 |
|
||
| 단가 (원) | `number` | `unit_price` | `prop.number` | 필수 |
|
||
| 세율 (%) | `number` | `tax_rate` | `prop.number` | 필수 |
|
||
| 비고 | `rich_text` | `notes` | `prop.rich_text.map(t => t.plain_text).join('')` | 선택 |
|
||
|
||
#### 속성 추출 헬퍼 (TypeScript)
|
||
|
||
```typescript
|
||
// server/utils/notion-extract.ts
|
||
import type { PageObjectResponse } from '@notionhq/client/build/src/api-endpoints'
|
||
type Prop = PageObjectResponse['properties'][string]
|
||
|
||
export const extract = {
|
||
title: (p: Prop): string =>
|
||
p.type === 'title' ? p.title.map(t => t.plain_text).join('') : '',
|
||
text: (p: Prop): string =>
|
||
p.type === 'rich_text' ? p.rich_text.map(t => t.plain_text).join('') : '',
|
||
number: (p: Prop): number | null =>
|
||
p.type === 'number' ? p.number : null,
|
||
date: (p: Prop): string | null =>
|
||
p.type === 'date' && p.date ? p.date.start : null,
|
||
email: (p: Prop): string =>
|
||
p.type === 'email' ? (p.email ?? '') : '',
|
||
}
|
||
```
|
||
|
||
#### 동기화 트리거: 수동 버튼
|
||
|
||
- **선택**: 수동 버튼 (대시보드 "동기화" 클릭)
|
||
- **근거**: Notion Webhook은 Public Integration만 지원하여 Internal Token 방식으로는 불가. 자동 폴링은 rate limit(3 req/s) 소진 위험. 1인 MVP에서 수동 동기화가 구현 복잡도 대비 충분한 UX를 제공함.
|
||
|
||
#### Rate Limit 대응
|
||
|
||
- Notion API: 평균 3 req/s per integration
|
||
- 서버 라우트에서 Nitro `defineCachedFunction` 적용 (TTL: 60초)
|
||
- 429 응답 시 지수 백오프(1s → 2s → 4s, 최대 3회 재시도)
|
||
|
||
```typescript
|
||
// server/utils/notion-retry.ts
|
||
import { APIResponseError } from '@notionhq/client'
|
||
|
||
export async function withRetry<T>(fn: () => Promise<T>, max = 3): Promise<T> {
|
||
for (let i = 0; i <= max; i++) {
|
||
try { return await fn() }
|
||
catch (e) {
|
||
if (e instanceof APIResponseError && e.status === 429 && i < max) {
|
||
const wait = Math.pow(2, i) * 1000
|
||
await new Promise(r => setTimeout(r, wait))
|
||
continue
|
||
}
|
||
throw e
|
||
}
|
||
}
|
||
throw new Error('unreachable')
|
||
}
|
||
```
|
||
|
||
#### 에러 처리
|
||
|
||
| HTTP 상태 | 원인 | 사용자 메시지 |
|
||
|-----------|------|---------------|
|
||
| 400 | 잘못된 속성명/필터 | "DB 속성명을 확인하세요. 대소문자와 공백이 정확해야 합니다." |
|
||
| 401 | 토큰 만료/무효 | "Notion Integration Token이 유효하지 않습니다." |
|
||
| 404 | DB ID 오류 또는 권한 미부여 | "데이터베이스를 찾을 수 없습니다. DB에 Integration을 연결했는지 확인하세요." |
|
||
| 429 | Rate limit 초과 | "잠시 후 다시 시도하세요. (요청 제한)" |
|
||
|
||
**구현 시 주의사항**: 노션 속성명은 한국어 포함 대소문자·공백이 완전히 일치해야 하므로, 설정 화면에서 속성명 매핑을 사용자가 직접 입력하는 방식을 고려한다.
|
||
|
||
---
|
||
|
||
### F2. 고유 링크 생성
|
||
|
||
- **URL 구조**: `/q/[uuid-v4]` — 128비트 랜덤, 추측 불가
|
||
- **유효기간**: 기본값 30일. 발행 시 커스터마이징 가능 (7일 / 14일 / 30일 / 무제한)
|
||
- **접근 제어**: 링크 소유 공급자만 비활성화 가능. 비활성화 즉시 클라이언트 접근 차단.
|
||
- **클립보드 복사 UX**: `navigator.clipboard.writeText()` + `@nuxt/ui` Toast 알림
|
||
|
||
```typescript
|
||
// server/api/quotes/index.post.ts
|
||
import { v4 as uuidv4 } from 'uuid'
|
||
|
||
const slug = uuidv4() // e.g. "550e8400-e29b-41d4-a716-446655440000"
|
||
// quote 레코드 생성 후 /q/${slug} 반환
|
||
```
|
||
|
||
**구현 시 주의사항**: `crypto.randomUUID()`를 Node.js 환경에서 직접 사용할 수 있으나, 브라우저 호환성을 위해 `uuid` 패키지를 명시적으로 사용한다.
|
||
|
||
---
|
||
|
||
### F3. 견적서 웹 뷰어
|
||
|
||
#### 레이아웃 구성
|
||
|
||
```
|
||
┌─────────────────────────────────────────┐
|
||
│ [로고] 상호명 / 연락처 / 이메일 │ ← 헤더
|
||
├─────────────────────────────────────────┤
|
||
│ 견적번호: Q-2026-001 발행일: 2026-03-14 │
|
||
│ 유효기간: 2026-04-14 수신자: 홍길동 님 │ ← 견적 메타
|
||
├─────────────────────────────────────────┤
|
||
│ 항목명 수량 단가 세액 소계 │
|
||
│ ────────────────────────────────────── │
|
||
│ 웹 개발 1 3,000,000 300,000 3.3M │ ← 품목 테이블
|
||
│ 디자인 1 1,000,000 100,000 1.1M │
|
||
├─────────────────────────────────────────┤
|
||
│ 공급가액 4,000,000 │
|
||
│ 세액 400,000 │ ← 합계 영역
|
||
│ 총액 4,400,000 │
|
||
├─────────────────────────────────────────┤
|
||
│ 비고: 계약금 50% 선입금 후 착수 │
|
||
│ 사업자번호: 000-00-00000 │ ← 푸터
|
||
└─────────────────────────────────────────┘
|
||
```
|
||
|
||
#### 반응형 요구사항
|
||
|
||
- 모바일(375px): 품목 테이블 `overflow-x: auto`, 헤더는 세로 스택
|
||
- PDF 출력: `@media print` CSS로 A4(210×297mm) 최적화, 푸터 고정
|
||
|
||
#### 브랜딩 커스터마이징 (MVP)
|
||
|
||
- 로고 이미지 업로드 → Supabase Storage 저장
|
||
- Primary 컬러 6가지 프리셋: `#2563EB` `#16A34A` `#DC2626` `#D97706` `#7C3AED` `#0891B2`
|
||
|
||
**구현 시 주의사항**: `@nuxt/ui` 컴포넌트의 color prop과 CSS 변수(`--color-primary`)를 연동하여 동적 테마 전환을 구현한다.
|
||
|
||
---
|
||
|
||
### F4. PDF 다운로드
|
||
|
||
- **생성 방식**: 서버사이드 헤드리스 렌더링
|
||
- **라우트**: `server/api/quote/[id]/pdf.get.ts`
|
||
- **라이브러리**: `@sparticuz/chromium` + `puppeteer-core`
|
||
- **근거**: 클라이언트 `window.print()`는 브라우저별 출력 차이 발생. 서버사이드가 레이아웃 일관성 보장.
|
||
|
||
```typescript
|
||
// server/api/quote/[id]/pdf.get.ts
|
||
import chromium from '@sparticuz/chromium'
|
||
import puppeteer from 'puppeteer-core'
|
||
|
||
export default defineEventHandler(async (event) => {
|
||
const id = getRouterParam(event, 'id')
|
||
// 1. DB에서 quote 조회
|
||
// 2. HTML 템플릿 렌더링
|
||
// 3. Puppeteer로 PDF 생성
|
||
const browser = await puppeteer.launch({
|
||
args: chromium.args,
|
||
executablePath: await chromium.executablePath(),
|
||
headless: chromium.headless,
|
||
})
|
||
const page = await browser.newPage()
|
||
await page.setContent(htmlContent, { waitUntil: 'networkidle0' })
|
||
const pdf = await page.pdf({ format: 'A4', printBackground: true })
|
||
await browser.close()
|
||
|
||
setResponseHeaders(event, {
|
||
'Content-Type': 'application/pdf',
|
||
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`,
|
||
})
|
||
return pdf
|
||
})
|
||
```
|
||
|
||
- **파일명 규칙**: `견적서_{quote_number}_{YYYYMMDD}.pdf`
|
||
- **이벤트 로깅**: 다운로드 완료 시 `quote_events` 테이블에 `{ type: 'pdf_downloaded', quote_id, created_at }` 삽입
|
||
- **Vercel 제약**: 서버리스 함수 메모리 1GB, 실행 시간 최대 60초(Pro 플랜). `@sparticuz/chromium`은 50MB 이하로 제한 내 처리 가능.
|
||
|
||
**구현 시 주의사항**: Vercel Edge Runtime은 Node.js API 미지원. PDF 라우트는 반드시 `export const runtime = 'nodejs'`를 명시한다.
|
||
|
||
---
|
||
|
||
### F5. 공급자 대시보드
|
||
|
||
- **견적서 목록**: 발행일 내림차순, 20개씩 페이지네이션
|
||
- **상태 뱃지**:
|
||
|
||
| 상태 | 조건 | 색상 |
|
||
|------|------|------|
|
||
| `draft` | 발행 전 | 회색 |
|
||
| `sent` | 링크 생성 완료 | 파란색 |
|
||
| `viewed` | 클라이언트 최초 열람 | 초록색 |
|
||
| `expired` | 유효기간 경과 또는 수동 만료 | 빨간색 |
|
||
|
||
- **이벤트 표시**: "열람 ✓" / "PDF 다운로드 ✓" 아이콘 배지
|
||
- **링크 만료 토글**: `UToggle` 컴포넌트, PATCH `/api/quotes/[id]` 호출
|
||
|
||
**구현 시 주의사항**: 상태 전환 로직은 서버에서만 처리하며 클라이언트에서 직접 `status` 필드를 조작하지 않는다.
|
||
|
||
---
|
||
|
||
## 5. 데이터 모델
|
||
|
||
```sql
|
||
-- suppliers: 공급자 (Supabase Auth users와 1:1)
|
||
CREATE TABLE suppliers (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||
company_name TEXT NOT NULL,
|
||
contact TEXT,
|
||
logo_url TEXT,
|
||
primary_color CHAR(7) DEFAULT '#2563EB',
|
||
notion_token TEXT, -- AES-256 암호화 저장
|
||
notion_db_id TEXT,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||
UNIQUE(user_id)
|
||
);
|
||
|
||
-- RLS: 본인 레코드만 읽기/쓰기
|
||
ALTER TABLE suppliers ENABLE ROW LEVEL SECURITY;
|
||
CREATE POLICY "suppliers_self" ON suppliers
|
||
USING (auth.uid() = user_id);
|
||
|
||
-- quotes: 견적서 헤더
|
||
CREATE TABLE quotes (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
supplier_id UUID NOT NULL REFERENCES suppliers(id) ON DELETE CASCADE,
|
||
slug UUID NOT NULL UNIQUE DEFAULT gen_random_uuid(), -- /q/[slug]
|
||
notion_page_id TEXT,
|
||
quote_number TEXT NOT NULL,
|
||
client_name TEXT NOT NULL,
|
||
client_email TEXT,
|
||
issued_at DATE NOT NULL,
|
||
expires_at DATE,
|
||
notes TEXT,
|
||
status TEXT NOT NULL DEFAULT 'sent'
|
||
CHECK (status IN ('draft','sent','viewed','expired')),
|
||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||
total_amount NUMERIC(12,2),
|
||
tax_amount NUMERIC(12,2),
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||
);
|
||
|
||
-- RLS: 공급자는 본인 견적만, 공개 slug 읽기는 허용
|
||
ALTER TABLE quotes ENABLE ROW LEVEL SECURITY;
|
||
CREATE POLICY "quotes_supplier" ON quotes
|
||
USING (supplier_id IN (SELECT id FROM suppliers WHERE user_id = auth.uid()));
|
||
CREATE POLICY "quotes_public_read" ON quotes
|
||
FOR SELECT USING (is_active = TRUE);
|
||
|
||
-- quote_items: 견적 항목
|
||
CREATE TABLE quote_items (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
quote_id UUID NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
|
||
item_name TEXT NOT NULL,
|
||
quantity NUMERIC(10,2) NOT NULL DEFAULT 1,
|
||
unit_price NUMERIC(12,2) NOT NULL,
|
||
tax_rate NUMERIC(5,2) NOT NULL DEFAULT 10,
|
||
sort_order SMALLINT NOT NULL DEFAULT 0
|
||
);
|
||
|
||
-- RLS: quotes와 동일 공급자 정책 상속
|
||
ALTER TABLE quote_items ENABLE ROW LEVEL SECURITY;
|
||
CREATE POLICY "quote_items_via_quotes" ON quote_items
|
||
USING (quote_id IN (SELECT id FROM quotes
|
||
WHERE supplier_id IN (SELECT id FROM suppliers WHERE user_id = auth.uid())));
|
||
CREATE POLICY "quote_items_public_read" ON quote_items
|
||
FOR SELECT USING (quote_id IN (SELECT id FROM quotes WHERE is_active = TRUE));
|
||
|
||
-- quote_events: 열람/다운로드 이벤트 로그
|
||
CREATE TABLE quote_events (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
quote_id UUID NOT NULL REFERENCES quotes(id) ON DELETE CASCADE,
|
||
event_type TEXT NOT NULL CHECK (event_type IN ('viewed','pdf_downloaded')),
|
||
ip_hash TEXT, -- 개인정보 최소화: IP를 SHA-256 해시로 저장
|
||
user_agent TEXT,
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||
);
|
||
|
||
-- RLS: 공급자만 이벤트 읽기, 삽입은 서버(service_role)만
|
||
ALTER TABLE quote_events ENABLE ROW LEVEL SECURITY;
|
||
CREATE POLICY "events_supplier_read" ON quote_events
|
||
FOR SELECT USING (quote_id IN (
|
||
SELECT id FROM quotes
|
||
WHERE supplier_id IN (SELECT id FROM suppliers WHERE user_id = auth.uid())
|
||
));
|
||
```
|
||
|
||
**구현 시 주의사항**: `quote_events` 삽입은 서버 라우트에서 `service_role` 키로 처리하고 클라이언트에 해당 키를 노출하지 않는다.
|
||
|
||
---
|
||
|
||
## 6. API 설계 (Nuxt Server Routes)
|
||
|
||
| Method | Route | 설명 | Auth |
|
||
|--------|-------|------|------|
|
||
| `GET` | `/api/quote/[slug]` | 슬러그로 견적서 + 항목 조회 | Public (slug 보유자) |
|
||
| `GET` | `/api/quote/[slug]/pdf` | PDF 생성 및 스트림 응답 | Public |
|
||
| `POST` | `/api/quotes` | 견적서 생성 (노션 데이터 포함) | Supplier (세션 필요) |
|
||
| `PATCH` | `/api/quotes/[id]` | 상태/활성화 변경 | Supplier |
|
||
| `DELETE` | `/api/quotes/[id]` | 견적서 삭제 | Supplier |
|
||
| `GET` | `/api/notion/sync` | 노션 DB 페이지 목록 조회 | Supplier |
|
||
| `POST` | `/api/notion/validate` | Token + DB ID 유효성 검사 | Supplier |
|
||
|
||
### 요청/응답 예시
|
||
|
||
```typescript
|
||
// POST /api/quotes
|
||
// Request body (Zod 검증)
|
||
const createQuoteSchema = z.object({
|
||
notionPageId: z.string(),
|
||
expiresAt: z.string().date().optional(),
|
||
primaryColor: z.string().regex(/^#[0-9A-F]{6}$/i).optional(),
|
||
})
|
||
|
||
// GET /api/quote/[slug] Response
|
||
interface QuoteResponse {
|
||
quote: { quoteNumber: string; clientName: string; issuedAt: string; expiresAt: string | null }
|
||
items: Array<{ itemName: string; quantity: number; unitPrice: number; taxRate: number }>
|
||
supplier: { companyName: string; contact: string; logoUrl: string | null; primaryColor: string }
|
||
}
|
||
```
|
||
|
||
**구현 시 주의사항**: 모든 입력값은 Zod로 서버에서 재검증. 클라이언트 검증만으로는 부족하다.
|
||
|
||
---
|
||
|
||
## 7. 기술 스택 최종 제안
|
||
|
||
| 용도 | 라이브러리 | 선택 이유 |
|
||
|------|-----------|-----------|
|
||
| **PDF 생성** | `@sparticuz/chromium` + `puppeteer-core` | Vercel 서버리스 환경에서 Chromium 번들링 가능한 유일한 실용적 옵션 |
|
||
| **노션 API 클라이언트** | `@notionhq/client` | Notion 공식 TypeScript SDK, 타입 완전 지원 |
|
||
| **날짜 처리** | `date-fns` | 경량(tree-shaking), 순수 함수형, `format(date, 'yyyyMMdd')` 직관적 API |
|
||
| **숫자/통화 포맷** | `Intl.NumberFormat` (내장) | 외부 의존성 없이 `new Intl.NumberFormat('ko-KR').format(n)` 으로 천 단위 콤마 처리 |
|
||
| **이메일 발송 (선택)** | `Resend` + `react-email` | Vercel 환경 최적화, 무료 플랜 월 3,000건 충분, SDK 단순함 |
|
||
| **입력 검증** | `zod` | 기존 스택 일관성, Nuxt server route와 네이티브 통합 |
|
||
| **UUID 생성** | `crypto.randomUUID()` (Node 내장) | 외부 의존성 불필요, RFC 4122 v4 준수 |
|
||
|
||
**구현 시 주의사항**: `puppeteer-core` 버전은 `@sparticuz/chromium`과 호환 버전을 반드시 맞춘다. (패키지 README에서 버전 매트릭스 확인)
|
||
|
||
---
|
||
|
||
## 8. MVP 마일스톤
|
||
|
||
| Phase | 기간 | 완성 기준 |
|
||
|-------|------|-----------|
|
||
| **Phase 1** 인프라 + 노션 연동 | Week 1~2 | Supabase 스키마 적용, 노션 토큰 등록·검증, DB 항목 목록 조회 API 동작 |
|
||
| **Phase 2** 핵심 기능 | Week 3~4 | 견적서 발행(링크 생성), 웹 뷰어 렌더링, PDF 다운로드 (A4 출력 확인) |
|
||
| **Launch** 대시보드 + 안정화 | Week 5 | 공급자 대시보드(상태·이벤트 추적), 에러 처리 완비, Vercel 배포 |
|
||
|
||
### 런치 기준 (Launch Criteria)
|
||
|
||
- [ ] 노션 연동 → 견적서 발행 → PDF 수령 E2E 시나리오 3회 반복 성공
|
||
- [ ] PDF 생성 p95 응답 시간 10초 이내 (Vercel 로그 확인)
|
||
- [ ] 만료된 링크 접근 시 클라이언트에게 적절한 안내 페이지 표시
|
||
- [ ] Supabase RLS 정책 검증: 타 공급자 데이터 접근 불가 확인
|
||
- [ ] 모바일(375px) Chrome에서 웹 뷰어 및 PDF 다운로드 정상 동작
|
||
|
||
**구현 시 주의사항**: 런치 기준은 QA 체크리스트로 관리하며, 모든 항목 체크 완료 전까지 프로덕션 도메인 공유를 금한다.
|
||
|
||
---
|
||
|
||
## 9. 성공 지표 (Metrics)
|
||
|
||
### Primary Metric (북극성)
|
||
|
||
- **주간 활성 견적서 발행 수** (WAQ: Weekly Active Quotes)
|
||
- 측정: Supabase `quotes` 테이블 `created_at` 집계
|
||
- 목표: 런치 4주 후 WAQ ≥ 10
|
||
|
||
### Supporting Metrics
|
||
|
||
| 지표 | 설명 | 측정 도구 |
|
||
|------|------|-----------|
|
||
| PDF 변환 성공률 | 전체 다운로드 시도 중 성공 비율 ≥ 95% | Supabase `quote_events` + Vercel 함수 에러 로그 |
|
||
| 견적서 열람률 | 발행된 링크 중 클라이언트가 실제 열람한 비율 | `quote_events` `viewed` / `quotes` `sent` |
|
||
| 평균 발행 소요 시간 | 노션 동기화 클릭 → 링크 복사 완료까지 | 브라우저 Performance API (PostHog 커스텀 이벤트) |
|
||
|
||
**구현 시 주의사항**: PostHog는 무료 플랜(월 100만 이벤트)으로 충분. Nuxt 플러그인으로 초기화하고 `usePostHog()`로 이벤트 전송.
|
||
|
||
---
|
||
|
||
## 10. 리스크 & Open Questions
|
||
|
||
### 리스크
|
||
|
||
| 리스크 | 발생 확률 | 영향도 | 대응 전략 |
|
||
|--------|-----------|--------|-----------|
|
||
| Vercel 서버리스 Cold Start (Chromium) | 높음 | 중간 | Vercel Pro Fluid Compute 또는 최소 warm-up 요청 설정. 대안: `html-pdf-node` 검토 |
|
||
| Notion API Rate Limit (3 req/s) | 중간 | 중간 | Nitro 60초 캐시 + 지수 백오프. 동기화는 수동 트리거로 제한 |
|
||
| Notion API 속성명 변경 | 낮음 | 높음 | 속성명 매핑을 대시보드에서 사용자가 설정 가능하게 구현 |
|
||
| Supabase Free Tier 제한 (500MB DB) | 낮음 | 낮음 | `quote_events` 90일 이상 데이터 주기적 아카이브 또는 자동 삭제 정책 |
|
||
|
||
### Open Questions
|
||
|
||
| # | 질문 | 현재 유력 가설 |
|
||
|---|------|----------------|
|
||
| OQ1 | 노션 속성명 매핑을 코드에 하드코딩할 것인가, 대시보드에서 설정 가능하게 할 것인가? | 설정 가능하게 구현. 초기 기본값 제공, 변경 허용. |
|
||
| OQ2 | PDF 생성을 Vercel 서버리스로 할 것인가, 별도 마이크로서비스(Render)로 분리할 것인가? | MVP는 Vercel 단일 배포. 10초 초과 시 Render 분리 검토. |
|
||
| OQ3 | 클라이언트 열람 이벤트를 IP 기반으로 중복 제거할 것인가? | SHA-256 해시로 저장, 24시간 내 동일 IP 재열람은 이벤트 미중복 기록. |
|
||
| OQ4 | 견적서 항목이 여러 노션 페이지(하위 DB)에 분산된 경우를 지원할 것인가? | MVP는 단일 DB, 단일 페이지 = 단일 견적서 구조만 지원. |
|
||
| OQ5 | 무료 플랜과 유료 플랜을 MVP에서 구분할 것인가? | MVP는 전체 무료. 100건 발행 초과 시 유료 전환 검토. |
|
||
|
||
**구현 시 주의사항**: OQ1~OQ5는 Phase 1 시작 전에 의사결정을 완료하고 CLAUDE.md 또는 별도 ADR 문서에 기록한다.
|