diff --git a/.cursor/rules/portfolio-docs.mdc b/.cursor/rules/portfolio-docs.mdc index 0da5ae7..6a0a939 100644 --- a/.cursor/rules/portfolio-docs.mdc +++ b/.cursor/rules/portfolio-docs.mdc @@ -3,12 +3,13 @@ description: Ticker Journal 작업 시 이력서·포트폴리오 문서를 함 globs: - apps/**/* - packages/**/* -alwaysApply: false + - supabase/**/* +alwaysApply: true --- # Portfolio / Resume sync -이 레포에서 의미 있는 기능·인프라 마일스톤을 끝낼 때마다 **코드만 끝내지 말고** 아래를 갱신한다. +이 레포에서 의미 있는 기능·인프라 마일스톤을 끝낼 때마다 **코드만 끝내지 말고** 아래를 반드시 갱신한다. 사용자가 따로 부탁하지 않아도 마일스톤 완료 시 문서 업데이트를 포함한다. 1. `docs/portfolio.md` - 과정(Process)에 무엇을 했는지 @@ -16,9 +17,10 @@ alwaysApply: false - 배운 점에 구체적 인사이트 1~3줄 - 갱신 로그에 날짜 한 줄 2. `docs/resume-bullets.md` - - `[예정]` 불릿을 실제 성과 문장으로 교체하거나 새 불릿 추가 + - `[예정]`/`[ ]` 불릿을 실제 성과 문장으로 교체하거나 새 불릿 추가 - 가능하면 수치·URL·스토어 링크 포함 + - 테스트 건수·페이즈 체크박스 최신화 -마일스톤 예: Auth 연결, 첫 CRUD, WebView 차트 실연동, 웹 검색, EAS 빌드, 스토어 제출/승인. +마일스톤 예: Auth 연결, 첫 CRUD, WebView 차트 실연동, 웹 검색, EAS 빌드, 스토어 제출/승인, Biome/TS 업그레이드. 사용자가 “커밋해줘”라고 하면 문서 변경도 같은 커밋 또는 직전 `docs:` 커밋에 포함한다 (커밋 메시지 한국어). diff --git a/.cursor/rules/testing.mdc b/.cursor/rules/testing.mdc new file mode 100644 index 0000000..d9ab385 --- /dev/null +++ b/.cursor/rules/testing.mdc @@ -0,0 +1,35 @@ +--- +description: Ticker Journal 테스트 작성 기준. 테스트 추가·수정 전에 적용. +globs: + - "**/*.{test,spec}.{ts,tsx}" + - "**/e2e/**" + - docs/testing.md +alwaysApply: true +--- + +# 테스트 작성 기준 + +소스 오브 트루스: `docs/testing.md`. 어긋나면 테스트를 만들지 않는다. + +## 먼저 고른다 + +- 웹 사용자 플로우 (페이지 진입, 로그인 화면, 이동) → Playwright E2E +- 순수 함수 (Zod, HTML 빌더, `resolveAuthCallbackPath`) → 단위 +- 웹 순수 뷰 (props → 텍스트, 라우팅 없음) → RTL. Auth/폼/라우팅은 E2E +- 모바일 화면 → Maestro E2E (예정). Jest에서 expo-router/auth를 목킹해 목록을 그리지 않는다 + +## 금지 + +- expo-router / `useFocusEffect`를 목킹한 뒤 화면 텍스트를 확인하는 테스트 +- `signInWithOtp` / `createTicker` 같은 I/O를 목킹한 뒤 `toHaveBeenCalled`만 하는 테스트 (로그인·CRUD 포함) +- E2E가 이미 보는 동작을 컴포넌트 테스트로 중복 +- 커버리지·건수 맞추기용 테스트 +- 매직링크 실메일 E2E (인박스 없음). 페이지가 뜨는 스모크만 + +## 설명 + +`it`/`test` 문자열은 한국어. 예: `it('빈 심볼을 거부한다')`. `describe`는 대상 식별자 가능 (`CreateTickerSchema`). + +## assert + +사용자/호출자가 보는 것: 화면 텍스트, URL `Location`, 순수 함수 반환값. diff --git a/.env.example b/.env.example index a6dc577..a908e05 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,15 @@ -# Supabase (Phase 0+) +# 앱별 파일에 복사한다. +# - apps/mobile/.env → EXPO_PUBLIC_* +# - apps/web/.env → NEXT_PUBLIC_* + +# Dashboard → Project Settings → API +EXPO_PUBLIC_SUPABASE_URL= +EXPO_PUBLIC_SUPABASE_KEY= + NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_ANON_KEY= -EXPO_PUBLIC_SUPABASE_URL= -EXPO_PUBLIC_SUPABASE_ANON_KEY= + +# Auth redirects (Supabase Dashboard → Authentication → URL Configuration) +# - mobile (dev build / 스토어): tickerjournal://auth/callback +# - mobile (Expo Go): Linking.createURL('auth/callback') 결과(exp://…)도 등록 +# - web: http://localhost:3000/auth/callback diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7f71f23 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,58 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm check + - run: pnpm typecheck + - run: pnpm test:coverage + - uses: supabase/setup-cli@v1 + with: + version: latest + - name: Start local Supabase (migrations) + run: supabase start + - name: Check Database types match schema + env: + DB_TYPES_SOURCE: local + run: pnpm check:db-types + - name: Stop local Supabase + if: always() + run: supabase stop --no-backup + + e2e: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm --filter @ticker-journal/shared build + - run: pnpm --filter @ticker-journal/web exec playwright install --with-deps chromium + - run: pnpm test:e2e diff --git a/.gitignore b/.gitignore index 45db547..53b4ed7 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ playwright-report test-results blob-report .gstack/ +supabase/.temp/ +.sonda/ +apps/web/.sonda/ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 0000000..a1bb101 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1 @@ +pnpm run ci diff --git a/.vscode/settings.json b/.vscode/settings.json index 7fcb0eb..11de4f6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,5 +3,12 @@ "editor.codeActionsOnSave": { "source.fixAll.biome": "explicit" }, - "editor.formatOnSave": true + "editor.formatOnSave": true, + "files.associations": { + "**/supabase/**/*.sql": "plaintext" + }, + "mssql.intelliSense.enableIntelliSense": false, + "mssql.intelliSense.enableErrorChecking": false, + "mssql.intelliSense.enableSuggestions": false, + "mssql.intelliSense.enableQuickInfo": false } diff --git a/README.md b/README.md index f6313c2..ee94ec5 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ pnpm dev:web # http://localhost:3000 pnpm dev:mobile # Expo pnpm test # Vitest + jest-expo pnpm test:e2e # Playwright (web) +pnpm run ci # Biome + typecheck + unit pnpm check # Biome lint + format check pnpm check:fix # Biome auto-fix pnpm typecheck @@ -58,7 +59,19 @@ pnpm typecheck ## 로드맵 -- Phase 0: Auth + CRUD (앱) +- Phase 0: Auth + CRUD (앱) — 브랜치 `feat/phase-0-auth-crud` - Phase 1: 웹 검색/상세 - Phase 2: App Store + Play Store - v1.1 공유 시트 / v2 AI 주간 브리핑 + +### Phase 0 로컬 설정 + +1. [Supabase](https://supabase.com) 프로젝트 생성 +2. SQL Editor 또는 `supabase db push`로 `supabase/migrations`의 모든 마이그레이션을 순서대로 적용 +3. Auth → URL Configuration에 추가: + - `tickerjournal://auth/callback` (모바일 개발 빌드/스토어) + - Expo Go라면 앱이 찍는 `exp://…/--/auth/callback` 도 등록 + - `http://localhost:3000/auth/callback` (웹) +4. `apps/mobile/.env` — `EXPO_PUBLIC_SUPABASE_URL` / `EXPO_PUBLIC_SUPABASE_KEY` +5. `apps/web/.env` — `NEXT_PUBLIC_SUPABASE_URL` / `NEXT_PUBLIC_SUPABASE_ANON_KEY` +6. `pnpm dev:mobile` / `pnpm dev:web` 후 매직링크 로그인 diff --git a/apps/mobile/__tests__/chart.test.ts b/apps/mobile/__tests__/chart.test.ts new file mode 100644 index 0000000..ad9a212 --- /dev/null +++ b/apps/mobile/__tests__/chart.test.ts @@ -0,0 +1,22 @@ +import { buildChartHtml } from '../lib/chart'; + +describe('buildChartHtml', () => { + it('US 심볼에 TradingView 위젯을 넣는다', () => { + const html = buildChartHtml('US', 'aapl'); + expect(html).toContain('TradingView.widget'); + expect(html).toContain('symbol: "AAPL"'); + }); + + it('US 심볼의 따옴표를 JS 문자열 밖으로 빼지 않는다', () => { + const html = buildChartHtml('US', "x');alert(1);//"); + expect(html).toContain('symbol: "X\');ALERT(1);//"'); + expect(html).not.toContain("symbol: '"); + }); + + it('KR 심볼은 fallback HTML을 쓴다', () => { + const html = buildChartHtml('KR', '005930'); + expect(html).toContain('KR 차트 fallback'); + expect(html).toContain('005930'); + expect(html).not.toContain('TradingView.widget'); + }); +}); diff --git a/apps/mobile/__tests__/watchlist.test.tsx b/apps/mobile/__tests__/watchlist.test.tsx deleted file mode 100644 index b2060f1..0000000 --- a/apps/mobile/__tests__/watchlist.test.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { render, screen } from '@testing-library/react-native'; -import WatchlistScreen from '../app/index'; - -jest.mock('expo-router', () => { - const React = require('react'); - return { - Link: ({ children, asChild }: { children: React.ReactNode; asChild?: boolean; href: string }) => - asChild ? children : <>{children}, - Stack: { - Screen: () => null, - }, - }; -}); - -describe('WatchlistScreen', () => { - it('shows placeholder tickers', () => { - render(); - expect(screen.getByText('관심종목')).toBeOnTheScreen(); - expect(screen.getByText(/AAPL/)).toBeOnTheScreen(); - expect(screen.getByText(/005930/)).toBeOnTheScreen(); - }); -}); diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 7d56c56..5fe4c84 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -24,6 +24,6 @@ "web": { "favicon": "./assets/favicon.png" }, - "plugins": ["expo-router"] + "plugins": ["expo-router", "expo-web-browser"] } } diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index c5990ec..29fcd27 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -1,14 +1,50 @@ -import { Stack } from 'expo-router'; +import { Stack, useRouter } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; +import { Alert, Pressable, Text } from 'react-native'; + +import { AuthProvider, useAuth } from '../lib/auth'; + +const LogoutButton = () => { + const { session, signOut } = useAuth(); + const router = useRouter(); + if (!session) return null; + + return ( + { + try { + await signOut(); + router.replace('/login'); + } catch (err) { + Alert.alert('로그아웃 실패', err instanceof Error ? err.message : '다시 시도해 주세요.'); + } + }} + accessibilityRole='button' + accessibilityLabel='로그아웃' + style={{ paddingHorizontal: 8 }} + > + 로그아웃 + + ); +}; export default function RootLayout() { return ( - <> + - + + + + , + }} + /> - + ); } diff --git a/apps/mobile/app/auth/callback.tsx b/apps/mobile/app/auth/callback.tsx new file mode 100644 index 0000000..6029cb0 --- /dev/null +++ b/apps/mobile/app/auth/callback.tsx @@ -0,0 +1,28 @@ +import { Redirect } from 'expo-router'; +import { ActivityIndicator, StyleSheet, Text, View } from 'react-native'; + +import { useAuth } from '../../lib/auth'; + +export default function AuthCallbackScreen() { + const { session, loading } = useAuth(); + + if (!loading && session) { + return ; + } + + if (!loading && !session) { + return ; + } + + return ( + + + 로그인 처리 중… + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12, backgroundColor: '#fff' }, + label: { fontSize: 14, color: '#666' }, +}); diff --git a/apps/mobile/app/index.tsx b/apps/mobile/app/index.tsx index 872b44c..990fd11 100644 --- a/apps/mobile/app/index.tsx +++ b/apps/mobile/app/index.tsx @@ -1,49 +1,285 @@ -import { APP_NAME, type Market } from '@ticker-journal/shared'; -import { Link } from 'expo-router'; -import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { CreateTickerSchema, type Market, type Ticker } from '@ticker-journal/shared'; +import { Link, Redirect, useFocusEffect } from 'expo-router'; +import { useCallback, useReducer, useRef, useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { ActivityIndicator, Alert, FlatList, Modal, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; +import { z } from 'zod'; -const PLACEHOLDER: Array<{ id: string; market: Market; symbol: string; name: string; summary: string }> = [ - { id: 'aapl', market: 'US', symbol: 'AAPL', name: 'Apple', summary: '샘플 · 메모/링크/매매 연결 예정' }, - { id: '005930', market: 'KR', symbol: '005930', name: '삼성전자', summary: '샘플 · KR은 차트 fallback' }, - { id: 'tsla', market: 'US', symbol: 'TSLA', name: 'Tesla', summary: '샘플 · 비어 있는 타임라인' }, -]; +import { createTicker, deleteTicker, listTickers } from '../lib/api'; +import { useAuth } from '../lib/auth'; + +const MARKETS: Market[] = ['US', 'KR']; + +const formSchema = z.object({ + market: z.enum(['US', 'KR']), + symbol: z.string().min(1, '심볼을 입력해 주세요.'), + name: z.string().optional(), +}); +type FormValues = z.infer; + +type ListState = { tickers: Ticker[]; loading: boolean; error: string | null }; +type ListAction = + | { type: 'LOAD_START' } + | { type: 'LOAD_OK'; tickers: Ticker[] } + | { type: 'LOAD_FAIL'; error: string }; + +const listInitial: ListState = { tickers: [], loading: true, error: null }; + +const listReducer = (state: ListState, action: ListAction): ListState => { + switch (action.type) { + case 'LOAD_START': + return { ...state, loading: true, error: null }; + case 'LOAD_OK': + return { tickers: action.tickers, loading: false, error: null }; + case 'LOAD_FAIL': + return { ...state, loading: false, error: action.error }; + default: + return state; + } +}; export default function WatchlistScreen() { + const { session, loading: authLoading } = useAuth(); + const [list, dispatch] = useReducer(listReducer, listInitial); + const [modalOpen, setModalOpen] = useState(false); + const loadGen = useRef(0); + + const { + control, + handleSubmit, + reset, + watch, + formState: { isSubmitting, errors }, + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { market: 'US', symbol: '', name: '' }, + }); + + const symbol = watch('symbol'); + + const load = useCallback(async () => { + const gen = ++loadGen.current; + dispatch({ type: 'LOAD_START' }); + try { + const next = await listTickers(); + if (gen !== loadGen.current) return; + dispatch({ type: 'LOAD_OK', tickers: next }); + } catch (err) { + if (gen !== loadGen.current) return; + dispatch({ + type: 'LOAD_FAIL', + error: err instanceof Error ? err.message : '목록을 불러오지 못했습니다.', + }); + } + }, []); + + useFocusEffect( + useCallback(() => { + if (!session) return; + void load(); + return () => { + loadGen.current += 1; + }; + }, [load, session]), + ); + + if (authLoading) { + return ( + + + + ); + } + + if (!session) { + return ; + } + + const closeModal = () => { + setModalOpen(false); + reset({ market: 'US', symbol: '', name: '' }); + }; + + const onCreate = handleSubmit(async (data) => { + try { + const parsed = CreateTickerSchema.parse({ + market: data.market, + symbol: data.symbol, + name: data.name?.trim() ? data.name.trim() : null, + }); + await createTicker(parsed); + closeModal(); + await load(); + } catch (err) { + const message = err instanceof Error ? err.message : '종목을 추가하지 못했습니다.'; + const isDuplicate = message.startsWith('이미 추가된 종목'); + Alert.alert(isDuplicate ? '동일 종목' : '추가 실패', message); + } + }); + + const handleDelete = (item: Ticker) => { + Alert.alert('종목 삭제', `${item.symbol} 을(를) 삭제할까요?`, [ + { text: '취소', style: 'cancel' }, + { + text: '삭제', + style: 'destructive', + onPress: () => { + void (async () => { + try { + await deleteTicker(item.id); + await load(); + } catch (err) { + Alert.alert('삭제 실패', err instanceof Error ? err.message : '삭제하지 못했습니다.'); + } + })(); + }, + }, + ]); + }; + return ( - {APP_NAME} - 관심종목 - Phase 0 뼈대 · Supabase 연동 전 로컬 플레이스홀더 - - {PLACEHOLDER.map((item) => ( - - - - {item.symbol} {item.market} - - {item.name} - {item.summary} - - - ))} + + 관심종목 + setModalOpen(true)} + accessibilityRole='button' + accessibilityLabel='종목 추가' + style={styles.addButton} + > + 추가 + + + + {list.loading ? : null} + {list.error ? {list.error} : null} + + {!list.loading && !list.error && list.tickers.length === 0 ? ( + 아직 종목이 없습니다. 추가 버튼으로 첫 종목을 만드세요. + ) : null} + + item.id} + contentContainerStyle={{ gap: 10, paddingBottom: 40 }} + renderItem={({ item }) => ( + + handleDelete(item)} + accessibilityRole='button' + accessibilityLabel={`${item.symbol} 상세`} + > + + {item.symbol} {item.market} + + {item.name ?? '이름 없음'} + 길게 눌러 삭제 + + + )} + /> + + + + + 종목 추가 + ( + + {MARKETS.map((m) => ( + onChange(m)} + style={[styles.chip, value === m && styles.chipActive]} + accessibilityRole='button' + accessibilityLabel={`시장 ${m}`} + > + {m} + + ))} + + )} + /> + ( + + )} + /> + {errors.symbol ? {errors.symbol.message} : null} + ( + + )} + /> + + + 취소 + + + {isSubmitting ? : 저장} + + + + + ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#fff', padding: 20, gap: 12 }, - eyebrow: { fontSize: 12, color: '#666', textTransform: 'uppercase', letterSpacing: 0.6 }, + header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, title: { fontSize: 28, fontWeight: '700', color: '#111' }, - hint: { fontSize: 13, color: '#666', marginBottom: 8 }, - card: { - borderWidth: 1, - borderColor: '#ccc', - borderRadius: 8, - padding: 14, - gap: 4, - }, + addButton: { backgroundColor: '#111', paddingHorizontal: 14, paddingVertical: 8, borderRadius: 8 }, + addButtonText: { color: '#fff', fontWeight: '600' }, + error: { color: '#b91c1c' }, + fieldError: { color: '#b91c1c', fontSize: 12, marginTop: -4 }, + empty: { color: '#666', fontSize: 14, lineHeight: 20 }, + center: { alignItems: 'center', justifyContent: 'center' }, + card: { borderWidth: 1, borderColor: '#ccc', borderRadius: 8, padding: 14, gap: 4 }, symbol: { fontSize: 16, fontWeight: '700', color: '#111' }, market: { fontSize: 12, fontWeight: '500', color: '#666' }, name: { fontSize: 14, color: '#333' }, summary: { fontSize: 12, color: '#666' }, + modalBackdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.35)', justifyContent: 'flex-end' }, + modalCard: { backgroundColor: '#fff', padding: 20, borderTopLeftRadius: 16, borderTopRightRadius: 16, gap: 10 }, + modalTitle: { fontSize: 18, fontWeight: '700' }, + marketRow: { flexDirection: 'row', gap: 8 }, + chip: { borderWidth: 1, borderColor: '#aaa', paddingHorizontal: 12, paddingVertical: 8, borderRadius: 999 }, + chipActive: { backgroundColor: '#111', borderColor: '#111' }, + chipText: { fontSize: 13, color: '#333' }, + chipTextActive: { color: '#fff' }, + input: { borderWidth: 1, borderColor: '#ccc', borderRadius: 8, paddingHorizontal: 12, paddingVertical: 12 }, + modalActions: { flexDirection: 'row', justifyContent: 'flex-end', gap: 8, marginTop: 8 }, + secondaryButton: { paddingHorizontal: 14, paddingVertical: 10 }, + primaryButton: { backgroundColor: '#111', paddingHorizontal: 16, paddingVertical: 10, borderRadius: 8 }, + primaryButtonText: { color: '#fff', fontWeight: '600' }, + disabled: { opacity: 0.5 }, }); diff --git a/apps/mobile/app/login.tsx b/apps/mobile/app/login.tsx new file mode 100644 index 0000000..9c02bab --- /dev/null +++ b/apps/mobile/app/login.tsx @@ -0,0 +1,238 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { Link, Redirect } from 'expo-router'; +import { useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { + ActivityIndicator, + KeyboardAvoidingView, + Platform, + Pressable, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; +import { z } from 'zod'; + +import { useAuth } from '../lib/auth'; + +type AuthMode = 'password' | 'magic'; + +const loginSchema = z.object({ + email: z.string().email('올바른 이메일을 입력해 주세요.'), + password: z.string().optional(), +}); + +type LoginForm = z.infer; + +export default function LoginScreen() { + const { session, configured, signInWithPassword, signInWithMagicLink, signInWithGoogle } = useAuth(); + const [mode, setMode] = useState('password'); + const [result, setResult] = useState<{ message?: string; error?: string }>({}); + const [oauthPending, setOauthPending] = useState(false); + + const { + control, + handleSubmit, + formState: { isSubmitting }, + } = useForm({ + resolver: zodResolver(loginSchema), + defaultValues: { email: '', password: '' }, + }); + + if (session) return ; + + const busy = isSubmitting || oauthPending; + + const onSubmit = handleSubmit(async (data) => { + setResult({}); + try { + if (mode === 'password') { + if (!data.password) { + setResult({ error: '비밀번호를 입력해 주세요.' }); + return; + } + await signInWithPassword(data.email, data.password); + } else { + await signInWithMagicLink(data.email); + setResult({ message: '매직링크를 이메일로 보냈습니다. 메일함에서 링크를 열어 주세요.' }); + } + } catch (err) { + setResult({ error: err instanceof Error ? err.message : '로그인에 실패했습니다.' }); + } + }); + + const handleGoogle = async () => { + if (busy) return; + setResult({}); + setOauthPending(true); + try { + await signInWithGoogle(); + } catch (err) { + setResult({ error: err instanceof Error ? err.message : 'Google 로그인에 실패했습니다.' }); + } finally { + setOauthPending(false); + } + }; + + const switchMode = (next: AuthMode) => { + setMode(next); + setResult({}); + }; + + return ( + + Ticker Journal + 로그인 + + {!configured && ( + + EXPO_PUBLIC_SUPABASE_URL / EXPO_PUBLIC_SUPABASE_KEY 가 비어 있습니다. apps/mobile/.env 를 채운 뒤 Expo를 + 재시작하세요. + + )} + + + switchMode('password')} + style={[styles.tab, mode === 'password' && styles.tabActive]} + accessibilityRole='tab' + > + 이메일/비밀번호 + + switchMode('magic')} + style={[styles.tab, mode === 'magic' && styles.tabActive]} + accessibilityRole='tab' + > + 매직링크 + + + + ( + <> + + {error && {error.message}} + + )} + /> + + {mode === 'password' && ( + ( + <> + + {error && {error.message}} + + )} + /> + )} + + + {isSubmitting ? ( + + ) : ( + {mode === 'password' ? '로그인' : '매직링크 보내기'} + )} + + + {mode === 'password' && ( + + 계정이 없으신가요? 회원가입 + + )} + + + + 또는 + + + + + {oauthPending ? ( + + ) : ( + Google로 계속하기 + )} + + + {result.message && {result.message}} + {result.error && {result.error}} + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: '#fff', padding: 24, justifyContent: 'center', gap: 12 }, + eyebrow: { fontSize: 12, color: '#666', textTransform: 'uppercase', letterSpacing: 0.6 }, + title: { fontSize: 28, fontWeight: '700', color: '#111' }, + hint: { fontSize: 13, color: '#666', lineHeight: 18, marginBottom: 8 }, + tabs: { flexDirection: 'row', borderRadius: 8, borderWidth: 1, borderColor: '#ddd', overflow: 'hidden' }, + tab: { flex: 1, paddingVertical: 10, alignItems: 'center', backgroundColor: '#f5f5f5' }, + tabActive: { backgroundColor: '#111' }, + tabText: { fontSize: 13, fontWeight: '600', color: '#666' }, + tabTextActive: { color: '#fff' }, + input: { + borderWidth: 1, + borderColor: '#ccc', + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 12, + fontSize: 16, + }, + fieldError: { color: '#b91c1c', fontSize: 12, marginTop: -4 }, + button: { backgroundColor: '#111', borderRadius: 8, paddingVertical: 14, alignItems: 'center' }, + buttonDisabled: { opacity: 0.5 }, + buttonText: { color: '#fff', fontWeight: '600' }, + divider: { flexDirection: 'row', alignItems: 'center', gap: 12, marginVertical: 4 }, + dividerLine: { flex: 1, height: 1, backgroundColor: '#ddd' }, + dividerText: { fontSize: 12, color: '#999' }, + googleButton: { + borderWidth: 1, + borderColor: '#ddd', + borderRadius: 8, + paddingVertical: 14, + alignItems: 'center', + backgroundColor: '#fff', + }, + googleButtonText: { color: '#333', fontWeight: '600' }, + link: { alignSelf: 'center', marginTop: 4 }, + linkText: { fontSize: 13, color: '#2563eb' }, + message: { color: '#166534', fontSize: 13, lineHeight: 18 }, + error: { color: '#b91c1c', fontSize: 13, lineHeight: 18 }, +}); diff --git a/apps/mobile/app/signup.tsx b/apps/mobile/app/signup.tsx new file mode 100644 index 0000000..5ef90be --- /dev/null +++ b/apps/mobile/app/signup.tsx @@ -0,0 +1,168 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { Link, Redirect } from 'expo-router'; +import { useState } from 'react'; +import { Controller, useForm } from 'react-hook-form'; +import { + ActivityIndicator, + KeyboardAvoidingView, + Platform, + Pressable, + StyleSheet, + Text, + TextInput, +} from 'react-native'; +import { z } from 'zod'; + +import { useAuth } from '../lib/auth'; + +const signUpSchema = z + .object({ + email: z.string().email('올바른 이메일을 입력해 주세요.'), + password: z.string().min(6, '비밀번호는 6자 이상이어야 합니다.'), + confirm: z.string().min(1, '비밀번호 확인을 입력해 주세요.'), + }) + .refine((d) => d.password === d.confirm, { + message: '비밀번호가 일치하지 않습니다.', + path: ['confirm'], + }); + +type SignUpForm = z.infer; + +export default function SignUpScreen() { + const { session, configured, signUp } = useAuth(); + const [result, setResult] = useState<{ message?: string; error?: string }>({}); + + const { + control, + handleSubmit, + formState: { isSubmitting }, + } = useForm({ + resolver: zodResolver(signUpSchema), + defaultValues: { email: '', password: '', confirm: '' }, + }); + + if (session) return ; + + const onSubmit = handleSubmit(async (data) => { + setResult({}); + try { + await signUp(data.email, data.password); + setResult({ message: '확인 이메일을 보냈습니다. 메일함에서 링크를 열어 주세요.' }); + } catch (err) { + setResult({ error: err instanceof Error ? err.message : '회원가입에 실패했습니다.' }); + } + }); + + return ( + + Ticker Journal + 회원가입 + + {!configured && ( + + EXPO_PUBLIC_SUPABASE_URL / EXPO_PUBLIC_SUPABASE_KEY 가 비어 있습니다. apps/mobile/.env 를 채운 뒤 Expo를 + 재시작하세요. + + )} + + ( + <> + + {error && {error.message}} + + )} + /> + + ( + <> + + {error && {error.message}} + + )} + /> + + ( + <> + + {error && {error.message}} + + )} + /> + + + {isSubmitting ? : 회원가입} + + + + 이미 계정이 있으신가요? 로그인 + + + {result.message && {result.message}} + {result.error && {result.error}} + + ); +} + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: '#fff', padding: 24, justifyContent: 'center', gap: 12 }, + eyebrow: { fontSize: 12, color: '#666', textTransform: 'uppercase', letterSpacing: 0.6 }, + title: { fontSize: 28, fontWeight: '700', color: '#111' }, + hint: { fontSize: 13, color: '#666', lineHeight: 18, marginBottom: 8 }, + input: { + borderWidth: 1, + borderColor: '#ccc', + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 12, + fontSize: 16, + }, + fieldError: { color: '#b91c1c', fontSize: 12, marginTop: -4 }, + button: { backgroundColor: '#111', borderRadius: 8, paddingVertical: 14, alignItems: 'center' }, + buttonDisabled: { opacity: 0.5 }, + buttonText: { color: '#fff', fontWeight: '600' }, + link: { alignSelf: 'center', marginTop: 4 }, + linkText: { fontSize: 13, color: '#2563eb' }, + message: { color: '#166534', fontSize: 13, lineHeight: 18 }, + error: { color: '#b91c1c', fontSize: 13, lineHeight: 18 }, +}); diff --git a/apps/mobile/app/ticker/[id].tsx b/apps/mobile/app/ticker/[id].tsx index 46c4b14..e19ff9e 100644 --- a/apps/mobile/app/ticker/[id].tsx +++ b/apps/mobile/app/ticker/[id].tsx @@ -1,51 +1,344 @@ -import { TimelineFilterSchema } from '@ticker-journal/shared'; -import { useLocalSearchParams } from 'expo-router'; -import { StyleSheet, Text, View } from 'react-native'; +import { + type CreateEntryInput, + CreateEntrySchema, + type Entry, + type Ticker, + type TimelineFilter, + TimelineFilterSchema, +} from '@ticker-journal/shared'; +import { Redirect, useFocusEffect, useLocalSearchParams } from 'expo-router'; +import { useCallback, useMemo, useRef, useState } from 'react'; +import { ActivityIndicator, Alert, FlatList, Modal, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; import { WebView } from 'react-native-webview'; +import { createEntry, deleteEntry, getTicker, listEntries } from '../../lib/api'; +import { useAuth } from '../../lib/auth'; +import { buildChartHtml } from '../../lib/chart'; + const FILTERS = TimelineFilterSchema.options; export default function TickerDetailScreen() { + const { session, loading: authLoading } = useAuth(); const { id } = useLocalSearchParams<{ id: string }>(); - const symbol = (id ?? 'AAPL').toUpperCase(); - const chartHtml = ` -
-
WebView chart placeholder
-
${symbol}
-
TradingView embed comes in Phase 0
-
- `; + const tickerId = id ?? ''; + + const [ticker, setTicker] = useState(null); + const [entries, setEntries] = useState([]); + const [filter, setFilter] = useState('all'); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + const [entryType, setEntryType] = useState<'memo' | 'link' | 'trade'>('memo'); + const [body, setBody] = useState(''); + const [url, setUrl] = useState(''); + const [title, setTitle] = useState(''); + const [note, setNote] = useState(''); + const [side, setSide] = useState<'buy' | 'sell'>('buy'); + const [reason, setReason] = useState(''); + const [saving, setSaving] = useState(false); + const loadGen = useRef(0); + + const chartHtml = useMemo(() => { + if (!ticker) return ''; + return buildChartHtml(ticker.market, ticker.symbol); + }, [ticker]); + + const load = useCallback(async () => { + if (!tickerId) return; + const gen = ++loadGen.current; + setLoading(true); + setError(null); + try { + const [nextTicker, nextEntries] = await Promise.all([getTicker(tickerId), listEntries(tickerId, filter)]); + if (gen !== loadGen.current) return; + setTicker(nextTicker); + setEntries(nextEntries); + } catch (err) { + if (gen !== loadGen.current) return; + setError(err instanceof Error ? err.message : '상세를 불러오지 못했습니다.'); + } finally { + if (gen === loadGen.current) setLoading(false); + } + }, [filter, tickerId]); + + useFocusEffect( + useCallback(() => { + if (!session) return; + void load(); + return () => { + loadGen.current += 1; + }; + }, [load, session]), + ); + + if (authLoading) { + return ( + + + + ); + } + + if (!session) { + return ; + } + + const closeModal = () => { + setModalOpen(false); + setEntryType('memo'); + setBody(''); + setUrl(''); + setTitle(''); + setNote(''); + setSide('buy'); + setReason(''); + }; + + const handleCreate = async () => { + if (!tickerId) return; + setSaving(true); + try { + let input: CreateEntryInput; + if (entryType === 'memo') { + input = CreateEntrySchema.parse({ type: 'memo', ticker_id: tickerId, body }); + } else if (entryType === 'link') { + input = CreateEntrySchema.parse({ + type: 'link', + ticker_id: tickerId, + url, + title: title.trim() ? title : null, + note: note.trim() ? note : null, + }); + } else { + input = CreateEntrySchema.parse({ + type: 'trade', + ticker_id: tickerId, + side, + traded_at: new Date().toISOString(), + reason: reason.trim() ? reason : null, + }); + } + await createEntry(input); + closeModal(); + await load(); + } catch (err) { + Alert.alert('저장 실패', err instanceof Error ? err.message : '엔트리를 저장하지 못했습니다.'); + } finally { + setSaving(false); + } + }; + + const handleDelete = (entry: Entry) => { + Alert.alert('엔트리 삭제', '이 기록을 삭제할까요?', [ + { text: '취소', style: 'cancel' }, + { + text: '삭제', + style: 'destructive', + onPress: () => { + void (async () => { + try { + await deleteEntry(entry.id); + await load(); + } catch (err) { + Alert.alert('삭제 실패', err instanceof Error ? err.message : '삭제하지 못했습니다.'); + } + })(); + }, + }, + ]); + }; return ( - - - + {ticker ? ( + + + + ) : null} - {FILTERS.map((filter) => ( - - {filter} - + {FILTERS.map((item) => ( + setFilter(item)} + style={[styles.chip, filter === item && styles.chipActive]} + accessibilityRole='button' + accessibilityLabel={`필터 ${item}`} + > + {item} + ))} - - 타임라인 플레이스홀더 - memo / link / trade CRUD는 Supabase 스키마 이후에 연결합니다. - + {loading ? : null} + {error ? {error} : null} + + item.id} + contentContainerStyle={{ paddingHorizontal: 12, gap: 8, paddingBottom: 100 }} + ListEmptyComponent={!loading && !error ? 타임라인이 비어 있습니다. : null} + renderItem={({ item }) => ( + handleDelete(item)} + accessibilityRole='button' + accessibilityLabel='엔트리' + > + {item.type.toUpperCase()} + {formatEntry(item)} + 길게 눌러 삭제 + + )} + /> + + setModalOpen(true)} + accessibilityRole='button' + accessibilityLabel='엔트리 추가' + > + + + + + + + + 엔트리 추가 + + {(['memo', 'link', 'trade'] as const).map((type) => ( + setEntryType(type)} + style={[styles.chip, entryType === type && styles.chipActive]} + > + {type} + + ))} + + + {entryType === 'memo' ? ( + + ) : null} + + {entryType === 'link' ? ( + <> + + + + + ) : null} + + {entryType === 'trade' ? ( + <> + + {(['buy', 'sell'] as const).map((value) => ( + setSide(value)} + style={[styles.chip, side === value && styles.chipActive]} + > + {value} + + ))} + + + + ) : null} + + + + 취소 + + + {saving ? : 저장} + + + + + ); } +const formatTradedAt = (value: string): string => { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString('ko-KR'); +}; + +const formatEntry = (entry: Entry): string => { + if (entry.type === 'memo') return entry.body; + if (entry.type === 'link') return `${entry.title ?? entry.url}\n${entry.url}`; + return `${entry.side.toUpperCase()} · ${formatTradedAt(entry.traded_at)}${entry.reason ? `\n${entry.reason}` : ''}`; +}; + const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#fff' }, - chart: { height: 180, borderBottomWidth: 1, borderBottomColor: '#ddd' }, + center: { alignItems: 'center', justifyContent: 'center' }, + chart: { height: 200, borderBottomWidth: 1, borderBottomColor: '#ddd' }, webview: { flex: 1 }, filters: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, padding: 12 }, chip: { borderWidth: 1, borderColor: '#aaa', paddingHorizontal: 10, paddingVertical: 6, borderRadius: 999 }, + chipActive: { backgroundColor: '#111', borderColor: '#111' }, chipText: { fontSize: 12, color: '#333' }, - timelineItem: { marginHorizontal: 12, borderWidth: 1, borderColor: '#ccc', borderRadius: 8, padding: 12, gap: 6 }, + chipTextActive: { color: '#fff' }, + error: { color: '#b91c1c', paddingHorizontal: 12 }, + empty: { color: '#666', padding: 12 }, + timelineItem: { + borderWidth: 1, + borderColor: '#ccc', + borderRadius: 8, + padding: 12, + gap: 6, + }, itemTitle: { fontSize: 14, fontWeight: '700', color: '#111' }, itemBody: { fontSize: 13, color: '#555', lineHeight: 18 }, + itemMeta: { fontSize: 11, color: '#888' }, + fab: { + position: 'absolute', + right: 20, + bottom: 28, + width: 56, + height: 56, + borderRadius: 28, + backgroundColor: '#111', + alignItems: 'center', + justifyContent: 'center', + }, + fabText: { color: '#fff', fontSize: 28, lineHeight: 30, fontWeight: '600' }, + modalBackdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.35)', justifyContent: 'flex-end' }, + modalCard: { backgroundColor: '#fff', padding: 20, borderTopLeftRadius: 16, borderTopRightRadius: 16, gap: 10 }, + modalTitle: { fontSize: 18, fontWeight: '700' }, + input: { borderWidth: 1, borderColor: '#ccc', borderRadius: 8, paddingHorizontal: 12, paddingVertical: 12 }, + multiline: { minHeight: 88, textAlignVertical: 'top' }, + modalActions: { flexDirection: 'row', justifyContent: 'flex-end', gap: 8, marginTop: 8 }, + secondaryButton: { paddingHorizontal: 14, paddingVertical: 10 }, + primaryButton: { backgroundColor: '#111', paddingHorizontal: 16, paddingVertical: 10, borderRadius: 8 }, + primaryButtonText: { color: '#fff', fontWeight: '600' }, + disabled: { opacity: 0.5 }, }); diff --git a/apps/mobile/jest.config.js b/apps/mobile/jest.config.js index 0e60b38..a0dff79 100644 --- a/apps/mobile/jest.config.js +++ b/apps/mobile/jest.config.js @@ -8,4 +8,7 @@ module.exports = { transformIgnorePatterns: [ 'node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@sentry/react-native|native-base|react-native-svg|react-native-webview|@ticker-journal/shared)', ], + collectCoverageFrom: ['lib/chart.ts'], + coverageDirectory: 'coverage', + coverageReporters: ['text', 'text-summary', 'json-summary', 'html'], }; diff --git a/apps/mobile/lib/api.ts b/apps/mobile/lib/api.ts new file mode 100644 index 0000000..96d834a --- /dev/null +++ b/apps/mobile/lib/api.ts @@ -0,0 +1,171 @@ +import { + type CreateEntryInput, + CreateEntrySchema, + type CreateTickerInput, + CreateTickerSchema, + type Entry, + type EntryInsert, + type EntryRow, + EntrySchema, + type Ticker, + TickerSchema, + type TimelineFilter, +} from '@ticker-journal/shared'; + +import { supabase } from './supabase'; + +export const listTickers = async (): Promise => { + const { data, error } = await supabase.from('tickers').select('*').order('created_at', { ascending: false }); + if (error) throw error; + return (data ?? []).map((row) => TickerSchema.parse(row)); +}; + +export const createTicker = async (input: CreateTickerInput): Promise => { + const parsed = CreateTickerSchema.parse(input); + const { + data: { user }, + error: userError, + } = await supabase.auth.getUser(); + if (userError) throw userError; + if (!user) throw new Error('로그인이 필요합니다.'); + + const { data, error } = await supabase + .from('tickers') + .insert({ + user_id: user.id, + market: parsed.market, + symbol: parsed.symbol, + name: parsed.name ?? null, + }) + .select('*') + .single(); + + if (error) { + if (error.code === '23505') { + throw new Error(`이미 추가된 종목입니다. (${parsed.market} ${parsed.symbol})`); + } + throw error; + } + return TickerSchema.parse(data); +}; + +export const deleteTicker = async (id: string): Promise => { + const { error } = await supabase.from('tickers').delete().eq('id', id); + if (error) throw error; +}; + +export const getTicker = async (id: string): Promise => { + const { data, error } = await supabase.from('tickers').select('*').eq('id', id).single(); + if (error) throw error; + return TickerSchema.parse(data); +}; + +export const listEntries = async (tickerId: string, filter: TimelineFilter = 'all'): Promise => { + let query = supabase.from('entries').select('*').eq('ticker_id', tickerId).order('created_at', { ascending: false }); + if (filter !== 'all') { + query = query.eq('type', filter); + } + const { data, error } = await query; + if (error) throw error; + return (data ?? []).map((row) => EntrySchema.parse(normalizeEntryRow(row))); +}; + +export const createEntry = async (input: CreateEntryInput): Promise => { + const parsed = CreateEntrySchema.parse(input); + const { + data: { user }, + error: userError, + } = await supabase.auth.getUser(); + if (userError) throw userError; + if (!user) throw new Error('로그인이 필요합니다.'); + + const payload = toInsertPayload(parsed, user.id); + const { data, error } = await supabase.from('entries').insert(payload).select('*').single(); + if (error) throw error; + return EntrySchema.parse(normalizeEntryRow(data)); +}; + +export const deleteEntry = async (id: string): Promise => { + const { error } = await supabase.from('entries').delete().eq('id', id); + if (error) throw error; +}; + +const toInsertPayload = (parsed: CreateEntryInput, userId: string): EntryInsert => { + const base: EntryInsert = { + user_id: userId, + ticker_id: parsed.ticker_id, + type: parsed.type, + body: null, + url: null, + title: null, + note: null, + side: null, + traded_at: null, + price: null, + qty: null, + reason: null, + }; + + if (parsed.type === 'memo') { + return { ...base, body: parsed.body }; + } + if (parsed.type === 'link') { + return { + ...base, + url: parsed.url, + title: parsed.title ?? null, + note: parsed.note ?? null, + }; + } + return { + ...base, + side: parsed.side, + traded_at: parsed.traded_at, + price: parsed.price ?? null, + qty: parsed.qty ?? null, + reason: parsed.reason ?? null, + }; +}; + +const normalizeEntryRow = (row: EntryRow): Entry => { + if (row.type === 'memo') { + return { + id: row.id, + user_id: row.user_id, + ticker_id: row.ticker_id, + created_at: row.created_at, + updated_at: row.updated_at, + type: 'memo', + body: row.body ?? '', + }; + } + if (row.type === 'link') { + return { + id: row.id, + user_id: row.user_id, + ticker_id: row.ticker_id, + created_at: row.created_at, + updated_at: row.updated_at, + type: 'link', + url: row.url ?? '', + title: row.title, + note: row.note, + }; + } + if (row.type !== 'trade') { + throw new Error(`알 수 없는 엔트리 타입: ${String(row.type)}`); + } + return { + id: row.id, + user_id: row.user_id, + ticker_id: row.ticker_id, + created_at: row.created_at, + updated_at: row.updated_at, + type: 'trade', + side: row.side === 'sell' ? 'sell' : 'buy', + traded_at: row.traded_at ?? '', + price: row.price, + qty: row.qty, + reason: row.reason, + }; +}; diff --git a/apps/mobile/lib/auth-callback.ts b/apps/mobile/lib/auth-callback.ts new file mode 100644 index 0000000..00bc0d2 --- /dev/null +++ b/apps/mobile/lib/auth-callback.ts @@ -0,0 +1,26 @@ +export type AuthCallbackPayload = + | { type: 'code'; code: string } + | { type: 'tokens'; access_token: string; refresh_token: string }; + +export const parseAuthCallbackUrl = (url: string): AuthCallbackPayload | null => { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return null; + } + + const code = parsed.searchParams.get('code'); + if (code) { + return { type: 'code', code }; + } + + const hashParams = new URLSearchParams(parsed.hash.startsWith('#') ? parsed.hash.slice(1) : parsed.hash); + const accessToken = hashParams.get('access_token') ?? parsed.searchParams.get('access_token'); + const refreshToken = hashParams.get('refresh_token') ?? parsed.searchParams.get('refresh_token'); + if (accessToken && refreshToken) { + return { type: 'tokens', access_token: accessToken, refresh_token: refreshToken }; + } + + return null; +}; diff --git a/apps/mobile/lib/auth.tsx b/apps/mobile/lib/auth.tsx new file mode 100644 index 0000000..e4c11de --- /dev/null +++ b/apps/mobile/lib/auth.tsx @@ -0,0 +1,157 @@ +import type { Session, User } from '@supabase/supabase-js'; +import * as Linking from 'expo-linking'; +import * as WebBrowser from 'expo-web-browser'; +import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from 'react'; + +import { parseAuthCallbackUrl } from './auth-callback'; +import { isSupabaseConfigured, supabase } from './supabase'; + +WebBrowser.maybeCompleteAuthSession(); + +type AuthContextValue = { + session: Session | null; + user: User | null; + loading: boolean; + configured: boolean; + signUp: (email: string, password: string) => Promise; + signInWithPassword: (email: string, password: string) => Promise; + signInWithMagicLink: (email: string) => Promise; + signInWithGoogle: () => Promise; + signOut: () => Promise; +}; + +const AuthContext = createContext(null); + +const consumeAuthCallbackUrl = async (url: string | null) => { + if (!url || !isSupabaseConfigured) return; + const payload = parseAuthCallbackUrl(url); + if (!payload) return; + + if (payload.type === 'code') { + const { error } = await supabase.auth.exchangeCodeForSession(payload.code); + if (error) throw error; + return; + } + + const { error } = await supabase.auth.setSession({ + access_token: payload.access_token, + refresh_token: payload.refresh_token, + }); + if (error) throw error; +}; + +export const AuthProvider = ({ children }: { children: ReactNode }) => { + const [session, setSession] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!isSupabaseConfigured) { + setLoading(false); + return; + } + + let mounted = true; + + const applyUrl = async (url: string | null) => { + try { + await consumeAuthCallbackUrl(url); + } catch { + // 콜백 실패 시 기존 세션 유지. 로그인 화면에서 다시 시도. + } + }; + + const init = async () => { + const { data } = await supabase.auth.getSession(); + if (!mounted) return; + setSession(data.session); + await applyUrl(await Linking.getInitialURL()); + if (mounted) setLoading(false); + }; + + void init(); + + const { data: subscription } = supabase.auth.onAuthStateChange((_event, next) => { + setSession(next); + }); + const urlSub = Linking.addEventListener('url', ({ url }) => { + void applyUrl(url); + }); + + return () => { + mounted = false; + subscription.subscription.unsubscribe(); + urlSub.remove(); + }; + }, []); + + const value = useMemo( + () => ({ + session, + user: session?.user ?? null, + loading, + configured: isSupabaseConfigured, + signUp: async (email: string, password: string) => { + if (!isSupabaseConfigured) + throw new Error('EXPO_PUBLIC_SUPABASE_URL / EXPO_PUBLIC_SUPABASE_KEY 를 설정하세요.'); + const { error } = await supabase.auth.signUp({ + email: email.trim(), + password, + options: { emailRedirectTo: Linking.createURL('auth/callback') }, + }); + if (error) throw error; + }, + signInWithPassword: async (email: string, password: string) => { + if (!isSupabaseConfigured) + throw new Error('EXPO_PUBLIC_SUPABASE_URL / EXPO_PUBLIC_SUPABASE_KEY 를 설정하세요.'); + const { error } = await supabase.auth.signInWithPassword({ + email: email.trim(), + password, + }); + if (error) throw error; + }, + signInWithMagicLink: async (email: string) => { + if (!isSupabaseConfigured) + throw new Error('EXPO_PUBLIC_SUPABASE_URL / EXPO_PUBLIC_SUPABASE_KEY 를 설정하세요.'); + const { error } = await supabase.auth.signInWithOtp({ + email: email.trim(), + options: { + emailRedirectTo: Linking.createURL('auth/callback'), + }, + }); + if (error) throw error; + }, + signInWithGoogle: async () => { + if (!isSupabaseConfigured) + throw new Error('EXPO_PUBLIC_SUPABASE_URL / EXPO_PUBLIC_SUPABASE_KEY 를 설정하세요.'); + const redirectTo = Linking.createURL('auth/callback'); + const { data, error } = await supabase.auth.signInWithOAuth({ + provider: 'google', + options: { + redirectTo, + skipBrowserRedirect: true, + }, + }); + if (error) throw error; + if (!data.url) throw new Error('Google 로그인 URL을 받지 못했습니다.'); + + const result = await WebBrowser.openAuthSessionAsync(data.url, redirectTo); + if (result.type === 'success') { + await consumeAuthCallbackUrl(result.url); + } + }, + signOut: async () => { + const { error } = await supabase.auth.signOut(); + if (error) throw error; + }, + }), + [loading, session], + ); + + return {children}; +}; + +export const useAuth = (): AuthContextValue => { + const ctx = useContext(AuthContext); + if (!ctx) throw new Error('useAuth must be used within AuthProvider'); + return ctx; +}; diff --git a/apps/mobile/lib/chart.ts b/apps/mobile/lib/chart.ts new file mode 100644 index 0000000..fd777ae --- /dev/null +++ b/apps/mobile/lib/chart.ts @@ -0,0 +1,61 @@ +import type { Market } from '@ticker-journal/shared'; + +export const buildChartHtml = (market: Market, symbol: string): string => { + if (market === 'KR') { + return ` + + + + + + +
+
KR 차트 fallback
+
${escapeHtml(symbol)}
+
TradingView KR 심볼 embed는 Phase 0에서 안정성 이슈로 안내 UI만 제공합니다.
+
+ +`; + } + + const tvSymbol = JSON.stringify(symbol.toUpperCase()).replaceAll('<', '\\u003c'); + return ` + + + + + + +
+ + + +`; +}; + +const escapeHtml = (value: string): string => + value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"'); diff --git a/apps/mobile/lib/supabase.ts b/apps/mobile/lib/supabase.ts new file mode 100644 index 0000000..80ba0fc --- /dev/null +++ b/apps/mobile/lib/supabase.ts @@ -0,0 +1,47 @@ +import 'react-native-url-polyfill/auto'; + +import { createClient } from '@supabase/supabase-js'; +import type { Database } from '@ticker-journal/shared'; +import * as SecureStore from 'expo-secure-store'; +import { Platform } from 'react-native'; + +const ExpoSecureStoreAdapter = { + getItem: (key: string) => { + if (Platform.OS === 'web') { + return globalThis.localStorage?.getItem(key) ?? null; + } + return SecureStore.getItemAsync(key); + }, + setItem: (key: string, value: string) => { + if (Platform.OS === 'web') { + globalThis.localStorage?.setItem(key, value); + return; + } + return SecureStore.setItemAsync(key, value); + }, + removeItem: (key: string) => { + if (Platform.OS === 'web') { + globalThis.localStorage?.removeItem(key); + return; + } + return SecureStore.deleteItemAsync(key); + }, +}; + +const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL ?? ''; +const supabaseKey = process.env.EXPO_PUBLIC_SUPABASE_KEY ?? process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY ?? ''; + +export const isSupabaseConfigured = Boolean(supabaseUrl && supabaseKey); + +export const supabase = createClient( + supabaseUrl || 'https://placeholder.supabase.co', + supabaseKey || 'placeholder', + { + auth: { + storage: ExpoSecureStoreAdapter, + autoRefreshToken: true, + persistSession: true, + detectSessionInUrl: false, + }, + }, +); diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 6ad205d..527ae22 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -3,18 +3,25 @@ "version": "1.0.0", "main": "expo-router/entry", "dependencies": { + "@hookform/resolvers": "^5.9.1", + "@supabase/supabase-js": "^2.112.3", "@ticker-journal/shared": "workspace:*", "expo": "~57.0.12", "expo-constants": "~57.0.10", "expo-linking": "~57.0.5", "expo-router": "~57.0.12", + "expo-secure-store": "^57.0.1", "expo-status-bar": "~57.0.1", + "expo-web-browser": "~57.0.2", "react": "19.2.3", + "react-hook-form": "^7.85.0", "react-native": "0.86.2", "react-native-gesture-handler": "~2.32.0", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "~4.26.2", - "react-native-webview": "13.16.1" + "react-native-url-polyfill": "^4.0.0", + "react-native-webview": "13.16.1", + "zod": "^3.25.76" }, "devDependencies": { "@testing-library/react-native": "^13.2.0", @@ -32,7 +39,8 @@ "web": "expo start --web", "typecheck": "tsc --noEmit", "lint": "biome lint .", - "test": "jest" + "test": "jest", + "test:coverage": "jest --coverage" }, "private": true } diff --git a/apps/web/e2e/home.spec.ts b/apps/web/e2e/home.spec.ts index 8d226b5..f1fd119 100644 --- a/apps/web/e2e/home.spec.ts +++ b/apps/web/e2e/home.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from '@playwright/test'; -test('home shows archive scaffold', async ({ page }) => { +test('홈에 아카이브 스캐폴드가 보인다', async ({ page }) => { await page.goto('/'); - await expect(page.getByRole('heading', { name: '웹 아카이브 뼈대' })).toBeVisible(); + await expect(page.getByRole('heading', { name: '웹 아카이브' })).toBeVisible(); await expect(page.getByText('Ticker Journal')).toBeVisible(); }); diff --git a/apps/web/e2e/login.spec.ts b/apps/web/e2e/login.spec.ts new file mode 100644 index 0000000..742dfcd --- /dev/null +++ b/apps/web/e2e/login.spec.ts @@ -0,0 +1,14 @@ +import { expect, test } from '@playwright/test'; + +test('로그인 페이지가 열린다', async ({ page }) => { + await page.goto('/login'); + await expect(page.getByRole('heading', { name: '로그인' })).toBeVisible(); + await expect(page.getByLabel('이메일')).toBeVisible(); + await expect(page.getByRole('button', { name: '로그인' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Google로 계속하기' })).toBeVisible(); +}); + +test('콜백 실패 시 안내를 보여준다', async ({ page }) => { + await page.goto('/login?error=auth'); + await expect(page.getByText('로그인에 실패했습니다')).toBeVisible(); +}); diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index bda2bde..d933830 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,8 +1,18 @@ import type { NextConfig } from 'next'; +import Sonda from 'sonda/next'; + +const withSonda = Sonda({ + enabled: process.env.ANALYZE === 'true', + open: false, + gzip: true, + format: 'html', +}); const nextConfig: NextConfig = { transpilePackages: ['@ticker-journal/shared'], allowedDevOrigins: ['127.0.0.1'], + // Sonda는 source map 기반이라 production source maps 필요 + productionBrowserSourceMaps: process.env.ANALYZE === 'true', }; -export default nextConfig; +export default withSonda(nextConfig); diff --git a/apps/web/package.json b/apps/web/package.json index 0d95f8d..2e730da 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,14 +10,18 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", + "test:coverage": "vitest run --coverage", "test:e2e": "playwright test", - "test:e2e:ui": "playwright test --ui" + "test:e2e:ui": "playwright test --ui", + "analyze": "ANALYZE=true next build --webpack" }, "dependencies": { + "@supabase/ssr": "^0.12.4", + "@supabase/supabase-js": "^2.112.3", + "@ticker-journal/shared": "workspace:*", "next": "16.3.0", "react": "19.2.3", - "react-dom": "19.2.3", - "@ticker-journal/shared": "workspace:*" + "react-dom": "19.2.3" }, "devDependencies": { "@playwright/test": "^1.54.2", @@ -29,7 +33,9 @@ "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^4.7.0", + "@vitest/coverage-v8": "^3.2.7", "jsdom": "^26.1.0", + "sonda": "0.14.0", "tailwindcss": "^4", "typescript": "~7.0.2", "vitest": "^3.2.4" diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index bde837e..cc6a99f 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -10,7 +10,7 @@ export default defineConfig({ trace: 'on-first-retry', }, webServer: { - command: 'pnpm dev', + command: 'pnpm -w --filter @ticker-journal/shared build && pnpm dev', url: 'http://127.0.0.1:3000', reuseExistingServer: !process.env.CI, cwd: __dirname, diff --git a/apps/web/src/app/auth/callback/redirect.test.ts b/apps/web/src/app/auth/callback/redirect.test.ts new file mode 100644 index 0000000..ea1c2b5 --- /dev/null +++ b/apps/web/src/app/auth/callback/redirect.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveAuthCallbackPath } from './redirect'; + +describe('resolveAuthCallbackPath', () => { + it('교환에 성공하면 next 경로로 보낸다', () => { + expect( + resolveAuthCallbackPath({ + code: 'abc', + exchangeOk: true, + next: '/tickers', + }), + ).toBe('/tickers'); + }); + + it('next가 없으면 홈으로 보낸다', () => { + expect(resolveAuthCallbackPath({ code: 'abc', exchangeOk: true })).toBe('/'); + }); + + it('코드가 없으면 로그인 에러로 보낸다', () => { + expect(resolveAuthCallbackPath({ code: null, exchangeOk: false })).toBe('/login?error=auth'); + }); + + it('교환에 실패하면 로그인 에러로 보낸다', () => { + expect(resolveAuthCallbackPath({ code: 'abc', exchangeOk: false })).toBe('/login?error=auth'); + }); + + it('외부 URL next는 홈으로 보낸다', () => { + expect( + resolveAuthCallbackPath({ + code: 'abc', + exchangeOk: true, + next: 'https://evil.example', + }), + ).toBe('/'); + }); + + it('프로토콜 상대 next(//)는 홈으로 보낸다', () => { + expect( + resolveAuthCallbackPath({ + code: 'abc', + exchangeOk: true, + next: '//evil.example', + }), + ).toBe('/'); + }); + + it('역슬래시 next(/\\)는 홈으로 보낸다', () => { + expect( + resolveAuthCallbackPath({ + code: 'abc', + exchangeOk: true, + next: '/\\evil.example', + }), + ).toBe('/'); + }); + + it('개행이 포함된 next(/\\n/…)는 홈으로 보낸다', () => { + const path = resolveAuthCallbackPath({ + code: 'abc', + exchangeOk: true, + next: '/\n/evil.example', + }); + expect(path).toBe('/'); + const url = new URL(path, 'https://app.example'); + expect(url.origin).toBe('https://app.example'); + expect(url.pathname).toBe('/'); + }); +}); diff --git a/apps/web/src/app/auth/callback/redirect.ts b/apps/web/src/app/auth/callback/redirect.ts new file mode 100644 index 0000000..f413d85 --- /dev/null +++ b/apps/web/src/app/auth/callback/redirect.ts @@ -0,0 +1,33 @@ +const LOGIN_ERROR_PATH = '/login?error=auth'; + +/** C0(0x00–0x1F) · DEL(0x7F). `new URL`이 개행 등으로 경로를 깨뜨리는 케이스 차단. */ +const hasForbiddenControlChars = (value: string): boolean => { + for (let i = 0; i < value.length; i++) { + const code = value.charCodeAt(i); + if (code <= 0x1f || code === 0x7f) return true; + } + return false; +}; + +/** 상대 경로만 허용. `//host`, `/\host`, 제어문자 등 open-redirect 패턴은 거부. */ +export const isSafeNextPath = (next: string): boolean => + !hasForbiddenControlChars(next) && + next.startsWith('/') && + !next.startsWith('//') && + !next.startsWith('/\\') && + !next.includes('\\'); + +export const resolveAuthCallbackPath = ({ + code, + exchangeOk, + next = '/', +}: { + code: string | null; + exchangeOk: boolean; + next?: string; +}): string => { + if (code && exchangeOk) { + return isSafeNextPath(next) ? next : '/'; + } + return LOGIN_ERROR_PATH; +}; diff --git a/apps/web/src/app/auth/callback/route.ts b/apps/web/src/app/auth/callback/route.ts new file mode 100644 index 0000000..2dcc224 --- /dev/null +++ b/apps/web/src/app/auth/callback/route.ts @@ -0,0 +1,53 @@ +import { createServerClient } from '@supabase/ssr'; +import type { Database } from '@ticker-journal/shared'; +import { type NextRequest, NextResponse } from 'next/server'; + +import { applyAuthCacheHeaders } from '@/lib/supabase/auth-cache-headers'; +import { getSupabaseEnv } from '@/lib/supabase/env'; + +import { resolveAuthCallbackPath } from './redirect'; + +export const GET = async (request: NextRequest) => { + const { searchParams, origin } = request.nextUrl; + const code = searchParams.get('code'); + const next = searchParams.get('next') ?? '/'; + + let exchangeOk = false; + const cookiesToSet: { name: string; value: string; options: Parameters[2] }[] = []; + const responseHeaders: Record = {}; + + if (code) { + const { url, key, configured } = getSupabaseEnv(); + if (configured) { + const supabase = createServerClient(url, key, { + cookies: { + getAll() { + return request.cookies.getAll(); + }, + setAll(toSet, headers) { + for (const { name, value, options } of toSet) { + cookiesToSet.push({ name, value, options }); + } + Object.assign(responseHeaders, headers); + }, + }, + }); + const { error } = await supabase.auth.exchangeCodeForSession(code); + exchangeOk = !error; + if (error) { + console.error('auth callback exchange failed:', error.message); + } + } + } + + const response = applyAuthCacheHeaders( + NextResponse.redirect(new URL(resolveAuthCallbackPath({ code, exchangeOk, next }), origin)), + ); + for (const { name, value, options } of cookiesToSet) { + response.cookies.set(name, value, options); + } + for (const [headerName, headerValue] of Object.entries(responseHeaders)) { + response.headers.set(headerName, headerValue); + } + return response; +}; diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index c9517b4..0e6476c 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from 'next'; import { Geist, Geist_Mono } from 'next/font/google'; +import type { ReactNode } from 'react'; import './globals.css'; const geistSans = Geist({ @@ -17,7 +18,7 @@ export const metadata: Metadata = { description: '종목 단위 리서치 저널 — 웹 아카이브', }; -export default function RootLayout({ children }: LayoutProps<'/'>) { +export default function RootLayout({ children }: { children: ReactNode }) { return ( {children} diff --git a/apps/web/src/app/login/actions.ts b/apps/web/src/app/login/actions.ts new file mode 100644 index 0000000..f91e864 --- /dev/null +++ b/apps/web/src/app/login/actions.ts @@ -0,0 +1,11 @@ +'use server'; + +import { redirect } from 'next/navigation'; + +import { createClient } from '@/lib/supabase/server'; + +export const signOut = async () => { + const supabase = await createClient(); + await supabase.auth.signOut(); + redirect('/'); +}; diff --git a/apps/web/src/app/login/page.tsx b/apps/web/src/app/login/page.tsx new file mode 100644 index 0000000..f7e9b9e --- /dev/null +++ b/apps/web/src/app/login/page.tsx @@ -0,0 +1,188 @@ +'use client'; + +import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; +import { Suspense, useActionState, useState } from 'react'; + +import { createClient } from '@/lib/supabase/client'; +import { getSupabaseEnv } from '@/lib/supabase/env'; + +type AuthMode = 'password' | 'magic'; +type FormState = { message: string | null; error: string | null }; + +const CALLBACK_ERROR = '로그인에 실패했습니다. 매직링크는 요청한 같은 브라우저에서 열어 주세요.'; +const INITIAL: FormState = { message: null, error: null }; + +export default function LoginPage() { + return ( + + + + ); +} + +function LoginForm() { + const { configured } = getSupabaseEnv(); + const searchParams = useSearchParams(); + const callbackError = searchParams.get('error') === 'auth' ? CALLBACK_ERROR : null; + const [oauthError, setOauthError] = useState(null); + + const passwordAction = async (_prev: FormState, formData: FormData): Promise => { + const email = (formData.get('email') as string)?.trim(); + const password = formData.get('password') as string; + if (!configured) return { message: null, error: 'NEXT_PUBLIC_SUPABASE_URL / ANON_KEY 가 비어 있습니다.' }; + try { + const supabase = createClient(); + const { error } = await supabase.auth.signInWithPassword({ email, password }); + if (error) return { message: null, error: error.message }; + window.location.href = '/'; + return { message: null, error: null }; + } catch (err) { + return { message: null, error: err instanceof Error ? err.message : '로그인에 실패했습니다.' }; + } + }; + + const magicAction = async (_prev: FormState, formData: FormData): Promise => { + const email = (formData.get('email') as string)?.trim(); + if (!configured) return { message: null, error: 'NEXT_PUBLIC_SUPABASE_URL / ANON_KEY 가 비어 있습니다.' }; + try { + const supabase = createClient(); + const { error } = await supabase.auth.signInWithOtp({ + email, + options: { emailRedirectTo: `${window.location.origin}/auth/callback` }, + }); + if (error) return { message: null, error: error.message }; + return { message: '매직링크를 보냈습니다. 메일함에서 링크를 열어 주세요.', error: null }; + } catch (err) { + return { message: null, error: err instanceof Error ? err.message : '로그인 요청에 실패했습니다.' }; + } + }; + + const [pwState, pwAction, pwPending] = useActionState(passwordAction, INITIAL); + const [mlState, mlAction, mlPending] = useActionState(magicAction, INITIAL); + + const [mode, setMode] = useActionState((_prev: AuthMode, next: AuthMode) => next, 'password' as AuthMode); + + const state = mode === 'password' ? pwState : mlState; + const pending = pwPending || mlPending; + + const handleGoogle = async () => { + setOauthError(null); + if (!configured) { + setOauthError('NEXT_PUBLIC_SUPABASE_URL / ANON_KEY 가 비어 있습니다.'); + return; + } + try { + const supabase = createClient(); + const { error } = await supabase.auth.signInWithOAuth({ + provider: 'google', + options: { redirectTo: `${window.location.origin}/auth/callback` }, + }); + if (error) { + setOauthError(error.message); + } + } catch (err) { + setOauthError(err instanceof Error ? err.message : 'Google 로그인에 실패했습니다.'); + } + }; + + return ( +
+
+ + ← 홈 + +

로그인

+ +
+ + +
+ +
+ + + + {mode === 'password' ? ( + <> + + + + ) : null} + + +
+ + {mode === 'password' ? ( +

+ 계정이 없으신가요?{' '} + + 회원가입 + +

+ ) : null} + +
+
+ 또는 +
+
+ + + + {!configured ? ( +

apps/web/.env 에 Supabase URL/KEY를 넣은 뒤 next dev를 재시작하세요.

+ ) : null} + {state.message ?

{state.message}

: null} + {(state.error ?? oauthError ?? callbackError) ? ( +

{state.error ?? oauthError ?? callbackError}

+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 28a2935..e82054b 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,5 +1,35 @@ +import { type Ticker, TickerSchema } from '@ticker-journal/shared'; + import { HomeView } from '@/components/home-view'; +import { getSupabaseEnv } from '@/lib/supabase/env'; +import { createClient } from '@/lib/supabase/server'; + +export default async function Home() { + const { configured } = getSupabaseEnv(); + + if (!configured) { + return ; + } + + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + let tickers: Ticker[] = []; + let loadError: string | null = null; + if (user) { + const { data, error } = await supabase.from('tickers').select('*').order('created_at', { ascending: false }); + if (error) { + console.error('tickers select failed:', error.message, error.code, error.details); + loadError = error.message; + } else if (data) { + tickers = data.flatMap((row) => { + const parsed = TickerSchema.safeParse(row); + return parsed.success ? [parsed.data] : []; + }); + } + } -export default function Home() { - return ; + return ; } diff --git a/apps/web/src/app/signup/page.tsx b/apps/web/src/app/signup/page.tsx new file mode 100644 index 0000000..cc64c33 --- /dev/null +++ b/apps/web/src/app/signup/page.tsx @@ -0,0 +1,112 @@ +'use client'; + +import Link from 'next/link'; +import { useActionState } from 'react'; + +import { createClient } from '@/lib/supabase/client'; +import { getSupabaseEnv } from '@/lib/supabase/env'; + +type FormState = { message: string | null; error: string | null }; +const INITIAL: FormState = { message: null, error: null }; + +export default function SignUpPage() { + const { configured } = getSupabaseEnv(); + + const signUpAction = async (_prev: FormState, formData: FormData): Promise => { + const email = (formData.get('email') as string)?.trim(); + const password = formData.get('password') as string; + const confirm = formData.get('confirm-password') as string; + + if (!configured) return { message: null, error: 'NEXT_PUBLIC_SUPABASE_URL / ANON_KEY 가 비어 있습니다.' }; + if (password.length < 6) return { message: null, error: '비밀번호는 6자 이상이어야 합니다.' }; + if (password !== confirm) return { message: null, error: '비밀번호가 일치하지 않습니다.' }; + + try { + const supabase = createClient(); + const { error } = await supabase.auth.signUp({ + email, + password, + options: { emailRedirectTo: `${window.location.origin}/auth/callback` }, + }); + if (error) return { message: null, error: error.message }; + return { message: '확인 이메일을 보냈습니다. 메일함에서 링크를 열어 주세요.', error: null }; + } catch (err) { + return { message: null, error: err instanceof Error ? err.message : '회원가입에 실패했습니다.' }; + } + }; + + const [state, action, pending] = useActionState(signUpAction, INITIAL); + + return ( +
+
+ + ← 홈 + +

회원가입

+ +
+ + + + + + + + + + +
+ +

+ 이미 계정이 있으신가요?{' '} + + 로그인 + +

+ + {!configured ? ( +

apps/web/.env 에 Supabase URL/KEY를 넣은 뒤 next dev를 재시작하세요.

+ ) : null} + {state.message ?

{state.message}

: null} + {state.error ?

{state.error}

: null} +
+
+ ); +} diff --git a/apps/web/src/components/home-view.test.tsx b/apps/web/src/components/home-view.test.tsx index 3d325d6..f2e021a 100644 --- a/apps/web/src/components/home-view.test.tsx +++ b/apps/web/src/components/home-view.test.tsx @@ -1,11 +1,37 @@ import { render, screen } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { HomeView } from '@/components/home-view'; +vi.mock('@/app/login/actions', () => ({ + signOut: vi.fn(), +})); + describe('HomeView', () => { - it('renders archive scaffold copy', () => { - render(); - expect(screen.getByRole('heading', { name: '웹 아카이브 뼈대' })).toBeInTheDocument(); - expect(screen.getByText(/Ticker Journal/i)).toBeInTheDocument(); + it('로그인하면 관심종목을 보여준다', () => { + render( + , + ); + expect(screen.getByText(/AAPL/)).toBeInTheDocument(); + expect(screen.getByText('you@example.com')).toBeInTheDocument(); + }); + + it('조회 실패를 빈 목록과 구분한다', () => { + render(); + expect(screen.getByText(/관심종목을 불러오지 못했습니다/)).toBeInTheDocument(); + expect(screen.getByText(/permission denied for table tickers/)).toBeInTheDocument(); + expect(screen.queryByText(/아직 종목이 없습니다/)).not.toBeInTheDocument(); }); }); diff --git a/apps/web/src/components/home-view.tsx b/apps/web/src/components/home-view.tsx index 7a8a7c2..ee8a251 100644 --- a/apps/web/src/components/home-view.tsx +++ b/apps/web/src/components/home-view.tsx @@ -1,23 +1,93 @@ +import type { Ticker } from '@ticker-journal/shared'; import { APP_NAME } from '@ticker-journal/shared'; +import Link from 'next/link'; -export function HomeView() { +import { signOut } from '@/app/login/actions'; + +type HomeViewProps = { + email?: string | null; + tickers?: Ticker[]; + configured?: boolean; + loadError?: string | null; +}; + +export function HomeView({ email = null, tickers = [], configured = false, loadError = null }: HomeViewProps) { return (
-

{APP_NAME}

-

웹 아카이브 뼈대

+
+
+

{APP_NAME}

+

웹 아카이브

+
+ {email ? ( +
+ +
+ ) : ( + + 로그인 + + )} +
+

- 모바일에서 넣은 종목 타임라인(메모·링크·매매 이유)을 같은 계정으로 검색·정리하는 Next.js 앱입니다. Phase 1에서 - Supabase Auth + 검색을 붙입니다. + 모바일에서 넣은 종목 타임라인을 같은 Supabase 계정으로 확인합니다. Phase 1에서 검색·상세를 확장합니다.

-
-

다음

-
    -
  • 매직링크 로그인
  • -
  • entries 검색 (q, page size 20)
  • -
  • 종목 상세 + TradingView embed
  • -
-
+ + {!configured ? ( +
+ Supabase env가 비어 있습니다. apps/web/.env를 확인하세요. +
+ ) : null} + + {email ? ( +
+

+ 로그인: {email} +

+

관심종목

+ {loadError ? ( +
+

관심종목을 불러오지 못했습니다. 잠시 후 다시 시도하세요.

+

{loadError}

+
+ ) : tickers.length === 0 ? ( +

+ 아직 종목이 없습니다. 모바일 앱에서 먼저 추가하세요. +

+ ) : ( +
    + {tickers.map((ticker) => ( +
  • +

    + {ticker.symbol}{' '} + {ticker.market} +

    +

    {ticker.name ?? '이름 없음'}

    +
  • + ))} +
+ )} +
+ ) : ( +
+

시작

+
    +
  • 매직링크 로그인
  • +
  • 모바일과 동일 계정으로 관심종목 조회
  • +
  • Phase 1: entries 검색 · 종목 상세
  • +
+
+ )}
); diff --git a/apps/web/src/lib/supabase/auth-cache-headers.ts b/apps/web/src/lib/supabase/auth-cache-headers.ts new file mode 100644 index 0000000..12d8729 --- /dev/null +++ b/apps/web/src/lib/supabase/auth-cache-headers.ts @@ -0,0 +1,9 @@ +import type { NextResponse } from 'next/server'; + +/** 세션 쿠키가 실린 응답이 CDN/공유 캐시에 남지 않도록 한다. */ +export const applyAuthCacheHeaders = (response: T): T => { + response.headers.set('Cache-Control', 'private, no-cache, no-store, must-revalidate, max-age=0'); + response.headers.set('Expires', '0'); + response.headers.set('Pragma', 'no-cache'); + return response; +}; diff --git a/apps/web/src/lib/supabase/client.ts b/apps/web/src/lib/supabase/client.ts new file mode 100644 index 0000000..9512596 --- /dev/null +++ b/apps/web/src/lib/supabase/client.ts @@ -0,0 +1,9 @@ +import { createBrowserClient } from '@supabase/ssr'; +import type { Database } from '@ticker-journal/shared'; + +import { getSupabaseEnv } from './env'; + +export const createClient = () => { + const { url, key } = getSupabaseEnv(); + return createBrowserClient(url || 'https://placeholder.supabase.co', key || 'placeholder'); +}; diff --git a/apps/web/src/lib/supabase/env.ts b/apps/web/src/lib/supabase/env.ts new file mode 100644 index 0000000..62fc4e1 --- /dev/null +++ b/apps/web/src/lib/supabase/env.ts @@ -0,0 +1,8 @@ +const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL ?? ''; +const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? process.env.NEXT_PUBLIC_SUPABASE_KEY ?? ''; + +export const getSupabaseEnv = () => ({ + url: supabaseUrl, + key: supabaseKey, + configured: Boolean(supabaseUrl && supabaseKey), +}); diff --git a/apps/web/src/lib/supabase/middleware.ts b/apps/web/src/lib/supabase/middleware.ts new file mode 100644 index 0000000..6de27bc --- /dev/null +++ b/apps/web/src/lib/supabase/middleware.ts @@ -0,0 +1,45 @@ +import { createServerClient } from '@supabase/ssr'; +import type { Database } from '@ticker-journal/shared'; +import { type NextRequest, NextResponse } from 'next/server'; + +import { applyAuthCacheHeaders } from './auth-cache-headers'; +import { getSupabaseEnv } from './env'; + +export const updateSession = async (request: NextRequest) => { + if (request.nextUrl.pathname.startsWith('/auth/callback')) { + return NextResponse.next({ request }); + } + + let supabaseResponse = NextResponse.next({ request }); + const { url, key, configured } = getSupabaseEnv(); + + if (!configured) { + return supabaseResponse; + } + + const supabase = createServerClient(url, key, { + cookies: { + getAll() { + return request.cookies.getAll(); + }, + setAll(cookiesToSet, headers) { + for (const { name, value } of cookiesToSet) { + request.cookies.set(name, value); + } + supabaseResponse = NextResponse.next({ request }); + for (const { name, value, options } of cookiesToSet) { + supabaseResponse.cookies.set(name, value, options); + } + for (const [headerName, headerValue] of Object.entries(headers)) { + supabaseResponse.headers.set(headerName, headerValue); + } + if (cookiesToSet.length > 0) { + applyAuthCacheHeaders(supabaseResponse); + } + }, + }, + }); + + await supabase.auth.getUser(); + return supabaseResponse; +}; diff --git a/apps/web/src/lib/supabase/server.ts b/apps/web/src/lib/supabase/server.ts new file mode 100644 index 0000000..0a74e32 --- /dev/null +++ b/apps/web/src/lib/supabase/server.ts @@ -0,0 +1,27 @@ +import { createServerClient } from '@supabase/ssr'; +import type { Database } from '@ticker-journal/shared'; +import { cookies } from 'next/headers'; + +import { getSupabaseEnv } from './env'; + +export const createClient = async () => { + const cookieStore = await cookies(); + const { url, key } = getSupabaseEnv(); + + return createServerClient(url || 'https://placeholder.supabase.co', key || 'placeholder', { + cookies: { + getAll() { + return cookieStore.getAll(); + }, + setAll(cookiesToSet, _headers) { + try { + for (const { name, value, options } of cookiesToSet) { + cookieStore.set(name, value, options); + } + } catch { + // Server Component에서는 쿠키 set이 무시될 수 있음. middleware가 세션 갱신. + } + }, + }, + }); +}; diff --git a/apps/web/src/proxy.ts b/apps/web/src/proxy.ts new file mode 100644 index 0000000..9acca17 --- /dev/null +++ b/apps/web/src/proxy.ts @@ -0,0 +1,9 @@ +import type { NextRequest } from 'next/server'; + +import { updateSession } from '@/lib/supabase/middleware'; + +export const proxy = async (request: NextRequest) => updateSession(request); + +export const config = { + matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'], +}; diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts index 20b2c36..b0d3612 100644 --- a/apps/web/vitest.config.ts +++ b/apps/web/vitest.config.ts @@ -8,6 +8,14 @@ export default defineConfig({ environment: 'jsdom', setupFiles: ['./vitest.setup.ts'], include: ['src/**/*.test.{ts,tsx}'], + coverage: { + provider: 'v8', + reporter: ['text', 'text-summary', 'json-summary', 'html'], + reportsDirectory: './coverage', + include: ['src/app/auth/callback/redirect.ts', 'src/components/home-view.tsx'], + exclude: ['src/**/*.test.*'], + all: true, + }, }, resolve: { dedupe: ['react', 'react-dom'], diff --git a/apps/web/vitest.setup.ts b/apps/web/vitest.setup.ts index bb02c60..5ac42a6 100644 --- a/apps/web/vitest.setup.ts +++ b/apps/web/vitest.setup.ts @@ -1 +1,7 @@ +import { cleanup } from '@testing-library/react'; import '@testing-library/jest-dom/vitest'; +import { afterEach } from 'vitest'; + +afterEach(() => { + cleanup(); +}); diff --git a/biome.json b/biome.json index fbd0034..d58ba18 100644 --- a/biome.json +++ b/biome.json @@ -22,7 +22,11 @@ "!**/public", "!**/public", "!**/.turbo", - "!**/.turbo" + "!**/next-env.d.ts", + "!**/supabase/.temp", + "!**/packages/shared/src/database.ts", + "!**/.sonda", + "!**/apps/web/.sonda" ] }, "formatter": { diff --git a/docs/architecture.md b/docs/architecture.md index 1ecb35e..48fb618 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -133,7 +133,8 @@ Phase 0 동안 웹은 **스키마·디자인 계약만 맞춤**. 필수는 아 ## 5. 도메인 모델 -소스 오브 트루스: `packages/shared/src/index.ts`. +- 소스 오브 트루스: `packages/shared/src/index.ts`. +- Supabase `Database` 타입: `packages/shared/src/database.ts` (웹·모바일 공용). 스키마 변경 후 `pnpm gen:types`로 재생성. CI는 `pnpm check:db-types`로 드리프트 검사. ### 5.1 ER @@ -229,8 +230,11 @@ create index entries_user_created_idx on public.entries (user_id, created_at des ### Auth -- Provider: **Supabase Email Magic Link** (비밀번호 없는 MVP). -- 세션: `@supabase/supabase-js` + Expo SecureStore(또는 공식 Expo 헬퍼) / 웹은 쿠키 또는 local 세션 (Phase 1에서 확정). +- Provider: **Supabase Auth** — 이메일/비밀번호 + 매직링크 + Google OAuth. +- 모바일: `detectSessionInUrl: false` + `Linking` 콜백에서 `exchangeCodeForSession` / `setSession`. redirect는 `Linking.createURL('auth/callback')`. Google은 `skipBrowserRedirect` + `WebBrowser.openAuthSessionAsync`. +- 웹: `@supabase/ssr` 쿠키. 콜백·세션 갱신(쿠키 set) 응답에 no-cache 헤더를 붙인다. `next` 쿼리는 상대 경로만 허용(`//`, `/\` 차단). +- 회원가입: 이메일/비밀번호 (확인 메일 발송). 매직링크는 기존 계정 로그인 전용. +- Google OAuth: Supabase Dashboard에서 Google provider 활성화 필요. - 로그아웃·계정 삭제 경로는 Phase 2 스토어 심사 전에 필수 (빈 상태·에러와 함께). ### RLS (필수) @@ -238,7 +242,7 @@ create index entries_user_created_idx on public.entries (user_id, created_at des ```text tickers: SELECT/INSERT/UPDATE/DELETE WHERE user_id = auth.uid() entries: SELECT/INSERT/UPDATE/DELETE WHERE user_id = auth.uid() -entries INSERT: ticker_id 가 본인 tickers 행이어야 함 (존재 + user_id 일치) +entries INSERT/UPDATE: ticker_id 가 본인 tickers 행이어야 함 (존재 + user_id 일치) ``` 서비스 롤 키는 **앱에 넣지 않는다**. anon key + RLS만. @@ -248,7 +252,7 @@ entries INSERT: ticker_id 가 본인 tickers 행이어야 함 (존재 + user_id | 변수 | 사용처 | |------|--------| | `EXPO_PUBLIC_SUPABASE_URL` | mobile | -| `EXPO_PUBLIC_SUPABASE_ANON_KEY` | mobile | +| `EXPO_PUBLIC_SUPABASE_KEY` | mobile (publishable / anon) | | `NEXT_PUBLIC_SUPABASE_URL` | web | | `NEXT_PUBLIC_SUPABASE_ANON_KEY` | web | @@ -294,12 +298,13 @@ sequenceDiagram | 도메인 | `packages/shared` | Vitest | | 웹 컴포넌트 | `apps/web` | Vitest + RTL | | 웹 E2E | `apps/web/e2e` | Playwright | -| 모바일 화면 | `apps/mobile` | jest-expo + RNTL | +| 모바일 단위 | `apps/mobile` | jest-expo (`buildChartHtml`) | +| 모바일 화면 | (예정) Maestro | E2E | Phase 0 추가 권장: - shared: Create* / payload check 케이스 보강 -- mobile: 로그인 게이트·CRUD 성공/실패 화면 테스트 (모킹) +- 모바일 화면: Maestro 스모크 (라우터 mock 컴포넌트 테스트는 하지 않음) - (나중) Maestro/Detox 스토어 전 스모크 --- @@ -352,14 +357,15 @@ Phase 0 추가 권장: 구현 시작 전/중 이 목록을 닫는다. -- [ ] Supabase 프로젝트 생성, `.env` 채움 (example 기준) -- [ ] `supabase/migrations` 에 tickers/entries + RLS -- [ ] mobile `lib/supabase.ts` + Auth 화면/세션 게이트 -- [ ] 관심종목 CRUD (placeholder 제거) -- [ ] 엔트리 CRUD + 필터 칩 동작 -- [ ] US TradingView WebView / KR fallback -- [ ] shared·mobile 테스트 갱신 -- [ ] `docs/portfolio.md` · `docs/resume-bullets.md` 갱신 +- [x] Supabase 프로젝트 생성, `.env` 채움 (example 기준) — **로컬 키는 사용자 환경** +- [x] `supabase/migrations` 에 tickers/entries + RLS +- [x] mobile `lib/supabase.ts` + Auth 화면/세션 게이트 +- [x] 관심종목 CRUD (placeholder 제거) +- [x] 엔트리 CRUD + 필터 칩 동작 +- [x] US TradingView WebView / KR fallback +- [x] shared·mobile 테스트 갱신 +- [x] `docs/portfolio.md` · `docs/resume-bullets.md` 갱신 +- [x] GitHub Actions CI (`check` / `typecheck` / `test` / Playwright E2E) --- @@ -368,3 +374,7 @@ Phase 0 추가 권장: | 날짜 | 내용 | |------|------| | 2026-08-13 | Phase 0 전 기준선 문서 초안 작성 | +| 2026-08-15 | Auth/CRUD 테스트 보강, GitHub Actions CI 추가 | +| 2026-08-15 | 모바일 매직링크 콜백·웹 세션 캐시 헤더 반영 | +| 2026-08-19 | 이메일/비번 회원가입·로그인 + Google OAuth 추가 | +| 2026-08-20 | 콜백 open-redirect·세션 캐시 헤더·entries UPDATE ticker 소유권·모바일 Google WebBrowser | diff --git a/docs/portfolio.md b/docs/portfolio.md index fc37445..389382f 100644 --- a/docs/portfolio.md +++ b/docs/portfolio.md @@ -1,21 +1,22 @@ # Ticker Journal — 포트폴리오 > 목적: 이력서/포트폴리오에 붙일 **과정 · 어려웠던 점 · 배운 점** + **다이어그램** 기록. -> 기능 구현이 끝날 때마다 이 문서와 아래 그림을 갱신한다. +> 기능 구현이 끝날 때마다 이 문서와 아래 그림을 갱신한다. (`/.cursor/rules/portfolio-docs.mdc`) | 항목 | 내용 | |------|------| | 기간 | 2026-08 ~ (진행 중) | | 역할 | 개인 프로젝트 (기획 · 설계 · 풀스택 · 모바일 · 배포) | -| 스택 | Expo (React Native), Expo Router, Next.js, TypeScript, Zod, pnpm monorepo, Turborepo, WebView, Vitest, RTL, jest-expo, Playwright, (예정) Supabase, EAS Submit | +| 스택 | Expo (React Native), Expo Router, Next.js 16, TypeScript 7, Zod, pnpm monorepo, Turborepo, Biome, WebView, Vitest, RTL, jest-expo, Playwright, Supabase (Auth + Postgres + RLS), (예정) EAS Submit | | 레포 | https://github.com/scs0209/ticker-journal | | 설계 문서 | `docs/design.md` · `docs/architecture.md` | +| 현재 브랜치 | `feat/phase-0-auth-crud` | --- ## 한 줄 요약 -노션·시트에 흩어진 주식 리서치·매매 이유를 **종목 타임라인**으로 묶고, **모바일에서 입력 · 웹에서 검색·정리**하며, **App Store / Play Store**까지 가는 크로스플랫폼 앱. +노션·시트에 흩어진 주식 리서치·매매 이유를 **종목 타임라인**으로 묶고, **모바일에서 입력 · 웹에서 동일 계정으로 확인**하며, **App Store / Play Store**까지 가는 크로스플랫폼 앱. --- @@ -38,7 +39,7 @@ flowchart LR B --> D[타임라인
memo / link / trade] D -->|FAB 추가| E[엔트리 작성] E --> D - D -.동일 계정.-> F[웹 검색·아카이브] + D -.동일 계정.-> F[웹 로그인·관심종목 조회] F --> B ``` @@ -48,15 +49,15 @@ flowchart LR flowchart TB subgraph clients [Clients] M[apps/mobile
Expo Router + WebView] - W[apps/web
Next.js App Router] + W[apps/web
Next.js App Router + @supabase/ssr] end subgraph shared [packages/shared] Z[Zod schemas
tickers / entries] end - subgraph backend [Backend - Phase 0+] - SB[(Supabase
Auth + Postgres + RLS)] + subgraph backend [Backend] + SB[(Supabase
Auth: email/password · magic link · Google OAuth
Postgres + RLS)] end M --> Z @@ -97,12 +98,12 @@ erDiagram gantt title Ticker Journal delivery dateFormat YYYY-MM-DD - section Now - Scaffold + tests :done, s0, 2026-08-11, 1d - section Phase 0 - Auth + CRUD mobile :p0, 2026-08-12, 10d + section Done + Scaffold + tests + Biome/TS7 :done, s0, 2026-08-11, 3d + Auth + CRUD 구현 (앱·웹 세션) :done, p0, 2026-08-13, 3d + 실계정·웹앱 동일 목록 스모크 :done, p0e, 2026-08-15, 2d section Phase 1 - Web search + detail :p1, after p0, 7d + Web search + detail :p1, after p0e, 7d section Phase 2 Store submit iOS+Android :p2, after p1, 21d section Later @@ -111,21 +112,24 @@ gantt ### 0.6 테스트 계층 +현재 **4계층** (모바일 화면 Maestro는 예정). + ```mermaid flowchart TB E2E[Playwright · 웹 E2E] - COMP[RTL · 웹 컴포넌트
RNTL · 모바일 화면] - UNIT[Vitest · shared Zod] + COMP[RTL · 웹 순수 뷰] + MUNIT[jest-expo · buildChartHtml] + SUNIT[Vitest · shared Zod] - E2E --> COMP --> UNIT + E2E --> COMP --> MUNIT --> SUNIT ``` | 영역 | 도구 | 역할 | |------|------|------| | shared | Vitest | 스키마·도메인 규칙 | -| web | Vitest + RTL | UI 계약 | -| web | Playwright | 브라우저 플로우 | -| mobile | jest-expo + RNTL | 네이티브 화면 | +| mobile | jest-expo | `buildChartHtml` (화면은 Maestro 예정) | +| web | Vitest + RTL | 로그인된 HomeView 종목 표시 · 콜백 경로 | +| web | Playwright | `/`, `/login` | --- @@ -154,14 +158,40 @@ flowchart TB - 위 **0.6** 참고. 상세: `docs/testing.md` -### 1.5 아키텍처 freeze (2026-08-13) +### 1.5 아키텍처 freeze · 툴링 (2026-08-13) + +- Phase 0 기준선: `docs/architecture.md` (모노레포 · Supabase/RLS · 도메인 · ADR) +- Biome 2.5.7 (3d-blog 정렬), TypeScript **7.0.2**, pnpm overrides → `pnpm-workspace.yaml` + +### 1.6 Phase 0 — Auth + CRUD (2026-08-13, `feat/phase-0-auth-crud`) + +**백엔드** + +- `supabase/migrations/..._init.sql`: `tickers` / `entries`, enum, check 제약, RLS (`user_id = auth.uid()`) + +**모바일** + +- 매직링크 로그인 (`expo-secure-store` 세션), 로그인 게이트 +- 관심종목 CRUD, 엔트리(memo/link/trade) CRUD + 타임라인 필터 +- US TradingView WebView / KR fallback HTML + +**웹** + +- `@supabase/ssr` + Next 16 `proxy.ts` 세션 갱신 +- `/login` 매직링크, `/auth/callback` 코드 교환 +- 홈에서 동일 계정 관심종목 조회 (검색·상세는 Phase 1) + +### 1.7 테스트 · CI (2026-08-15) -- Phase 0 착수 전 기준선: `docs/architecture.md` (모노레포 · Supabase/RLS · 도메인 · ADR) +- 라우터 mock 없이 못 도는 모바일 화면 테스트는 삭제. 화면은 Maestro E2E +- GitHub Actions: `pnpm check` · `typecheck` · `test` + Playwright E2E +- Husky pre-commit: `pnpm run ci` (E2E는 CI만) -### 1.6 이후 로드맵 +### 1.8 이후 로드맵 -- [ ] Phase 0: Supabase Auth + CRUD (앱) -- [ ] Phase 1: 웹 검색·종목 상세 +- [x] Phase 0 구현: Supabase Auth + 모바일 CRUD + 웹 세션/목록 (`feat/phase-0-auth-crud`) +- [x] Phase 0 실계정 스모크: 앱에서 종목·entry 생성 후 웹에서 동일 관심종목 목록 확인 +- [ ] Phase 1: 웹 entries 검색·종목 상세 - [ ] Phase 2: EAS → App Store / Play Store - [ ] v1.1 공유 시트 / v2 AI 브리핑 @@ -175,8 +205,14 @@ flowchart TB | 아키텍처 | WebView 래퍼 감점 위험 | 네이티브 셸 + WebView 차트 | | 성공 조건 | 내부 배포만으론 이력서 신호 약함 | 양 스토어를 Success Criteria로 | | 모노레포 | 앱별 lock/workspace 충돌 | 루트 workspace만 유지 | -| 테스트 | React 19.2.3 vs react-dom 19.2.8 → RTL 빈 DOM | `pnpm.overrides`로 정렬 | -| RN 테스트 | Vitest 통일 유혹 | mobile만 jest-expo + RNTL | +| 테스트 | React 19.2.3 vs react-dom 19.2.8 → RTL 빈 DOM | overrides로 정렬 (`pnpm-workspace.yaml`) | +| RN 테스트 | Vitest 통일 유혹 | mobile만 jest-expo (`buildChartHtml`); 화면은 Maestro 예정 | +| Phase 0 | Supabase 없이 앱만 만들면 E2E 검증 불가 | 마이그레이션·RLS·env를 코드와 같이 고정 | +| Next 16 | `middleware` deprecation | `proxy.ts`로 세션 갱신 이전 | +| Auth | 모바일/웹 redirect URL이 다름 | `tickerjournal://…` + Expo Go `exp://…` + `localhost:3000/auth/callback` 등록 | +| Auth | `detectSessionInUrl: false`면 딥링크만으로는 세션이 안 생김 | `Linking`으로 code 교환·토큰 `setSession` | +| CLI | `config.toml`을 JSON으로 두면 supabase CLI가 못 읽음 | 실제 TOML로 교체 | +| 차트 | HTML escape를 JS 문자열에 쓰면 쿼트 인젝션·이중 인코딩 | `JSON.stringify`로 위젯 심볼 삽입 | --- @@ -184,9 +220,13 @@ flowchart TB - 포트폴리오 모바일은 **배포·네이티브 표면 + 수치**가 기능 나열보다 세다. - 경쟁 상대는 Tradervue가 아니라 **노션+시트 분산**. -- 모노레포 공유 스키마 = “풀스택 모바일” 한 줄. +- 모노레포 공유 Zod + RLS = “클라이언트가 달라도 권한 모델은 하나”. - React 버전 불일치는 RTL이 **조용히 빈 트리**를 그린다. -- 다이어그램(와이어·머메이드)을 문서에 두면 면접·노션 포트폴리오에 바로 붙일 수 있다. +- 다이어그램을 문서에 두면 면접·노션 포트폴리오에 바로 붙일 수 있다. +- **문서 동기화**: 코드 마일스톤마다 `portfolio.md` / `resume-bullets.md`를 같이 갱신하지 않으면 이력서 문장이 코드보다 뒤처진다. +- Next 16에서는 edge 세션 갱신을 `proxy` 컨벤션으로 맞추는 편이 경고·미래 호환에 유리하다. +- RN에서 매직링크는 redirect URL만 맞추는 게 아니라 **콜백 URL → `exchangeCodeForSession` / `setSession`** 까지 연결해야 한다. +- `@supabase/ssr`가 넘기는 no-cache 헤더를 응답에 안 붙이면 세션 쿠키가 CDN에 캐시될 수 있다. --- @@ -194,19 +234,22 @@ flowchart TB ### 프로젝트 소개 -Ticker Journal은 주식 리서치 스크랩과 매매 이유를 종목 타임라인으로 관리하는 크로스플랫폼 앱입니다. 모바일 입력 + WebView 차트, 웹 검색·정리를 같은 계정으로 제공합니다. +Ticker Journal은 주식 리서치 스크랩과 매매 이유를 종목 타임라인으로 관리하는 크로스플랫폼 앱입니다. Expo 모바일에서 입력하고, Next.js 웹에서 같은 Supabase 계정으로 관심종목을 확인합니다. ### 내가 한 일 -- 문제 정의·MVP 3화면 와이어프레임·아키텍처 다이어그램 작성 -- pnpm/Turborepo 모노레포 (`mobile` / `web` / `shared`) -- Expo Router + WebView 뼈대, Zod 공유 스키마 -- Vitest / RTL / Playwright / jest-expo 테스트 계층 -- (예정) Supabase · 스토어 2곳 배포 +- 문제 정의·MVP 3화면 와이어프레임·아키텍처/ADR 문서화 +- pnpm/Turborepo 모노레포 (`mobile` / `web` / `shared`) + Biome + TypeScript 7 +- Expo Router · Auth(이메일/비번 + 매직링크 + Google OAuth) · 관심종목/엔트리 CRUD · TradingView WebView +- Next.js `@supabase/ssr` 로그인·콜백·관심종목 조회 +- Postgres 스키마 + RLS, Zod 공유 스키마로 입력 검증 +- Vitest / RTL / Playwright / jest-expo 테스트 계층 + GitHub Actions CI +- (예정) entries 웹 검색, 스토어 2곳 배포 ### 성과 / 임팩트 -- *(스토어 URL, 실사용 종목 N·주간 M — `docs/resume-bullets.md` 지표 표 참고)* +- 앱·웹 **동일 Supabase 계정**으로 Auth + 종목 목록 동기화 경로 확보 +- *(실사용 종목 N·주간 entry M, 스토어 URL — `docs/resume-bullets.md` 지표 표)* --- @@ -219,4 +262,15 @@ Ticker Journal은 주식 리서치 스크랩과 매매 이유를 종목 타임 | 2026-08-11 | 이력서 문구를 수치·임팩트 지표 중심으로 재작성 | | 2026-08-11 | 와이어프레임·머메이드(플로우/아키텍처/ER/로드맵/테스트) 섹션 추가 | | 2026-08-13 | Phase 0 전 `docs/architecture.md` 기준선 추가 | +| 2026-08-13 | Biome · TypeScript 7 도입 | +| 2026-08-13 | Phase 0: migrations/RLS, 모바일 Auth·CRUD·차트, 웹 세션·관심종목 조회 | | 2026-08-14 | office-hours 설계 문서를 `docs/design.md`로 레포에 포함 | +| 2026-08-15 | Auth/CRUD 테스트 보강, GitHub Actions CI | +| 2026-08-15 | CodeRabbit: 매직링크 콜백, 차트 JS 삽입, 세션 캐시 헤더, 조회 에러 상태 | +| 2026-08-19 | 이메일/비번 회원가입·로그인 + Google OAuth 추가, 회원가입 화면(앱·웹) | +| 2026-08-20 | CodeRabbit: open-redirect·세션 캐시·entries UPDATE 소유권·Google WebBrowser | +| 2026-08-24 | CodeRabbit 잔여: 로그아웃 Alert, trigger search_path, WebView originWhitelist, 모달 초기화 | +| 2026-08-24 | `pnpm gen:types`로 Database 재생성·Relationships 반영, CI `check:db-types` | +| 2026-08-24 | `pnpm test:coverage`로 단위·컴포넌트 Lines % 측정 (shared/mobile 100%, web ~81%) | +| 2026-08-25 | 중복 종목 Alert 문구, Sonda 번들 분석 스크립트 | +| 2026-08-25 | Phase 0 실계정 스모크 완료로 마감 | diff --git a/docs/resume-bullets.md b/docs/resume-bullets.md index fb889be..1749a60 100644 --- a/docs/resume-bullets.md +++ b/docs/resume-bullets.md @@ -2,18 +2,19 @@ > 원칙: **기술 나열 < 측정 가능한 결과**. > 불릿 1개 = `행동 + 대상 + 수치/범위 + (가능하면) 전후 비교`. -> 스택은 Skills 한 줄에만 두고, Projects 불릿에는 숫자를 넣는다. +> 스택은 Skills 한 줄에만 두고, Projects 불릿에는 숫자를 넣는다. +> 마일스톤마다 이 파일 + `docs/portfolio.md`를 **같이** 갱신한다. --- -## 지금 당장 쓸 수 있는 수치 (스캐폴드 기준) +## 지금 당장 쓸 수 있는 수치 (Phase 0 기준) -구현 자랑 대신 **규모·품질 게이트**로 쓴다. +- 웹·모바일 **2앱** + 공유 패키지 **1개** 모노레포로 도메인 스키마를 한곳(`packages/shared`)에 두고, 테스트 **4계층**(shared 단위 · mobile 단위 · 웹 컴포넌트 · 웹 E2E) + **GitHub Actions CI** (`check` / `typecheck` / `test` / Playwright) +- 관심종목 → 종목 상세(WebView) → 웹 아카이브까지 **핵심 루프 3화면**으로 MVP 고정, 브로커 SaaS 범위는 제외 +- Supabase **Auth(이메일/비번 + 매직링크 + Google OAuth) + RLS**로 앱·웹 **동일 계정** 경로 확보: 모바일 CRUD(관심종목·memo/link/trade) + 웹 관심종목 조회 +- 차트는 TradingView WebView(US) / KR fallback으로 분리해 네이티브 차트 공수를 제외하고 Phase 0 일정 유지 -- 웹·모바일·공유 패키지 **3앱 모노레포**로 도메인 스키마를 한곳(`packages/shared`)에 두고, 단위·컴포넌트·E2E **4계층** 테스트(Vitest / RTL / jest-expo / Playwright) **9+ 케이스** 통과 -- 관심종목 → 종목 상세(WebView) → 웹 아카이브까지 **핵심 루프 3화면**으로 MVP 범위를 고정해, 브로커 SaaS급 기능을 제외하고 스토어 배포를 성공 조건으로 설정 - -*(아직 유저·스토어 수치가 없으면 이 2줄 + Skills. 과한 기술 불릿은 넣지 않는다.)* +*(실사용 종목 수·스토어 URL이 생기면 아래 표의 숫자를 채운 뒤 불릿을 교체한다.)* --- @@ -21,14 +22,14 @@ 출시·실사용 전에 이력서에 “구현했다”만 쓰지 말고, 아래를 **매주 기록**한다. -| 지표 | 목표 예시 | 이력서 문장 틀 | -|------|-----------|----------------| -| 스토어 | App Store + Play **2곳** 라이브 | “iOS·Android 스토어 **2곳** 배포 완료 (URL)” | -| 본인 실사용 | 관심종목 **≥10**, 주간 entry **≥20** | “본인 워크플로로 종목 N개·주간 기록 M건 운영” | -| 입력 속도 | 노션 대비 기록 시간 **X%↓** 또는 **N초** | “모바일 입력으로 스크랩·매매 이유 기록 평균 Ns” | -| 검색 회수 | 웹에서 과거 메모 찾기 **성공률** / 시간 | “웹 검색으로 과거 리서치 회수 시간 Y분→Z분” | -| 품질 | 테스트 **N건**, CI 통과, 크래시 **0** (데모 기간) | “단위·E2E N건·스토어 심사용 빈/에러/계정삭제 경로 충족” | -| 범위 통제 | 제외한 기능 **K개**, 출시까지 **D주** | “브로커 연동 등 K개 제외, D주 내 스토어 제출” | +| 지표 | 목표 예시 | 이력서 문장 틀 | 현재 | +|------|-----------|----------------|------| +| 스토어 | App Store + Play **2곳** 라이브 | “iOS·Android 스토어 **2곳** 배포 완료 (URL)” | 미착수 | +| 본인 실사용 | 관심종목 **≥10**, 주간 entry **≥20** | “본인 워크플로로 종목 N개·주간 기록 M건 운영” | 마이그레이션 적용 후 채움 | +| 입력 속도 | 노션 대비 기록 시간 **X%↓** 또는 **N초** | “모바일 입력으로 스크랩·매매 이유 기록 평균 Ns” | 미측정 | +| 검색 회수 | 웹에서 과거 메모 찾기 **성공률** / 시간 | “웹 검색으로 과거 리서치 회수 시간 Y분→Z분” | Phase 1 | +| 품질 | 테스트 **N건**, CI 통과, 크래시 **0** | “단위·E2E N건·스토어 심사용 경로 충족” | 테스트 **20+3건**, Lines shared/mobile **100%** · web **~81%**, GitHub Actions CI 통과, Phase 0 실계정 스모크 완료 | +| 범위 통제 | 제외한 기능 **K개**, 출시까지 **D주** | “브로커 연동 등 K개 제외, D주 내 스토어 제출” | 브로커·AI 브리핑 제외 | --- @@ -37,18 +38,25 @@ | 약함 (구현 나열) | 강함 (수치·결과) | |------------------|------------------| | Expo Router로 네비게이션 구성 | 핵심 플로우 **3화면**으로 MVP 고정 후 스토어 **2곳** 배포 목표 | -| Vitest, Playwright 도입 | 테스트 **4계층 / 9+건**으로 회귀 방지, React 버전 충돌 제거 후 전 패키지 green | -| Supabase CRUD 구현 | 앱·웹 **동일 계정**으로 종목 N개·entry M건 동기화 | -| WebView로 차트 넣음 | 차트는 WebView로 분리해 네이티브 개발 범위 **축소**, 출시 일정 **D주** 유지 | +| Vitest, Playwright 도입 | 테스트 **4계층** + GitHub Actions CI로 회귀 방지, React·TS 버전 정렬 후 전 패키지 green | +| Supabase CRUD 구현 | 앱·웹 **동일 계정** + RLS로 종목 목록 동기화 경로 확보 | +| WebView로 차트 넣음 | 차트는 WebView로 분리해 네이티브 범위 **축소**, Phase 0에서 US embed / KR fallback | --- ## Phase별 불릿 템플릿 (숫자 채우기) -### Phase 0~1 (Auth·CRUD·웹) +### Phase 0 (Auth·CRUD·웹 세션) — 완료 + +- `[x]` Expo·Next **동일 Supabase 프로젝트**에 Auth(이메일/비번·매직링크·Google)를 붙이고, Postgres `tickers`/`entries` + RLS로 모바일 CRUD·웹 관심종목 조회까지 연결 +- `[x]` 관심종목·엔트리(memo/link/trade) 입력 경로를 Zod 공유 스키마로 검증하고, US TradingView WebView / KR fallback으로 차트 표면을 분리 +- `[x]` 모노레포 테스트 **4계층** + 커버리지(`pnpm test:coverage`) + GitHub Actions(`check`/`typecheck`/`test`/E2E) + Biome + TypeScript 7으로 품질 게이트 유지 +- `[x]` 본인 계정으로 관심종목 **3**개·entry 입력 후 웹에서 **동일 관심종목 목록** 수동 스모크 완료 + +### Phase 1 (웹 검색·상세) -- `[ ]` 앱·웹 단일 계정으로 관심종목 **__**개, entry **__**건 생성·검색 end-to-end 검증 - `[ ]` 웹 검색(페이지 20)으로 과거 메모 회수 **평균 __초** (이전: 노션/시트 수 분) +- `[ ]` 종목 상세 + TradingView를 웹에도 제공해 앱·웹 **읽기 경로 대칭** ### Phase 2 (스토어 = 이력서 임팩트 피크) @@ -59,18 +67,20 @@ ### 면접 30초 (숫자만 남기기) 1. 문제: 노션+시트에 리서치가 흩어짐 -2. 결과: 종목 타임라인 앱+웹, 스토어 **2곳**, 실사용 종목 **N**·주간 **M** -3. 어떻게: MVP **3화면**, 테스트 **4계층**, 모노레포 스키마 1곳 -4. 링크: 스토어 / 웹 / 데모 +2. 결과: 종목 타임라인 앱+웹, (목표) 스토어 **2곳**, 실사용 종목 **N**·주간 **M** +3. 어떻게: MVP **3화면**, 테스트 **4계층 + CI**, Zod 1곳, Supabase Auth+RLS +4. 링크: 레포 / 웹 / (스토어) --- ## Skills (여기만 기술 나열) -`React Native` · `Expo` · `Next.js` · `TypeScript` · `Zod` · `Monorepo` · `Vitest` · `Playwright` · `Jest` · `Supabase`(예정) · `EAS` / Store(예정) +`React Native` · `Expo` · `Next.js` · `TypeScript` · `Zod` · `Monorepo` · `Biome` · `Vitest` · `Playwright` · `Jest` · `Supabase` · `EAS` / Store(예정) --- ## 갱신 규칙 -마일스톤마다 **숫자부터** 채운다. 기술 설명은 `docs/portfolio.md`에만 두고, 이력서 불릿에는 표를 갱신한 수치만 올린다. +1. 코드 마일스톤이 끝나면 **숫자부터** 이 파일을 고친다. +2. 과정·어려움·배운 점은 `docs/portfolio.md`에만 길게 쓴다. +3. 커밋 시 문서 변경을 같은 커밋(또는 직전 `docs:`)에 포함한다. diff --git a/docs/testing.md b/docs/testing.md index a3480bc..b63b9ee 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,24 +1,56 @@ -# 테스트 전략 +# 테스트 기준 -| 영역 | 도구 | 이유 | -|------|------|------| -| `packages/shared` | **Vitest** | Zod/순수 로직. 가장, RN 불필요 | -| `apps/web` 컴포넌트 | **Vitest + RTL** | Next/React DOM과 궁합 좋음 | -| `apps/web` E2E | **Playwright** | 브라우저 실사용 플로우 | -| `apps/mobile` | **jest-expo + RNTL** | Expo 공식 경로. Vitest는 RN 네이티브 모듈/모킹이 아직 번거로움 | +테스트를 추가하기 전에 아래 질문을 통과해야 한다. 통과 못하면 작성하지 않는다. -## RN을 Vitest로 안 간 이유 +1. **깨지면 사용자가 알아채는가?** (스키마 변환, 잘못된 화면, 잘못된 리다이렉트) +2. **이미 위 레이어가 같은 동작을 보는가?** (E2E가 커버하면 컴포넌트/단위를 중복하지 않는다) +3. **성공 조건이 목 호출인가?** 그러면 잘못된 테스트다. 화면 텍스트, URL, 순수 함수 반환값만 assert한다. -- Expo/RN 생태계 예제·모킹(`jest-expo`)이 Jest 기준 -- `react-native` / Expo Router / WebView는 Jest transform·preset이 검증됨 -- 이력서에는 “계층별 테스트(단위·컴포넌트·E2E)”가 도구 통일보다 설득력 있음 +`describe`/`it`/`test` 설명은 **한국어**. 대상 이름(`CreateTickerSchema`, `HomeView`)만 영어 식별자를 쓴다. -E2E 모바일은 나중에 **Maestro** 또는 Detox를 검토 (스토어 전 스모크). +## 레이어 + +현재 **4계층**이다. (모바일 화면 Maestro E2E는 예정이라 아직 세지 않는다.) + +| 레이어 | 대상 | 도구 | 하지 않는 것 | +|--------|------|------|----------------| +| shared 단위 | Zod 스키마 정규화/거부 | Vitest | supabase/OTP를 목킹하고 `toHaveBeenCalled` | +| mobile 단위 | `buildChartHtml` | jest-expo | expo-router/Auth를 목킹한 화면 테스트 | +| 웹 컴포넌트 | 웹 순수 뷰만 (`HomeView` props → 텍스트) | RTL | 라우터/Auth를 목킹한 화면 테스트 | +| 웹 E2E | 웹 사용자 플로우: 홈, 로그인 페이지 | Playwright | 매직링크 메일·실세션 (인박스 없음) | + +웹 페이지 플로우 = Playwright. 웹 RTL은 **props → 텍스트**인 순수 뷰만 (예: 로그인된 `HomeView` 종목). 라우팅·폼 제출·Auth는 E2E. + +## 지금 허용된 테스트 + +- `packages/shared` — 스키마 정규화/거부 +- `apps/mobile/lib/chart.ts` — US 위젯 / KR fallback HTML +- `apps/web` `resolveAuthCallbackPath` — 콜백 성공/실패 경로 (웹 단위) +- `apps/web` `HomeView` — 로그인된 종목 표시, 조회 실패 메시지 (E2E에 세션 없음) +- Playwright — `/`, `/login`이 뜨는지 + +모바일 화면(관심종목·상세)은 라우터 없이 마운트되지 않는다. 구현 mock으로 목록을 그리는 테스트는 하지 않고, Maestro E2E에서 다룬다. ## 명령 ```bash -pnpm test # 전 패키지 unit/component +pnpm test # 단위 + 컴포넌트 +pnpm test:coverage # 단위·컴포넌트 커버리지(정량 %) pnpm test:e2e # Playwright (web) -pnpm --filter @ticker-journal/mobile test +pnpm run ci # Biome + typecheck + unit (pre-commit 훅과 동일) ``` + +### 커버리지(정량) + +`pnpm test:coverage`로 **라인/브랜치 %**를 본다. HTML은 각 패키지 `coverage/index.html`. + +측정 범위는 `docs/testing.md` 허용 대상(단위·컴포넌트)이다. 화면·Auth·라우팅은 E2E/Maestro 영역이라 여기 %에 넣지 않는다. **%를 올리려고 목킹 테스트를 추가하지 않는다.** + +| 패키지 | 포함 파일 | Lines (참고) | +|--------|-----------|--------------| +| shared | `src/index.ts` (스키마) | ~100% | +| web | `redirect.ts`, `HomeView` | ~81% | +| mobile | `lib/chart.ts` | ~100% | + +로컬: Husky `pre-commit`은 `pnpm run ci`만 실행한다 (`check` + `typecheck` + `test`). +GitHub Actions는 여기에 **`test:coverage` · `check:db-types`(local Supabase) · Playwright E2E**를 더 돌린다. 커밋이 통과해도 CI가 더 넓은 게이트다. diff --git a/package.json b/package.json index bc39263..6aa4d84 100644 --- a/package.json +++ b/package.json @@ -6,20 +6,27 @@ "dev": "turbo run dev", "dev:web": "pnpm --filter @ticker-journal/web dev", "dev:mobile": "pnpm --filter @ticker-journal/mobile start", + "dev:ios": "pnpm --filter @ticker-journal/mobile exec expo start --ios", "build": "turbo run build", "lint": "turbo run lint", "typecheck": "turbo run typecheck", "test": "turbo run test", + "test:coverage": "turbo run test:coverage", "test:e2e": "pnpm --filter @ticker-journal/web test:e2e", + "ci": "pnpm check && pnpm typecheck && pnpm test", + "gen:types": "supabase gen types typescript --linked --schema public > packages/shared/src/database.ts.tmp && mv packages/shared/src/database.ts.tmp packages/shared/src/database.ts", + "check:db-types": "node scripts/check-db-types.mjs", "format": "biome format --write .", "format:check": "biome format .", "lint:biome": "biome lint .", "lint:biome:fix": "biome lint --write .", "check": "biome check .", - "check:fix": "biome check --write ." + "check:fix": "biome check --write .", + "prepare": "husky" }, "devDependencies": { "@biomejs/biome": "2.5.7", + "husky": "^9.1.7", "turbo": "^2.5.4", "typescript": "~7.0.2" } diff --git a/packages/shared/package.json b/packages/shared/package.json index 5c59c75..0c6e0c9 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -17,12 +17,14 @@ "dev": "tsc -p tsconfig.json --watch", "test": "vitest run", "test:watch": "vitest", + "test:coverage": "vitest run --coverage", "lint": "biome lint ." }, "dependencies": { "zod": "^3.25.67" }, "devDependencies": { + "@vitest/coverage-v8": "^3.2.7", "typescript": "~7.0.2", "vitest": "^3.2.4" } diff --git a/packages/shared/src/database-exports.ts b/packages/shared/src/database-exports.ts new file mode 100644 index 0000000..a52130a --- /dev/null +++ b/packages/shared/src/database-exports.ts @@ -0,0 +1,8 @@ +/** `Database`는 `pnpm gen:types`로 재생성한다. 수동 편집하지 말 것. */ +import type { Database } from './database'; + +export type { Database }; +export type EntryRow = Database['public']['Tables']['entries']['Row']; +export type EntryInsert = Database['public']['Tables']['entries']['Insert']; +export type TickerRow = Database['public']['Tables']['tickers']['Row']; +export type TickerInsert = Database['public']['Tables']['tickers']['Insert']; diff --git a/packages/shared/src/database.ts b/packages/shared/src/database.ts new file mode 100644 index 0000000..38cec6e --- /dev/null +++ b/packages/shared/src/database.ts @@ -0,0 +1,249 @@ +export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[] + +export type Database = { + // Allows to automatically instantiate createClient with right options + // instead of createClient(URL, KEY) + __InternalSupabase: { + PostgrestVersion: "14.15" + } + public: { + Tables: { + entries: { + Row: { + body: string | null + created_at: string + id: string + note: string | null + price: number | null + qty: number | null + reason: string | null + side: Database["public"]["Enums"]["trade_side"] | null + ticker_id: string + title: string | null + traded_at: string | null + type: Database["public"]["Enums"]["entry_type"] + updated_at: string + url: string | null + user_id: string + } + Insert: { + body?: string | null + created_at?: string + id?: string + note?: string | null + price?: number | null + qty?: number | null + reason?: string | null + side?: Database["public"]["Enums"]["trade_side"] | null + ticker_id: string + title?: string | null + traded_at?: string | null + type: Database["public"]["Enums"]["entry_type"] + updated_at?: string + url?: string | null + user_id: string + } + Update: { + body?: string | null + created_at?: string + id?: string + note?: string | null + price?: number | null + qty?: number | null + reason?: string | null + side?: Database["public"]["Enums"]["trade_side"] | null + ticker_id?: string + title?: string | null + traded_at?: string | null + type?: Database["public"]["Enums"]["entry_type"] + updated_at?: string + url?: string | null + user_id?: string + } + Relationships: [ + { + foreignKeyName: "entries_ticker_id_fkey" + columns: ["ticker_id"] + isOneToOne: false + referencedRelation: "tickers" + referencedColumns: ["id"] + }, + ] + } + tickers: { + Row: { + created_at: string + id: string + market: Database["public"]["Enums"]["market"] + name: string | null + symbol: string + user_id: string + } + Insert: { + created_at?: string + id?: string + market: Database["public"]["Enums"]["market"] + name?: string | null + symbol: string + user_id: string + } + Update: { + created_at?: string + id?: string + market?: Database["public"]["Enums"]["market"] + name?: string | null + symbol?: string + user_id?: string + } + Relationships: [] + } + } + Views: { + [_ in never]: never + } + Functions: { + [_ in never]: never + } + Enums: { + entry_type: "memo" | "link" | "trade" + market: "US" | "KR" + trade_side: "buy" | "sell" + } + CompositeTypes: { + [_ in never]: never + } + } +} + +type DatabaseWithoutInternals = Omit + +type DefaultSchema = DatabaseWithoutInternals[Extract] + +export type Tables< + DefaultSchemaTableNameOrOptions extends + | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends { + Row: infer R + } + ? R + : never + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & + DefaultSchema["Views"]) + ? (DefaultSchema["Tables"] & + DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R + } + ? R + : never + : never + +export type TablesInsert< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Insert: infer I + } + ? I + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Insert: infer I + } + ? I + : never + : never + +export type TablesUpdate< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] + : never = never, +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { + Update: infer U + } + ? U + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { + Update: infer U + } + ? U + : never + : never + +export type Enums< + DefaultSchemaEnumNameOrOptions extends + | keyof DefaultSchema["Enums"] + | { schema: keyof DatabaseWithoutInternals }, + EnumName extends DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] + : never = never, +> = DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName] + : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] + ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions] + : never + +export type CompositeTypes< + PublicCompositeTypeNameOrOptions extends + | keyof DefaultSchema["CompositeTypes"] + | { schema: keyof DatabaseWithoutInternals }, + CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] + : never = never, +> = PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] + : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"] + ? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] + : never + +export const Constants = { + public: { + Enums: { + entry_type: ["memo", "link", "trade"], + market: ["US", "KR"], + trade_side: ["buy", "sell"], + }, + }, +} as const diff --git a/packages/shared/src/index.test.ts b/packages/shared/src/index.test.ts index 38d3412..dcb7afe 100644 --- a/packages/shared/src/index.test.ts +++ b/packages/shared/src/index.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { CreateEntrySchema, CreateTickerSchema, TimelineFilterSchema } from './index'; describe('CreateTickerSchema', () => { - it('uppercases and trims symbol', () => { + it('심볼을 대문자로 만들고 공백을 제거한다', () => { const parsed = CreateTickerSchema.parse({ market: 'US', symbol: ' aapl ', @@ -11,7 +11,7 @@ describe('CreateTickerSchema', () => { expect(parsed.symbol).toBe('AAPL'); }); - it('rejects empty symbol', () => { + it('빈 심볼을 거부한다', () => { expect(() => CreateTickerSchema.parse({ market: 'KR', symbol: '' })).toThrow(); }); }); @@ -19,7 +19,7 @@ describe('CreateTickerSchema', () => { describe('CreateEntrySchema', () => { const tickerId = '11111111-1111-1111-1111-111111111111'; - it('accepts memo', () => { + it('memo 타입을 허용한다', () => { const parsed = CreateEntrySchema.parse({ type: 'memo', ticker_id: tickerId, @@ -28,7 +28,7 @@ describe('CreateEntrySchema', () => { expect(parsed.type).toBe('memo'); }); - it('accepts link with url', () => { + it('url이 있는 link를 허용한다', () => { const parsed = CreateEntrySchema.parse({ type: 'link', ticker_id: tickerId, @@ -38,7 +38,7 @@ describe('CreateEntrySchema', () => { expect(parsed.type).toBe('link'); }); - it('rejects link without url', () => { + it('url이 없는 link를 거부한다', () => { expect(() => CreateEntrySchema.parse({ type: 'link', @@ -47,7 +47,7 @@ describe('CreateEntrySchema', () => { ).toThrow(); }); - it('accepts trade with side', () => { + it('side가 있는 trade를 허용한다', () => { const parsed = CreateEntrySchema.parse({ type: 'trade', ticker_id: tickerId, @@ -60,7 +60,7 @@ describe('CreateEntrySchema', () => { }); describe('TimelineFilterSchema', () => { - it('allows all filter chips', () => { + it('필터 칩 옵션을 모두 허용한다', () => { expect(TimelineFilterSchema.options).toEqual(['all', 'memo', 'link', 'trade']); }); }); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index f873ce0..055f2d0 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -97,3 +97,5 @@ export const TimelineFilterSchema = z.enum(['all', 'memo', 'link', 'trade']); export type TimelineFilter = z.infer; export const APP_NAME = 'Ticker Journal'; + +export type { Database, EntryInsert, EntryRow, TickerInsert, TickerRow } from './database-exports'; diff --git a/packages/shared/vitest.config.ts b/packages/shared/vitest.config.ts index 7eeb3f8..4a76d2f 100644 --- a/packages/shared/vitest.config.ts +++ b/packages/shared/vitest.config.ts @@ -4,5 +4,13 @@ export default defineConfig({ test: { environment: 'node', include: ['src/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'text-summary', 'json-summary', 'html'], + reportsDirectory: './coverage', + include: ['src/index.ts'], + exclude: ['src/**/*.test.*'], + all: true, + }, }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef2e979..6cc23ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,6 +16,9 @@ importers: '@biomejs/biome': specifier: 2.5.7 version: 2.5.7 + husky: + specifier: ^9.1.7 + version: 9.1.7 turbo: specifier: ^2.5.4 version: 2.10.9 @@ -25,6 +28,12 @@ importers: apps/mobile: dependencies: + '@hookform/resolvers': + specifier: ^5.9.1 + version: 5.9.1(@sinclair/typebox@0.34.52)(react-hook-form@7.85.0(react@19.2.3))(zod@3.25.76) + '@supabase/supabase-js': + specifier: ^2.112.3 + version: 2.112.3 '@ticker-journal/shared': specifier: workspace:* version: link:../../packages/shared @@ -40,12 +49,21 @@ importers: expo-router: specifier: ~57.0.12 version: 57.0.12(2d1944fe711474db0ab9900b6473795b) + expo-secure-store: + specifier: ^57.0.1 + version: 57.0.1(expo@57.0.12) expo-status-bar: specifier: ~57.0.1 version: 57.0.1(expo@57.0.12)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3) + expo-web-browser: + specifier: ~57.0.2 + version: 57.0.2(expo@57.0.12)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3)) react: specifier: 19.2.3 version: 19.2.3 + react-hook-form: + specifier: ^7.85.0 + version: 7.85.0(react@19.2.3) react-native: specifier: 0.86.2 version: 0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3) @@ -58,9 +76,15 @@ importers: react-native-screens: specifier: ~4.26.2 version: 4.26.2(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3) + react-native-url-polyfill: + specifier: ^4.0.0 + version: 4.0.0(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3)) react-native-webview: specifier: 13.16.1 version: 13.16.1(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3) + zod: + specifier: ^3.25.76 + version: 3.25.76 devDependencies: '@testing-library/react-native': specifier: ^13.2.0 @@ -86,6 +110,12 @@ importers: apps/web: dependencies: + '@supabase/ssr': + specifier: ^0.12.4 + version: 0.12.4(@supabase/supabase-js@2.112.3) + '@supabase/supabase-js': + specifier: ^2.112.3 + version: 2.112.3 '@ticker-journal/shared': specifier: workspace:* version: link:../../packages/shared @@ -126,9 +156,15 @@ importers: '@vitejs/plugin-react': specifier: ^4.7.0 version: 4.7.0(vite@7.3.6(@types/node@20.19.43)(jiti@2.7.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) + '@vitest/coverage-v8': + specifier: ^3.2.7 + version: 3.2.7(vitest@3.2.7(@types/node@20.19.43)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) jsdom: specifier: ^26.1.0 version: 26.1.0 + sonda: + specifier: 0.14.0 + version: 0.14.0 tailwindcss: specifier: ^4 version: 4.3.3 @@ -145,6 +181,9 @@ importers: specifier: ^3.25.67 version: 3.25.76 devDependencies: + '@vitest/coverage-v8': + specifier: ^3.2.7 + version: 3.2.7(vitest@3.2.7(@types/node@20.19.43)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0)) typescript: specifier: ~7.0.2 version: 7.0.2 @@ -161,6 +200,10 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} @@ -621,6 +664,10 @@ packages: '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@biomejs/biome@2.5.7': resolution: {integrity: sha512-zr8K/DcY5tYsQOQwqMJ0AWElo6QgmgNI7idXgXLhevVszlt8RGVpesEJPqx3ThazLaOwjJ5Y8fz3BtH5fGZNsw==} engines: {node: '>=14.21.3'} @@ -1057,6 +1104,84 @@ packages: resolution: {integrity: sha512-4aQzz9vgxcNXFfo/iyNgDDYfsU5XGKKxWxZopw0cVotHiW+U8IJbIxMaxsINs6bHhtkG3StKNPcOrn3eBuxKPw==} hasBin: true + '@hookform/resolvers@5.9.1': + resolution: {integrity: sha512-7b7vsbraJxKgjVSA1Nur9tLwj539WGJUBLA7QNvXnFoT2pM5Z7G+6rlukk4B2/QrTZy6huRtH6wKeESPKuIr6w==} + peerDependencies: + '@sinclair/typebox': '>=0.25.24' + '@standard-schema/spec': ^1.0.0 + '@typeschema/main': '>=0.13.7' + '@vinejs/vine': ^2.0.0 || ^3.0.0 || ^4.0.0 + ajv: ^8.12.0 + ajv-errors: ^3.0.0 + ajv-formats: ^2.1.1 + arktype: ^2.0.0 + ata-validator: ^1.2.0 + class-transformer: '>=0.4.0' + class-validator: '>=0.12.0' + computed-types: ^1.0.0 + effect: ^3.10.3 + fluentvalidation-ts: ^3.0.0 + fp-ts: ^2.7.0 + io-ts: ^2.0.0 + joi: ^17.0.0 || ^18.0.0 + nope-validator: '>=0.12.0' + react-hook-form: ^7.55.0 + superstruct: '>=0.12.0' + typanion: ^3.3.2 + valibot: '>=0.31.0 || ^1.0.0-beta.4 || ^1.0.0-rc' + vest: '>=6.0.0' + yup: ^1.0.0 + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + '@sinclair/typebox': + optional: true + '@standard-schema/spec': + optional: true + '@typeschema/main': + optional: true + '@vinejs/vine': + optional: true + ajv: + optional: true + ajv-errors: + optional: true + ajv-formats: + optional: true + arktype: + optional: true + ata-validator: + optional: true + class-transformer: + optional: true + class-validator: + optional: true + computed-types: + optional: true + effect: + optional: true + fluentvalidation-ts: + optional: true + fp-ts: + optional: true + io-ts: + optional: true + joi: + optional: true + nope-validator: + optional: true + superstruct: + optional: true + typanion: + optional: true + valibot: + optional: true + vest: + optional: true + yup: + optional: true + zod: + optional: true + '@img/colour@1.1.0': resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} @@ -1219,6 +1344,10 @@ packages: cpu: [x64] os: [win32] + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + '@isaacs/ttlcache@1.4.1': resolution: {integrity: sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==} engines: {node: '>=12'} @@ -1394,6 +1523,10 @@ packages: cpu: [x64] os: [win32] + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@playwright/test@1.62.1': resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} engines: {node: '>=20'} @@ -1859,6 +1992,46 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + + '@supabase/auth-js@2.112.3': + resolution: {integrity: sha512-NA0rsgAlWZPvbhw8aUdmgfpHVgUAcd8zK5ov43l++o1bLIPXZhRiAlRobhwF5AatQuovpqxsMH50F4oyyV4XZw==} + engines: {node: '>=22.0.0'} + + '@supabase/functions-js@2.112.3': + resolution: {integrity: sha512-gfv481mTOVWtZIJgXupxZpni2V2UWPf6jeF/jOK7HdMHdH+mt6sU0sHHwf0POsPip8ltlulu9OUHgwVzl5ddRw==} + engines: {node: '>=22.0.0'} + + '@supabase/phoenix@0.4.5': + resolution: {integrity: sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==} + + '@supabase/postgrest-js@2.112.3': + resolution: {integrity: sha512-+Mf6uCpzr00bqxwX8hTK2X2L9eAL/1vuOjdEjx6upz9ulb0RmQT16XeU/JkMUlVHw/B46ZnPa2busY4Kd9YCzw==} + engines: {node: '>=22.0.0'} + + '@supabase/realtime-js@2.112.3': + resolution: {integrity: sha512-E6wljXWs7DUOloyIB69i3YFInWE6IyCvgTAbQ0KYxOHv26FdA1KzEXTuzxrYEdf70t406Z9BRwUlGyclGF2FXA==} + engines: {node: '>=22.0.0'} + + '@supabase/ssr@0.12.4': + resolution: {integrity: sha512-xHzcgI8cC1TpBKSwJcR5Yd8CCwfIq0SBc5yb4yz/YFw5tbCrEQ0QT3a+2jymCxHgQWLfzwN93HZ6eRbcoMkOlA==} + peerDependencies: + '@supabase/supabase-js': ^2.111.0 + + '@supabase/storage-js@2.112.3': + resolution: {integrity: sha512-oSK61tzlUvg+BWPqpKQCu9qqonsO26btaoAR9D6Gest2aj7xUqToj9rKyaoYOJczkhg9BjqA1REbYy9tPI4bDA==} + engines: {node: '>=22.0.0'} + + '@supabase/supabase-js@2.112.3': + resolution: {integrity: sha512-Jv1bxVQmEJNkjvPEhFaKjPzsh+Ozyew6lWGD+SoYcsclDEP1z7yEvKvfUQfzy0DkxRIQnZNxmmWtAzw5XLTQoA==} + engines: {node: '>=22.0.0'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@swc/helpers@0.5.15': resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} @@ -2229,6 +2402,15 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + '@vitest/coverage-v8@3.2.7': + resolution: {integrity: sha512-NEGWJS2XNu2PfRLQwOO3CTKj1tTETxNBdk454vDxVBhxJYhPaA/eS0nAI0c+1El1P7a60z8+i+ZrQoGESweGKg==} + peerDependencies: + '@vitest/browser': 3.2.7 + vitest: 3.2.7 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@3.2.7': resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} @@ -2342,6 +2524,10 @@ packages: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + anymatch@3.1.3: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} @@ -2373,6 +2559,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + ast-v8-to-istanbul@0.3.12: + resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -2479,6 +2668,9 @@ packages: brace-expansion@1.1.18: resolution: {integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==} + brace-expansion@2.1.4: + resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} + brace-expansion@5.0.9: resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} engines: {node: 20 || >=22} @@ -2650,6 +2842,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + core-js-compat@3.50.0: resolution: {integrity: sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q==} engines: {node: '>=6.4.0'} @@ -2791,6 +2987,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -2804,6 +3003,9 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + encodeurl@1.0.2: resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} engines: {node: '>= 0.8'} @@ -3010,6 +3212,11 @@ packages: react-server-dom-webpack: optional: true + expo-secure-store@57.0.1: + resolution: {integrity: sha512-tLa1VmSadOq19mA/dwkl99RbHyjLE0T1qqBYMY3/OsguZTI+rlrDy/DDJjupqlVtmr95hD7o1pYqx5aL+B4YMA==} + peerDependencies: + expo: '*' + expo-server@57.0.2: resolution: {integrity: sha512-GzfiSHC19xU7I0Dq4O/7DOtWdmc14vynpxBb9nWDRvsF+7RjoSIdkVePFhx4Qm6ILFNbo6KxFGu95QnDdKxUdw==} engines: {node: '>=20.16.0'} @@ -3029,6 +3236,12 @@ packages: react: 19.2.3 react-native: '*' + expo-web-browser@57.0.2: + resolution: {integrity: sha512-3vl5kvd7PB48ub6PpNIJUuPxO8xVa6D8RnIgNba6SXRwqFprOfeEZgwTgtm41kz0AAtvMOztUVNEUkwrHKjqMQ==} + peerDependencies: + expo: '*' + react-native: '*' + expo@57.0.12: resolution: {integrity: sha512-sVgXaMjh5uapBvBkik3QibxbKI2g1zNtNeHntjRfixWAI3tlQZbaK4ACdInY4+jUBVymkf8u9BXJUaSes9jdtA==} hasBin: true @@ -3103,6 +3316,10 @@ packages: fontfaceobserver@2.3.0: resolution: {integrity: sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + form-data@4.0.6: resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} @@ -3159,6 +3376,11 @@ packages: resolution: {integrity: sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==} engines: {node: '>=6'} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + glob@13.0.6: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} @@ -3257,6 +3479,15 @@ packages: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + + iceberg-js@0.8.1: + resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==} + engines: {node: '>=20.0.0'} + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -3354,10 +3585,17 @@ packages: resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} engines: {node: '>=10'} + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + istanbul-reports@3.2.0: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jest-changed-files@29.7.0: resolution: {integrity: sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -3534,6 +3772,9 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3789,6 +4030,9 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} @@ -3911,6 +4155,10 @@ packages: minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -4050,6 +4298,9 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parse-json@5.2.0: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} @@ -4080,6 +4331,10 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.2: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} @@ -4204,6 +4459,12 @@ packages: peerDependencies: react: 19.2.3 + react-hook-form@7.85.0: + resolution: {integrity: sha512-U2MTriFXnclmV4rOE20p2DcRFv5WEg3FIcBFOKcOLFHDVvGIMPvLTkTWefUsonmlaVy23khVDxDWym6uJVGOzw==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: 19.2.3 + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -4255,6 +4516,11 @@ packages: react: 19.2.3 react-native: '*' + react-native-url-polyfill@4.0.0: + resolution: {integrity: sha512-eqYM3wBAA0eL1sPYbBAoNfbES3+NkgcxUdelQ7QzmoVtqKB5qGG0U13MPTRUroAWK+y2EoJFS3MZUK0fwTf0pA==} + peerDependencies: + react-native: '*' + react-native-webview@13.16.1: resolution: {integrity: sha512-If0eHhoEdOYDcHsX+xBFwHMbWBGK1BvGDQDQdVkwtSIXiq1uiqjkpWVP2uQ1as94J0CzvFE9PUNDuhiX0Z6ubw==} peerDependencies: @@ -4472,6 +4738,10 @@ packages: signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + simple-plist@1.3.1: resolution: {integrity: sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==} @@ -4493,6 +4763,11 @@ packages: resolution: {integrity: sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==} engines: {node: '>=8.0.0'} + sonda@0.14.0: + resolution: {integrity: sha512-NAneRzAkk8KTW1G/Z8n3kUIUjZ8u6B1yRGq1onkD+D93CSOq/aTRVkHHutD/9R6M72Jei3AIpCbkSr8tjzZVdw==} + engines: {node: '>=22.12'} + hasBin: true + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -4579,6 +4854,10 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + strip-ansi@5.2.0: resolution: {integrity: sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==} engines: {node: '>=6'} @@ -4669,9 +4948,16 @@ packages: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} + test-exclude@7.0.2: + resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} + engines: {node: '>=18'} + throat@5.0.0: resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==} + tiny-open@1.3.0: + resolution: {integrity: sha512-GUFS8yjJZq0oWqCKCJVHcBgMpmi2WEGXY1le3E5ncR0DsgTII5uUyxtfk8/vGyDDGBE42UYHR6Ocvlk8mkXSRg==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -4986,6 +5272,10 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -5077,6 +5367,11 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@asamuzakjp/css-color@3.2.0': dependencies: '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) @@ -5636,6 +5931,8 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} + '@bcoe/v8-coverage@1.0.2': {} + '@biomejs/biome@2.5.7': optionalDependencies: '@biomejs/cli-darwin-arm64': 2.5.7 @@ -6159,6 +6456,14 @@ snapshots: chalk: 4.1.2 js-yaml: 4.3.1 + '@hookform/resolvers@5.9.1(@sinclair/typebox@0.34.52)(react-hook-form@7.85.0(react@19.2.3))(zod@3.25.76)': + dependencies: + '@standard-schema/utils': 0.3.0 + react-hook-form: 7.85.0(react@19.2.3) + optionalDependencies: + '@sinclair/typebox': 0.34.52 + zod: 3.25.76 + '@img/colour@1.1.0': optional: true @@ -6266,6 +6571,15 @@ snapshots: '@img/sharp-win32-x64@0.35.3': optional: true + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + '@isaacs/ttlcache@1.4.1': {} '@istanbuljs/load-nyc-config@1.1.0': @@ -6505,6 +6819,9 @@ snapshots: '@next/swc-win32-x64-msvc@16.3.0': optional: true + '@pkgjs/parseargs@0.11.0': + optional: true + '@playwright/test@1.62.1': dependencies: playwright: 1.62.1 @@ -6846,9 +7163,7 @@ snapshots: metro-runtime: 0.84.4 transitivePeerDependencies: - '@babel/core' - - bufferutil - supports-color - - utf-8-validate '@react-native/normalize-colors@0.86.2': {} @@ -6950,6 +7265,45 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@standard-schema/utils@0.3.0': {} + + '@supabase/auth-js@2.112.3': + dependencies: + tslib: 2.8.1 + + '@supabase/functions-js@2.112.3': + dependencies: + tslib: 2.8.1 + + '@supabase/phoenix@0.4.5': {} + + '@supabase/postgrest-js@2.112.3': + dependencies: + tslib: 2.8.1 + + '@supabase/realtime-js@2.112.3': + dependencies: + '@supabase/phoenix': 0.4.5 + tslib: 2.8.1 + + '@supabase/ssr@0.12.4(@supabase/supabase-js@2.112.3)': + dependencies: + '@supabase/supabase-js': 2.112.3 + cookie: 1.1.1 + + '@supabase/storage-js@2.112.3': + dependencies: + iceberg-js: 0.8.1 + tslib: 2.8.1 + + '@supabase/supabase-js@2.112.3': + dependencies: + '@supabase/auth-js': 2.112.3 + '@supabase/functions-js': 2.112.3 + '@supabase/postgrest-js': 2.112.3 + '@supabase/realtime-js': 2.112.3 + '@supabase/storage-js': 2.112.3 + '@swc/helpers@0.5.15': dependencies: tslib: 2.8.1 @@ -7248,6 +7602,25 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitest/coverage-v8@3.2.7(vitest@3.2.7(@types/node@20.19.43)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 1.0.2 + ast-v8-to-istanbul: 0.3.12 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.21 + magicast: 0.3.5 + std-env: 3.10.0 + test-exclude: 7.0.2 + tinyrainbow: 2.0.0 + vitest: 3.2.7(@types/node@20.19.43)(jiti@2.7.0)(jsdom@26.1.0)(lightningcss@1.33.0)(terser@5.49.2)(yaml@2.9.0) + transitivePeerDependencies: + - supports-color + '@vitest/expect@3.2.7': dependencies: '@types/chai': 5.2.3 @@ -7355,6 +7728,8 @@ snapshots: ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} + anymatch@3.1.3: dependencies: normalize-path: 3.0.0 @@ -7382,6 +7757,12 @@ snapshots: assertion-error@2.0.1: {} + ast-v8-to-istanbul@0.3.12: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + asynckit@0.4.0: {} babel-jest@29.7.0(@babel/core@7.29.7): @@ -7562,6 +7943,10 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 + brace-expansion@2.1.4: + dependencies: + balanced-match: 1.0.2 + brace-expansion@5.0.9: dependencies: balanced-match: 4.0.4 @@ -7737,6 +8122,8 @@ snapshots: convert-source-map@2.0.0: {} + cookie@1.1.1: {} + core-js-compat@3.50.0: dependencies: browserslist: 4.28.8 @@ -7848,6 +8235,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + eastasianwidth@0.2.0: {} + ee-first@1.1.1: {} electron-to-chromium@1.5.403: {} @@ -7856,6 +8245,8 @@ snapshots: emoji-regex@8.0.0: {} + emoji-regex@9.2.2: {} + encodeurl@1.0.2: {} encodeurl@2.0.0: {} @@ -8104,6 +8495,10 @@ snapshots: - react-native-worklets - supports-color + expo-secure-store@57.0.1(expo@57.0.12): + dependencies: + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.11.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3)(typescript@7.0.2) + expo-server@57.0.2: {} expo-status-bar@57.0.1(expo@57.0.12)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3): @@ -8121,6 +8516,11 @@ snapshots: react-native: 0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3) sf-symbols-typescript: 2.2.0 + expo-web-browser@57.0.2(expo@57.0.12)(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3)): + dependencies: + expo: 57.0.12(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.11.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3)(typescript@7.0.2) + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3) + expo@57.0.12(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.9)(expo-router@57.0.12)(react-dom@19.2.3(react@19.2.3))(react-native-webview@13.16.1(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3))(react-native-worklets@0.11.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3))(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3)(typescript@7.0.2): dependencies: '@babel/runtime': 7.29.7 @@ -8208,6 +8608,11 @@ snapshots: fontfaceobserver@2.3.0: {} + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + form-data@4.0.6: dependencies: asynckit: 0.4.0 @@ -8258,6 +8663,15 @@ snapshots: getenv@2.0.0: {} + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + glob@13.0.6: dependencies: minimatch: 10.2.6 @@ -8368,6 +8782,10 @@ snapshots: human-signals@2.1.0: {} + husky@9.1.7: {} + + iceberg-js@0.8.1: {} + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -8460,11 +8878,25 @@ snapshots: transitivePeerDependencies: - supports-color + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + istanbul-reports@3.2.0: dependencies: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jest-changed-files@29.7.0: dependencies: execa: 5.1.1 @@ -8850,6 +9282,8 @@ snapshots: jiti@2.7.0: {} + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -9078,6 +9512,12 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.3.5: + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + source-map-js: 1.2.1 + make-dir@4.0.0: dependencies: semver: 7.8.5 @@ -9301,6 +9741,10 @@ snapshots: dependencies: brace-expansion: 1.1.18 + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.4 + minipass@7.1.3: {} mkdirp@1.0.4: {} @@ -9425,6 +9869,8 @@ snapshots: p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} + parse-json@5.2.0: dependencies: '@babel/code-frame': 7.29.7 @@ -9450,6 +9896,11 @@ snapshots: path-parse@1.0.7: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + path-scurry@2.0.2: dependencies: lru-cache: 11.5.2 @@ -9573,6 +10024,10 @@ snapshots: dependencies: react: 19.2.3 + react-hook-form@7.85.0(react@19.2.3): + dependencies: + react: 19.2.3 + react-is@16.13.1: {} react-is@17.0.2: {} @@ -9624,6 +10079,10 @@ snapshots: react-native: 0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3) warn-once: 0.1.1 + react-native-url-polyfill@4.0.0(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3)): + dependencies: + react-native: 0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3) + react-native-webview@13.16.1(react-native@0.86.2(@babel/core@7.29.7)(@react-native/jest-preset@0.86.2(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.2(@babel/core@7.29.7))(@types/react@19.2.18)(react@19.2.3))(react@19.2.3): dependencies: escape-string-regexp: 4.0.0 @@ -9926,6 +10385,8 @@ snapshots: signal-exit@3.0.7: {} + signal-exit@4.1.0: {} + simple-plist@1.3.1: dependencies: bplist-creator: 0.1.0 @@ -9944,6 +10405,11 @@ snapshots: slugify@1.6.9: {} + sonda@0.14.0: + dependencies: + '@jridgewell/remapping': 2.3.5 + tiny-open: 1.3.0 + source-map-js@1.2.1: {} source-map-support@0.5.13: @@ -10021,6 +10487,12 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + strip-ansi@5.2.0: dependencies: ansi-regex: 4.1.1 @@ -10099,8 +10571,16 @@ snapshots: glob: 7.2.3 minimatch: 3.1.5 + test-exclude@7.0.2: + dependencies: + '@istanbuljs/schema': 0.1.6 + glob: 10.5.0 + minimatch: 10.2.6 + throat@5.0.0: {} + tiny-open@1.3.0: {} + tinybench@2.9.0: {} tinyexec@0.3.2: {} @@ -10403,6 +10883,12 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + wrappy@1.0.2: {} write-file-atomic@4.0.2: diff --git a/scripts/check-db-types.mjs b/scripts/check-db-types.mjs new file mode 100644 index 0000000..3ebf67a --- /dev/null +++ b/scripts/check-db-types.mjs @@ -0,0 +1,79 @@ +#!/usr/bin/env node +/** + * packages/shared/src/database.ts 가 `pnpm gen:types` 결과와 같은지 검사한다. + * + * 소스: + * - linked (기본, 로컬): `supabase gen types --linked` + * - local: `supabase start` 후 `--local` (CI 권장, 토큰 불필요) + * - project: `--project-id` + SUPABASE_ACCESS_TOKEN + */ +import { execFileSync } from 'node:child_process'; +import { readFileSync, unlinkSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(fileURLToPath(new URL('.', import.meta.url)), '..'); +const committedPath = resolve(root, 'packages/shared/src/database.ts'); +const generatedPath = resolve(root, 'packages/shared/src/database.generated.ts'); + +// biome-ignore lint/suspicious/noUndeclaredEnvVars: root script, not a turbo task +const projectId = process.env.SUPABASE_PROJECT_ID ?? 'bfzalnslexpnoohaqbgs'; +// biome-ignore lint/suspicious/noUndeclaredEnvVars: root script, not a turbo task +const source = process.env.DB_TYPES_SOURCE ?? (process.env.CI ? 'local' : 'linked'); + +const normalize = (src) => { + const body = src + .replace(/\r\n/g, '\n') + // remote gen만 붙는 메타 — local/remote 비교에서 제외 + .replace( + /\n\s*\/\/ Allows to automatically instantiate createClient with right options\n\s*\/\/ instead of createClient\(URL, KEY\)\n\s*__InternalSupabase: \{\n\s*PostgrestVersion: "[^"]+"\n\s*\}\n/, + '\n', + ) + .replace(/PostgrestVersion:\s*"[^"]+"/g, 'PostgrestVersion: "NORMALIZED"') + .trimEnd(); + return `${body}\n`; +}; + +const genArgs = (() => { + if (source === 'local') { + return ['supabase', 'gen', 'types', 'typescript', '--local', '--schema', 'public']; + } + if (source === 'project') { + return ['supabase', 'gen', 'types', 'typescript', '--project-id', projectId, '--schema', 'public']; + } + return ['supabase', 'gen', 'types', 'typescript', '--linked', '--schema', 'public']; +})(); + +// biome-ignore lint/suspicious/noUndeclaredEnvVars: optional CI secret +const accessToken = process.env.SUPABASE_ACCESS_TOKEN; +if (source === 'project' && !accessToken) { + console.warn('check:db-types: SUPABASE_ACCESS_TOKEN 없음 — project 소스 검사를 스킵합니다.'); + process.exit(0); +} + +const generated = execFileSync('npx', genArgs, { + cwd: root, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + env: process.env, +}); + +const committed = normalize(readFileSync(committedPath, 'utf8')); +const fresh = normalize(generated); + +if (committed !== fresh) { + const left = resolve(root, 'packages/shared/src/database.committed.norm.ts'); + writeFileSync(left, committed); + writeFileSync(generatedPath, fresh); + try { + execFileSync('diff', ['-u', left, generatedPath], { encoding: 'utf8', stdio: 'inherit' }); + } catch { + // diff exits 1 on mismatch + } + unlinkSync(left); + unlinkSync(generatedPath); + console.error('\nDatabase 타입이 스키마와 다릅니다. `pnpm gen:types` 후 커밋하세요.'); + process.exit(1); +} + +console.log(`Database 타입이 스키마와 일치합니다. (source=${source})`); diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 0000000..8cab882 --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1,4 @@ +project_id = "ticker-journal" + +[db] +major_version = 15 diff --git a/supabase/migrations/20260813100000_init.sql b/supabase/migrations/20260813100000_init.sql new file mode 100644 index 0000000..6f02717 --- /dev/null +++ b/supabase/migrations/20260813100000_init.sql @@ -0,0 +1,117 @@ +-- Phase 0: tickers / entries + RLS +create extension if not exists "pgcrypto"; + +create type public.market as enum ('US', 'KR'); +create type public.entry_type as enum ('memo', 'link', 'trade'); +create type public.trade_side as enum ('buy', 'sell'); + +create table public.tickers ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users (id) on delete cascade, + market public.market not null, + symbol text not null, + name text, + created_at timestamptz not null default now(), + unique (user_id, market, symbol) +); + +create table public.entries ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users (id) on delete cascade, + ticker_id uuid not null references public.tickers (id) on delete cascade, + type public.entry_type not null, + body text, + url text, + title text, + note text, + side public.trade_side, + traded_at timestamptz, + price numeric, + qty numeric, + reason text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint entries_payload_check check ( + (type = 'memo' and body is not null) + or (type = 'link' and url is not null) + or (type = 'trade' and side is not null and traded_at is not null) + ) +); + +create index entries_ticker_created_idx on public.entries (ticker_id, created_at desc); +create index entries_user_created_idx on public.entries (user_id, created_at desc); + +create or replace function public.set_updated_at() +returns trigger +language plpgsql +set search_path = '' +as $$ +begin + new.updated_at = now(); + return new; +end; +$$; + +create trigger entries_set_updated_at +before update on public.entries +for each row +execute function public.set_updated_at(); + +alter table public.tickers enable row level security; +alter table public.entries enable row level security; + +create policy "tickers_select_own" + on public.tickers for select + using (user_id = auth.uid()); + +create policy "tickers_insert_own" + on public.tickers for insert + with check (user_id = auth.uid()); + +create policy "tickers_update_own" + on public.tickers for update + using (user_id = auth.uid()) + with check (user_id = auth.uid()); + +create policy "tickers_delete_own" + on public.tickers for delete + using (user_id = auth.uid()); + +create policy "entries_select_own" + on public.entries for select + using (user_id = auth.uid()); + +create policy "entries_insert_own" + on public.entries for insert + with check ( + user_id = auth.uid() + and exists ( + select 1 + from public.tickers t + where t.id = ticker_id + and t.user_id = auth.uid() + ) + ); + +create policy "entries_update_own" + on public.entries for update + using (user_id = auth.uid()) + with check ( + user_id = auth.uid() + and exists ( + select 1 + from public.tickers t + where t.id = ticker_id + and t.user_id = auth.uid() + ) + ); + +create policy "entries_delete_own" + on public.entries for delete + using (user_id = auth.uid()); + +grant usage on schema public to anon, authenticated; +grant select, insert, update, delete on table public.tickers to authenticated; +grant select, insert, update, delete on table public.entries to authenticated; +grant select on table public.tickers to anon; +grant select on table public.entries to anon; diff --git a/supabase/migrations/20260820100000_entries_update_ticker_ownership.sql b/supabase/migrations/20260820100000_entries_update_ticker_ownership.sql new file mode 100644 index 0000000..f2cd6b2 --- /dev/null +++ b/supabase/migrations/20260820100000_entries_update_ticker_ownership.sql @@ -0,0 +1,15 @@ +-- entries UPDATE 시 ticker_id가 본인 소유 tickers만 가리키도록 보강 +drop policy if exists "entries_update_own" on public.entries; + +create policy "entries_update_own" + on public.entries for update + using (user_id = auth.uid()) + with check ( + user_id = auth.uid() + and exists ( + select 1 + from public.tickers t + where t.id = ticker_id + and t.user_id = auth.uid() + ) + ); diff --git a/supabase/migrations/20260820110000_grant_table_privileges.sql b/supabase/migrations/20260820110000_grant_table_privileges.sql new file mode 100644 index 0000000..d73134f --- /dev/null +++ b/supabase/migrations/20260820110000_grant_table_privileges.sql @@ -0,0 +1,6 @@ +-- authenticated/anon 역할에 테이블 권한 부여 (RLS 정책과 함께 필요) +grant usage on schema public to anon, authenticated; +grant select, insert, update, delete on table public.tickers to authenticated; +grant select, insert, update, delete on table public.entries to authenticated; +grant select on table public.tickers to anon; +grant select on table public.entries to anon; diff --git a/supabase/migrations/20260824100000_set_updated_at_search_path.sql b/supabase/migrations/20260824100000_set_updated_at_search_path.sql new file mode 100644 index 0000000..e7b1bac --- /dev/null +++ b/supabase/migrations/20260824100000_set_updated_at_search_path.sql @@ -0,0 +1,11 @@ +-- 트리거 함수 search_path 고정 (mutable search_path 경고 완화) +create or replace function public.set_updated_at() +returns trigger +language plpgsql +set search_path = '' +as $$ +begin + new.updated_at = now(); + return new; +end; +$$; diff --git a/turbo.json b/turbo.json index 3e91b9a..a8fbfb6 100644 --- a/turbo.json +++ b/turbo.json @@ -18,6 +18,10 @@ "test": { "dependsOn": ["^build"], "outputs": [] + }, + "test:coverage": { + "dependsOn": ["^build"], + "outputs": ["coverage/**"] } } }