diff --git a/docs/superpowers/plans/2026-07-09-phase3b-music-video-order.md b/docs/superpowers/plans/2026-07-09-phase3b-music-video-order.md new file mode 100644 index 0000000..753fba6 --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-phase3b-music-video-order.md @@ -0,0 +1,272 @@ +# Phase 3b 음악 영상화 유료 발주 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 회원이 저장한 음악 트랙을 계좌이체로 "영상화 발주"하고 관리자가 URL로 완성 영상을 납품하는 흐름을 붙인다. + +**Architecture:** 기존 orders(계좌이체)·admin/orders·메일 인프라를 재사용. `music_video` 제품 1행 + music_tracks의 video_status/video_url 컬럼으로 상태를 표현. 신청은 마이페이지 음악 카드, 납품은 admin/orders에서 URL 입력. + +**Tech Stack:** Next.js 16 (App Router, TS), Supabase, Tailwind v4(`--jsm-*`), vitest + +**Spec:** `docs/superpowers/specs/2026-07-09-phase3b-music-video-order-design.md` + +## Global Constraints + +- **순수 시각 태스크 없음** — 대부분 API/데이터. 신규/변경 UI는 라이트 가드레일(gradient/blur/보라/이모지 금지, `--jsm-*`만) +- orders POST의 일반 상품(products) 발주 동작은 **회귀 금지** — music_video 분기만 추가 +- `BankTransferModal`은 다른 상품도 사용 — `musicTrackId`는 **optional**, 미전달 시 기존 동작 불변 +- 인증→검증→admin insert(user_id는 세션), user_id 스푸핑 불가. 본인 트랙만 발주(music_tracks user_id 일치 검증) +- 마이그레이션은 phase3a(music_tracks) 적용 후 실행 전제 — 헤더에 명시. 기존 마이그레이션 수정 금지, 신규만 +- 커밋은 스코프 파일만 — **`git add -A`·`git commit -a` 금지**, 커밋 전 `git status` 확인 +- 각 Task 종료 시 `npm run build` 성공 + `npm test`(31) 유지 후 커밋 +- 커밋 트레일러: `Co-Authored-By: Claude Opus 4.8 (1M context) ` + +## 확인된 기존 계약 + +- `products`: `{ id, name, price, is_active, ... }`. `getProductById(admin, id)`(`lib/supabase/product-files.ts`) +- `POST /api/orders`(`app/api/orders/route.ts`): 인증→rate-limit→`sanitizeStr(productId/depositorName)`→getProductById(active)→중복 pending 가드(user+product)→insert `{user_id, product_id, amount:product.price, status:'pending', metadata:{method:'bank_transfer', depositor_name}}`→접수 메일 +- `admin/orders`(`app/api/admin/orders/route.ts`): GET select `id,user_id,product_id,amount,status,metadata,created_at`; PATCH `{id, status∈pending|paid|cancelled}`, paid 시 `sendOrderPaidEmail` +- `BankTransferModal({ product:{id,name,price}, isOpen, onClose })` → POST `{ productId, depositorName }` +- `music_tracks`: `{ id, user_id, title, story, lyrics, style, audio_url, task_id, created_at }`(Phase 3a), RLS `music_select_own` +- 마이페이지 AI기록: `MusicTrackRow = { id, title, story, audio_url, created_at }`, `MusicAiCard` + +--- + +### Task 1: DB 마이그레이션 (music_video 제품 + music_tracks 확장) + +**Files:** +- Create: `supabase/migrations/2026-07-09-phase3b-music-video.sql` + +**Interfaces:** +- Produces: `products` 행 `music_video`, `music_tracks.video_status/video_url/video_order_id`. Task 2·3·4가 소비 + +- [ ] **Step 1: products 실제 컬럼 확인** + +`005_all_products.sql` 또는 products 생성 마이그레이션을 Read해 필수(NOT NULL, default 없는) 컬럼 파악. `id,name,price,is_active` 외 NOT NULL 컬럼이 있으면 시드 INSERT에 값 포함. + +- [ ] **Step 2: 마이그레이션 파일 작성** + +`supabase/migrations/2026-07-09-phase3b-music-video.sql`: +```sql +-- Phase 3b (2026-07-09): 음악 영상화 발주 +-- 의존성: 2026-07-03-phase3a-music.sql(music_tracks) 적용 후 +-- 적용: 클라우드 Supabase + NAS self-host 양쪽 + +INSERT INTO products (id, name, price, is_active) +VALUES ('music_video', '음악 영상화', 30000, true) +ON CONFLICT (id) DO NOTHING; + +ALTER TABLE music_tracks + ADD COLUMN IF NOT EXISTS video_status text NOT NULL DEFAULT 'none' + CHECK (video_status IN ('none','requested','in_progress','delivered')), + ADD COLUMN IF NOT EXISTS video_url text, + ADD COLUMN IF NOT EXISTS video_order_id uuid; +``` +(Step 1에서 products에 추가 NOT NULL 컬럼 발견 시 INSERT 컬럼 목록·값 확장) + +- [ ] **Step 3: 검증·커밋** — `npm test && npm run build` PASS. `git add supabase/migrations/2026-07-09-phase3b-music-video.sql && git commit -m "feat(phase3b): music_video 제품 시드 + music_tracks video 컬럼 마이그레이션"` + +--- + +### Task 2: 발주 API 확장 (orders POST + video-product GET) + +**Files:** +- Modify: `app/api/orders/route.ts` +- Create: `app/api/studio/video-product/route.ts` + +**Interfaces:** +- Consumes: music_tracks(T1), `createAdminClient`, `getProductById` +- Produces: + - `POST /api/orders` body에 optional `musicTrackId` — music_video 발주 시 트랙 검증·연결 + - `GET /api/studio/video-product` (로그인) → `{ product: { id, name, price, is_active } }` / 401 / 404 + - Task 3이 소비 + +- [ ] **Step 1: orders POST에 musicTrackId 처리 추가** + +`app/api/orders/route.ts`를 Read 후, body 파싱부에 `musicTrackId` 추출(`sanitizeStr(rawMusicTrackId, 64)`). productId 검증 이후·insert 이전에: +```typescript +// music_video 발주: 본인 트랙 검증 + 트랙별 중복 가드 +let musicTrackId: string | null = null; +if (productId === 'music_video') { + musicTrackId = sanitizeStr((body as Record).musicTrackId, 64) || null; + if (!musicTrackId) return NextResponse.json({ error: '영상화할 트랙이 필요합니다' }, { status: 400 }); + const { data: track } = await admin + .from('music_tracks').select('id').eq('id', musicTrackId).eq('user_id', user.id).maybeSingle(); + if (!track) return NextResponse.json({ error: '본인 음악 트랙만 영상화할 수 있습니다' }, { status: 404 }); +} +``` +중복 pending 가드를 분기: music_video면 트랙 단위로 +```typescript +const dupQuery = admin.from('orders').select('id') + .eq('user_id', user.id).eq('product_id', productId).eq('status', 'pending'); +const { data: existing } = productId === 'music_video' && musicTrackId + ? await dupQuery.eq('metadata->>music_track_id', musicTrackId).maybeSingle() + : await dupQuery.maybeSingle(); +``` +insert의 metadata에 music_track_id 포함: +```typescript +metadata: { + method: 'bank_transfer', + depositor_name: depositorName, + ...(musicTrackId ? { music_track_id: musicTrackId } : {}), +}, +``` +insert 성공(orderId 확보) 후, 메일 발송 전/후에: +```typescript +if (productId === 'music_video' && musicTrackId) { + await admin.from('music_tracks') + .update({ video_status: 'requested', video_order_id: orderId }) + .eq('id', musicTrackId).eq('user_id', user.id); +} +``` +(일반 상품 경로는 위 분기 밖 — 기존 동작 그대로) + +- [ ] **Step 2: video-product GET** + +`app/api/studio/video-product/route.ts`: +```typescript +import { NextResponse } from 'next/server'; +import { createClient } from '@/lib/supabase/server'; +import { createAdminClient } from '@/lib/supabase/admin'; +import { getProductById } from '@/lib/supabase/product-files'; + +export const runtime = 'nodejs'; + +export async function GET() { + const supabase = await createClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return NextResponse.json({ error: '로그인이 필요합니다.' }, { status: 401 }); + const admin = createAdminClient(); + const product = await getProductById(admin, 'music_video'); + if (!product || !product.is_active) return NextResponse.json({ error: '영상화 상품이 준비 중입니다.' }, { status: 404 }); + return NextResponse.json({ product: { id: product.id, name: product.name, price: product.price, is_active: product.is_active } }); +} +``` + +- [ ] **Step 3: 검증·커밋** — `npm test && npm run build`(라우트 /api/studio/video-product 등장). `git add app/api/orders/route.ts app/api/studio/video-product/route.ts && git commit -m "feat(phase3b): orders에 영상화(music_video) 발주 + video-product 조회"` + +--- + +### Task 3: BankTransferModal 확장 + 마이페이지 신청/상태 + +**Files:** +- Modify: `app/components/BankTransferModal.tsx` +- Modify: `app/api/studio/tracks/route.ts` (GET select에 video 컬럼 추가) +- Modify: `app/mypage/page.tsx` (MusicAiCard + MusicTrackRow) + +**Interfaces:** +- Consumes: `POST /api/orders`(musicTrackId, T2), `GET /api/studio/video-product`(T2) +- Produces: 없음 + +- [ ] **Step 1: tracks GET에 video 컬럼** + +`app/api/studio/tracks/route.ts` GET select에 `video_status, video_url` 추가: +```typescript +.select('id, title, story, lyrics, style, audio_url, task_id, created_at, video_status, video_url') +``` + +- [ ] **Step 2: BankTransferModal에 musicTrackId optional prop** + +`app/components/BankTransferModal.tsx`: Props에 `musicTrackId?: string` 추가. POST body를 조건부 병합: +```typescript +body: JSON.stringify({ productId: product.id, depositorName: name, ...(musicTrackId ? { musicTrackId } : {}) }), +``` +(useCallback deps에 `musicTrackId` 추가. 다른 로직·UI 무변경) + +- [ ] **Step 3: 마이페이지 음악 카드 영상화 UI** + +`app/mypage/page.tsx`: +- `MusicTrackRow` 타입에 `video_status?: 'none'|'requested'|'in_progress'|'delivered'`, `video_url?: string | null` 추가 +- state: `videoProduct`({id,name,price} | null), `videoModalTrackId`(string | null) +- `MusicAiCard`에 video 영역: + - `video_status` 없음/`'none'`: "영상화 신청" 버튼 → `GET /api/studio/video-product`로 `videoProduct` 로드 후 `videoModalTrackId=track.id`로 `BankTransferModal` 오픈(product=videoProduct, musicTrackId=track.id, onClose로 목록 갱신 loadAiRecords) + - `'requested'`: 뱃지 "영상화 접수됨 · 입금 확인 중" + - `'in_progress'`: 뱃지 "영상 제작 중" + - `'delivered'` + `video_url`: `