From d0afd8bbd3352280f6099c5910104497e9377c6d Mon Sep 17 00:00:00 2001 From: scs0209 Date: Sat, 15 Aug 2026 18:05:14 +0900 Subject: [PATCH 01/34] =?UTF-8?q?feat:=20Supabase=20=EC=B4=88=EA=B8=B0=20?= =?UTF-8?q?=EC=8A=A4=ED=82=A4=EB=A7=88=EC=99=80=20RLS=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tickers/entries 테이블과 enum, 소유자 기준 RLS를 마이그레이션에 고정 - 로컬 CLI용 supabase/config.toml을 추가 Co-authored-by: Cursor --- supabase/config.toml | 6 ++ supabase/migrations/20260813100000_init.sql | 102 ++++++++++++++++++++ 2 files changed, 108 insertions(+) create mode 100644 supabase/config.toml create mode 100644 supabase/migrations/20260813100000_init.sql diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 0000000..f13f7b4 --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1,6 @@ +{ + "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..ee020bb --- /dev/null +++ b/supabase/migrations/20260813100000_init.sql @@ -0,0 +1,102 @@ +-- 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 +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()); + +create policy "entries_delete_own" + on public.entries for delete + using (user_id = auth.uid()); From 9234061db0ec7be7ab73e89e641c37b93bcbd0a9 Mon Sep 17 00:00:00 2001 From: scs0209 Date: Sat, 15 Aug 2026 18:05:26 +0900 Subject: [PATCH 02/34] =?UTF-8?q?feat(mobile):=20=EB=A7=A4=EC=A7=81?= =?UTF-8?q?=EB=A7=81=ED=81=AC=20=EC=9D=B8=EC=A6=9D=EA=B3=BC=20=EC=A2=85?= =?UTF-8?q?=EB=AA=A9=C2=B7=EC=97=94=ED=8A=B8=EB=A6=AC=20CRUD=EB=A5=BC=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SecureStore 세션과 로그인 게이트로 관심종목·엔트리 CRUD를 연동 - US TradingView WebView와 KR fallback, 관련 테스트를 추가 Co-authored-by: Cursor --- apps/mobile/__tests__/chart.test.ts | 16 ++ apps/mobile/__tests__/watchlist.test.tsx | 53 +++- apps/mobile/app/_layout.tsx | 38 ++- apps/mobile/app/index.tsx | 230 +++++++++++++--- apps/mobile/app/login.tsx | 100 +++++++ apps/mobile/app/ticker/[id].tsx | 322 +++++++++++++++++++++-- apps/mobile/lib/api.ts | 170 ++++++++++++ apps/mobile/lib/auth.tsx | 78 ++++++ apps/mobile/lib/chart.ts | 61 +++++ apps/mobile/lib/supabase.ts | 42 +++ apps/mobile/package.json | 3 + 11 files changed, 1048 insertions(+), 65 deletions(-) create mode 100644 apps/mobile/__tests__/chart.test.ts create mode 100644 apps/mobile/app/login.tsx create mode 100644 apps/mobile/lib/api.ts create mode 100644 apps/mobile/lib/auth.tsx create mode 100644 apps/mobile/lib/chart.ts create mode 100644 apps/mobile/lib/supabase.ts diff --git a/apps/mobile/__tests__/chart.test.ts b/apps/mobile/__tests__/chart.test.ts new file mode 100644 index 0000000..b198fbd --- /dev/null +++ b/apps/mobile/__tests__/chart.test.ts @@ -0,0 +1,16 @@ +import { buildChartHtml } from '../lib/chart'; + +describe('buildChartHtml', () => { + it('embeds TradingView for US symbols', () => { + const html = buildChartHtml('US', 'aapl'); + expect(html).toContain('TradingView.widget'); + expect(html).toContain('AAPL'); + }); + + it('uses fallback for KR symbols', () => { + 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 index b2060f1..c25861b 100644 --- a/apps/mobile/__tests__/watchlist.test.tsx +++ b/apps/mobile/__tests__/watchlist.test.tsx @@ -1,4 +1,5 @@ -import { render, screen } from '@testing-library/react-native'; +import { render, screen, waitFor } from '@testing-library/react-native'; + import WatchlistScreen from '../app/index'; jest.mock('expo-router', () => { @@ -6,17 +7,59 @@ jest.mock('expo-router', () => { return { Link: ({ children, asChild }: { children: React.ReactNode; asChild?: boolean; href: string }) => asChild ? children : <>{children}, + Redirect: () => null, + useFocusEffect: (cb: () => void | (() => void)) => { + React.useEffect(() => { + const cleanup = cb(); + return typeof cleanup === 'function' ? cleanup : undefined; + }, []); + }, Stack: { Screen: () => null, }, }; }); +jest.mock('../lib/auth', () => ({ + useAuth: () => ({ + session: { user: { id: '00000000-0000-4000-8000-000000000001' } }, + loading: false, + configured: true, + signInWithMagicLink: jest.fn(), + signOut: jest.fn(), + }), +})); + +jest.mock('../lib/api', () => ({ + listTickers: jest.fn(async () => [ + { + id: '11111111-1111-4111-8111-111111111111', + user_id: '00000000-0000-4000-8000-000000000001', + market: 'US', + symbol: 'AAPL', + name: 'Apple', + created_at: '2026-08-13T00:00:00.000Z', + }, + { + id: '22222222-2222-4222-8222-222222222222', + user_id: '00000000-0000-4000-8000-000000000001', + market: 'KR', + symbol: '005930', + name: '삼성전자', + created_at: '2026-08-13T00:00:00.000Z', + }, + ]), + createTicker: jest.fn(), + deleteTicker: jest.fn(), +})); + describe('WatchlistScreen', () => { - it('shows placeholder tickers', () => { + it('shows tickers from api', async () => { render(); - expect(screen.getByText('관심종목')).toBeOnTheScreen(); - expect(screen.getByText(/AAPL/)).toBeOnTheScreen(); - expect(screen.getByText(/005930/)).toBeOnTheScreen(); + await waitFor(() => { + expect(screen.getByText('관심종목')).toBeOnTheScreen(); + expect(screen.getByText(/AAPL/)).toBeOnTheScreen(); + expect(screen.getByText(/005930/)).toBeOnTheScreen(); + }); }); }); diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index c5990ec..35d387e 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -1,14 +1,44 @@ -import { Stack } from 'expo-router'; +import { Stack, useRouter } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; +import { 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 ( + { + await signOut(); + router.replace('/login'); + }} + accessibilityRole='button' + accessibilityLabel='로그아웃' + style={{ paddingHorizontal: 8 }} + > + 로그아웃 + + ); +}; export default function RootLayout() { return ( - <> + - + + , + }} + /> - + ); } diff --git a/apps/mobile/app/index.tsx b/apps/mobile/app/index.tsx index 872b44c..69409b8 100644 --- a/apps/mobile/app/index.tsx +++ b/apps/mobile/app/index.tsx @@ -1,49 +1,215 @@ -import { APP_NAME, type Market } from '@ticker-journal/shared'; -import { Link } from 'expo-router'; -import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { CreateTickerSchema, type Market, type Ticker } from '@ticker-journal/shared'; +import { Link, Redirect, useFocusEffect } from 'expo-router'; +import { useCallback, useState } from 'react'; +import { ActivityIndicator, Alert, FlatList, Modal, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; -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']; export default function WatchlistScreen() { + const { session, loading: authLoading } = useAuth(); + const [tickers, setTickers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + const [market, setMarket] = useState('US'); + const [symbol, setSymbol] = useState(''); + const [name, setName] = useState(''); + const [saving, setSaving] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + setTickers(await listTickers()); + } catch (err) { + setError(err instanceof Error ? err.message : '목록을 불러오지 못했습니다.'); + } finally { + setLoading(false); + } + }, []); + + useFocusEffect( + useCallback(() => { + if (!session) return; + void load(); + }, [load, session]), + ); + + if (authLoading) { + return ( + + + + ); + } + + if (!session) { + return ; + } + + const handleCreate = async () => { + setSaving(true); + try { + const parsed = CreateTickerSchema.parse({ + market, + symbol, + name: name.trim() ? name.trim() : null, + }); + await createTicker(parsed); + setModalOpen(false); + setSymbol(''); + setName(''); + await load(); + } catch (err) { + Alert.alert('추가 실패', err instanceof Error ? err.message : '종목을 추가하지 못했습니다.'); + } finally { + setSaving(false); + } + }; + + 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} + > + 추가 + + + + {loading ? : null} + {error ? {error} : null} + + {!loading && 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) => ( + setMarket(m)} + style={[styles.chip, market === m && styles.chipActive]} + accessibilityRole='button' + accessibilityLabel={`시장 ${m}`} + > + {m} + + ))} + + + + + setModalOpen(false)} style={styles.secondaryButton}> + 취소 + + + {saving ? : 저장} + + + + + ); } 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' }, + 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..a2a8d5b --- /dev/null +++ b/apps/mobile/app/login.tsx @@ -0,0 +1,100 @@ +import { Redirect } from 'expo-router'; +import { useState } from 'react'; +import { + ActivityIndicator, + KeyboardAvoidingView, + Platform, + Pressable, + StyleSheet, + Text, + TextInput, +} from 'react-native'; + +import { useAuth } from '../lib/auth'; + +export default function LoginScreen() { + const { session, configured, signInWithMagicLink } = useAuth(); + const [email, setEmail] = useState(''); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + const [pending, setPending] = useState(false); + + if (session) { + return ; + } + + const handleSubmit = async () => { + setError(null); + setMessage(null); + setPending(true); + try { + await signInWithMagicLink(email); + setMessage('매직링크를 이메일로 보냈습니다. 메일함에서 링크를 열어 주세요.'); + } catch (err) { + setError(err instanceof Error ? err.message : '로그인 요청에 실패했습니다.'); + } finally { + setPending(false); + } + }; + + return ( + + Ticker Journal + 로그인 + + {configured + ? '이메일 매직링크로 로그인합니다. Supabase Auth redirect에 tickerjournal://auth/callback 을 등록하세요.' + : 'EXPO_PUBLIC_SUPABASE_URL / EXPO_PUBLIC_SUPABASE_KEY 가 비어 있습니다. apps/mobile/.env 를 채운 뒤 Expo를 재시작하세요.'} + + + + + + {pending ? : 매직링크 보내기} + + + {message ? {message} : null} + {error ? {error} : null} + + ); +} + +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, + }, + button: { + backgroundColor: '#111', + borderRadius: 8, + paddingVertical: 14, + alignItems: 'center', + }, + buttonDisabled: { opacity: 0.5 }, + buttonText: { color: '#fff', fontWeight: '600' }, + 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..75ae929 100644 --- a/apps/mobile/app/ticker/[id].tsx +++ b/apps/mobile/app/ticker/[id].tsx @@ -1,51 +1,325 @@ -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, 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 chartHtml = useMemo(() => { + if (!ticker) return ''; + return buildChartHtml(ticker.market, ticker.symbol); + }, [ticker]); + + const load = useCallback(async () => { + if (!tickerId) return; + setLoading(true); + setError(null); + try { + const [nextTicker, nextEntries] = await Promise.all([getTicker(tickerId), listEntries(tickerId, filter)]); + setTicker(nextTicker); + setEntries(nextEntries); + } catch (err) { + setError(err instanceof Error ? err.message : '상세를 불러오지 못했습니다.'); + } finally { + setLoading(false); + } + }, [filter, tickerId]); + + useFocusEffect( + useCallback(() => { + if (!session) return; + void load(); + }, [load, session]), + ); + + if (authLoading) { + return ( + + + + ); + } + + if (!session) { + return ; + } + + 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); + setModalOpen(false); + setBody(''); + setUrl(''); + setTitle(''); + setNote(''); + setReason(''); + 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 ? 타임라인이 비어 있습니다. : 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} + + + setModalOpen(false)} style={styles.secondaryButton}> + 취소 + + + {saving ? : 저장} + + + + + ); } +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()} · ${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/lib/api.ts b/apps/mobile/lib/api.ts new file mode 100644 index 0000000..9df98cc --- /dev/null +++ b/apps/mobile/lib/api.ts @@ -0,0 +1,170 @@ +import { + type CreateEntryInput, + CreateEntrySchema, + type CreateTickerInput, + CreateTickerSchema, + type Entry, + EntrySchema, + type Ticker, + TickerSchema, + type TimelineFilter, +} from '@ticker-journal/shared'; +import type { z } from 'zod'; + +import { supabase } from './supabase'; + +const EntryRowSchema = EntrySchema; +type EntryRow = z.infer; + +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) 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) => EntryRowSchema.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 as Record) + .select('*') + .single(); + if (error) throw error; + return EntryRowSchema.parse(normalizeEntryRow(data as Record)); +}; + +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) => { + const base = { + user_id: userId, + ticker_id: parsed.ticker_id, + type: parsed.type, + body: null as string | null, + url: null as string | null, + title: null as string | null, + note: null as string | null, + side: null as 'buy' | 'sell' | null, + traded_at: null as string | null, + price: null as number | null, + qty: null as number | null, + reason: null as string | 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: Record): EntryRow => { + const type = row.type; + if (type === 'memo') { + return { + id: String(row.id), + user_id: String(row.user_id), + ticker_id: String(row.ticker_id), + created_at: String(row.created_at), + updated_at: String(row.updated_at), + type: 'memo', + body: String(row.body ?? ''), + }; + } + if (type === 'link') { + return { + id: String(row.id), + user_id: String(row.user_id), + ticker_id: String(row.ticker_id), + created_at: String(row.created_at), + updated_at: String(row.updated_at), + type: 'link', + url: String(row.url ?? ''), + title: (row.title as string | null) ?? null, + note: (row.note as string | null) ?? null, + }; + } + return { + id: String(row.id), + user_id: String(row.user_id), + ticker_id: String(row.ticker_id), + created_at: String(row.created_at), + updated_at: String(row.updated_at), + type: 'trade', + side: row.side === 'sell' ? 'sell' : 'buy', + traded_at: String(row.traded_at ?? ''), + price: row.price == null ? null : Number(row.price), + qty: row.qty == null ? null : Number(row.qty), + reason: (row.reason as string | null) ?? null, + }; +}; diff --git a/apps/mobile/lib/auth.tsx b/apps/mobile/lib/auth.tsx new file mode 100644 index 0000000..29ab10a --- /dev/null +++ b/apps/mobile/lib/auth.tsx @@ -0,0 +1,78 @@ +import type { Session, User } from '@supabase/supabase-js'; +import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from 'react'; + +import { isSupabaseConfigured, supabase } from './supabase'; + +type AuthContextValue = { + session: Session | null; + user: User | null; + loading: boolean; + configured: boolean; + signInWithMagicLink: (email: string) => Promise; + signOut: () => Promise; +}; + +const AuthContext = createContext(null); + +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; + supabase.auth.getSession().then(({ data }) => { + if (!mounted) return; + setSession(data.session); + setLoading(false); + }); + + const { data: subscription } = supabase.auth.onAuthStateChange((_event, next) => { + setSession(next); + setLoading(false); + }); + + return () => { + mounted = false; + subscription.subscription.unsubscribe(); + }; + }, []); + + const value = useMemo( + () => ({ + session, + user: session?.user ?? null, + loading, + configured: isSupabaseConfigured, + 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: 'tickerjournal://auth/callback', + }, + }); + if (error) throw error; + }, + 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..1864204 --- /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 = escapeHtml(symbol.toUpperCase()); + 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..7f2ca93 --- /dev/null +++ b/apps/mobile/lib/supabase.ts @@ -0,0 +1,42 @@ +import 'react-native-url-polyfill/auto'; + +import { createClient } from '@supabase/supabase-js'; +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..888de38 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -3,17 +3,20 @@ "version": "1.0.0", "main": "expo-router/entry", "dependencies": { + "@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", "react": "19.2.3", "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-url-polyfill": "^4.0.0", "react-native-webview": "13.16.1" }, "devDependencies": { From 6de843463204e11190137264e1aa77afed42b2c5 Mon Sep 17 00:00:00 2001 From: scs0209 Date: Sat, 15 Aug 2026 18:05:34 +0900 Subject: [PATCH 03/34] =?UTF-8?q?feat(web):=20=EB=A7=A4=EC=A7=81=EB=A7=81?= =?UTF-8?q?=ED=81=AC=20=EC=84=B8=EC=85=98=EA=B3=BC=20=EA=B4=80=EC=8B=AC?= =?UTF-8?q?=EC=A2=85=EB=AA=A9=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @supabase/ssr와 proxy로 세션을 갱신하고 콜백에서 코드를 교환 - 홈에서 동일 계정 관심종목을 조회하고 env 예시를 보강 Co-authored-by: Cursor --- .env.example | 13 ++- apps/web/e2e/home.spec.ts | 2 +- apps/web/package.json | 6 +- apps/web/src/app/auth/callback/route.ts | 19 ++++ apps/web/src/app/login/actions.ts | 11 ++ apps/web/src/app/login/page.tsx | 84 ++++++++++++++ apps/web/src/app/page.tsx | 30 ++++- apps/web/src/components/home-view.test.tsx | 34 +++++- apps/web/src/components/home-view.tsx | 90 ++++++++++++--- apps/web/src/lib/supabase/client.ts | 8 ++ apps/web/src/lib/supabase/env.ts | 8 ++ apps/web/src/lib/supabase/middleware.ts | 33 ++++++ apps/web/src/lib/supabase/server.ts | 26 +++++ apps/web/src/proxy.ts | 9 ++ pnpm-lock.yaml | 121 ++++++++++++++++++++- 15 files changed, 469 insertions(+), 25 deletions(-) create mode 100644 apps/web/src/app/auth/callback/route.ts create mode 100644 apps/web/src/app/login/actions.ts create mode 100644 apps/web/src/app/login/page.tsx create mode 100644 apps/web/src/lib/supabase/client.ts create mode 100644 apps/web/src/lib/supabase/env.ts create mode 100644 apps/web/src/lib/supabase/middleware.ts create mode 100644 apps/web/src/lib/supabase/server.ts create mode 100644 apps/web/src/proxy.ts diff --git a/.env.example b/.env.example index a6dc577..a2bd579 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,16 @@ # Supabase (Phase 0+) +# Dashboard → Project Settings → API NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_ANON_KEY= + +# Expo (apps/mobile/.env) EXPO_PUBLIC_SUPABASE_URL= -EXPO_PUBLIC_SUPABASE_ANON_KEY= +EXPO_PUBLIC_SUPABASE_KEY= + +# Next (apps/web/.env) +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_ANON_KEY= + +# Auth redirects +# - mobile: tickerjournal://auth/callback +# - web: http://localhost:3000/auth/callback diff --git a/apps/web/e2e/home.spec.ts b/apps/web/e2e/home.spec.ts index 8d226b5..4f03e73 100644 --- a/apps/web/e2e/home.spec.ts +++ b/apps/web/e2e/home.spec.ts @@ -2,6 +2,6 @@ import { expect, test } from '@playwright/test'; test('home shows archive scaffold', 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/package.json b/apps/web/package.json index 0d95f8d..a7a0e58 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,10 +14,12 @@ "test:e2e:ui": "playwright test --ui" }, "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", 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..2dd41ce --- /dev/null +++ b/apps/web/src/app/auth/callback/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from 'next/server'; + +import { createClient } from '@/lib/supabase/server'; + +export const GET = async (request: Request) => { + const { searchParams, origin } = new URL(request.url); + const code = searchParams.get('code'); + const next = searchParams.get('next') ?? '/'; + + if (code) { + const supabase = await createClient(); + const { error } = await supabase.auth.exchangeCodeForSession(code); + if (!error) { + return NextResponse.redirect(new URL(next, origin)); + } + } + + return NextResponse.redirect(new URL('/login?error=auth', origin)); +}; 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..409dc0e --- /dev/null +++ b/apps/web/src/app/login/page.tsx @@ -0,0 +1,84 @@ +'use client'; + +import Link from 'next/link'; +import { useState } from 'react'; + +import { createClient } from '@/lib/supabase/client'; +import { getSupabaseEnv } from '@/lib/supabase/env'; + +export default function LoginPage() { + const { configured } = getSupabaseEnv(); + const [email, setEmail] = useState(''); + const [message, setMessage] = useState(null); + const [error, setError] = useState(null); + const [pending, setPending] = useState(false); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setMessage(null); + setError(null); + setPending(true); + try { + if (!configured) { + throw new Error('NEXT_PUBLIC_SUPABASE_URL / ANON_KEY 가 비어 있습니다.'); + } + const supabase = createClient(); + const { error: signInError } = await supabase.auth.signInWithOtp({ + email: email.trim(), + options: { + emailRedirectTo: `${window.location.origin}/auth/callback`, + }, + }); + if (signInError) throw signInError; + setMessage('매직링크를 보냈습니다. 메일함에서 링크를 열어 주세요.'); + } catch (err) { + setError(err instanceof Error ? err.message : '로그인 요청에 실패했습니다.'); + } finally { + setPending(false); + } + }; + + return ( +
+
+ + ← 홈 + +

로그인

+

+ 모바일과 같은 Supabase 계정입니다. Auth redirect에{' '} + http://localhost:3000/auth/callback 을 등록하세요. +

+ +
+ + setEmail(event.target.value)} + className='rounded-lg border border-zinc-300 bg-white px-3 py-2 text-base outline-none focus:border-zinc-500' + placeholder='you@example.com' + /> + +
+ + {!configured ? ( +

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

+ ) : null} + {message ?

{message}

: null} + {error ?

{error}

: null} +
+
+ ); +} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 28a2935..43ef661 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -1,5 +1,31 @@ +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[] = []; + if (user) { + const { data, error } = await supabase.from('tickers').select('*').order('created_at', { ascending: false }); + if (!error && 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/components/home-view.test.tsx b/apps/web/src/components/home-view.test.tsx index 3d325d6..bb2d359 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(); + it('renders archive scaffold copy when logged out', () => { + render(); + expect(screen.getByRole('heading', { name: '웹 아카이브' })).toBeInTheDocument(); expect(screen.getByText(/Ticker Journal/i)).toBeInTheDocument(); + expect(screen.getByRole('link', { name: '로그인' })).toBeInTheDocument(); + }); + + it('renders tickers when logged in', () => { + render( + , + ); + expect(screen.getByText(/AAPL/)).toBeInTheDocument(); + expect(screen.getByText('you@example.com')).toBeInTheDocument(); }); }); diff --git a/apps/web/src/components/home-view.tsx b/apps/web/src/components/home-view.tsx index 7a8a7c2..a96a7e9 100644 --- a/apps/web/src/components/home-view.tsx +++ b/apps/web/src/components/home-view.tsx @@ -1,23 +1,87 @@ +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; +}; + +export function HomeView({ email = null, tickers = [], configured = false }: 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} +

+

관심종목

+ {tickers.length === 0 ? ( +

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

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

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

    +

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

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

시작

+
    +
  • 매직링크 로그인
  • +
  • 모바일과 동일 계정으로 관심종목 조회
  • +
  • Phase 1: entries 검색 · 종목 상세
  • +
+
+ )}
); diff --git a/apps/web/src/lib/supabase/client.ts b/apps/web/src/lib/supabase/client.ts new file mode 100644 index 0000000..b6d2800 --- /dev/null +++ b/apps/web/src/lib/supabase/client.ts @@ -0,0 +1,8 @@ +import { createBrowserClient } from '@supabase/ssr'; + +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..3c352fe --- /dev/null +++ b/apps/web/src/lib/supabase/middleware.ts @@ -0,0 +1,33 @@ +import { createServerClient } from '@supabase/ssr'; +import { type NextRequest, NextResponse } from 'next/server'; + +import { getSupabaseEnv } from './env'; + +export const updateSession = async (request: NextRequest) => { + 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) { + 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); + } + }, + }, + }); + + 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..129876f --- /dev/null +++ b/apps/web/src/lib/supabase/server.ts @@ -0,0 +1,26 @@ +import { createServerClient } from '@supabase/ssr'; +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) { + 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/pnpm-lock.yaml b/pnpm-lock.yaml index ef2e979..510cd94 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,6 +25,9 @@ importers: apps/mobile: dependencies: + '@supabase/supabase-js': + specifier: ^2.112.3 + version: 2.112.3 '@ticker-journal/shared': specifier: workspace:* version: link:../../packages/shared @@ -40,6 +43,9 @@ 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) @@ -58,6 +64,9 @@ 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) @@ -86,6 +95,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 @@ -1859,6 +1874,43 @@ packages: '@sinonjs/fake-timers@10.3.0': resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + '@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==} @@ -2650,6 +2702,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'} @@ -3010,6 +3066,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'} @@ -3257,6 +3318,10 @@ packages: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} + 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'} @@ -4255,6 +4320,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: @@ -6846,9 +6916,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 +7018,43 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 + '@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 @@ -7737,6 +7842,8 @@ snapshots: convert-source-map@2.0.0: {} + cookie@1.1.1: {} + core-js-compat@3.50.0: dependencies: browserslist: 4.28.8 @@ -8104,6 +8211,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): @@ -8368,6 +8479,8 @@ snapshots: human-signals@2.1.0: {} + iceberg-js@0.8.1: {} + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -9624,6 +9737,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 From d29caf5e8094bf08216e47fe3f8bf1b879f1462a Mon Sep 17 00:00:00 2001 From: scs0209 Date: Sat, 15 Aug 2026 18:05:40 +0900 Subject: [PATCH 04/34] =?UTF-8?q?docs:=20Phase=200=20=EC=9D=B8=EC=A6=9D?= =?UTF-8?q?=C2=B7CRUD=20=EB=AC=B8=EC=84=9C=EB=A5=BC=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 로컬 Supabase 설정과 아키텍처 체크리스트를 반영 - 포트폴리오·이력서 불릿을 Auth/CRUD 성과 기준으로 수정 Co-authored-by: Cursor --- .cursor/rules/portfolio-docs.mdc | 10 ++-- README.md | 13 ++++- docs/architecture.md | 18 +++---- docs/portfolio.md | 88 +++++++++++++++++++++----------- docs/resume-bullets.md | 60 +++++++++++++--------- 5 files changed, 121 insertions(+), 68 deletions(-) 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/README.md b/README.md index f6313c2..077f6ce 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,18 @@ 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 또는 CLI로 `supabase/migrations/20260813100000_init.sql` 적용 +3. Auth → URL Configuration에 추가: + - `tickerjournal://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/docs/architecture.md b/docs/architecture.md index 1ecb35e..9254004 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -248,7 +248,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 | @@ -352,14 +352,14 @@ 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` 갱신 --- diff --git a/docs/portfolio.md b/docs/portfolio.md index fc37445..75cdd87 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 magic link
Postgres + RLS)] end M --> Z @@ -97,10 +98,10 @@ erDiagram gantt title Ticker Journal delivery dateFormat YYYY-MM-DD - section Now - Scaffold + tests :done, s0, 2026-08-11, 1d + section Done + Scaffold + tests + Biome/TS7 :done, s0, 2026-08-11, 3d section Phase 0 - Auth + CRUD mobile :p0, 2026-08-12, 10d + Auth + CRUD mobile + web session :active, p0, 2026-08-13, 7d section Phase 1 Web search + detail :p1, after p0, 7d section Phase 2 @@ -122,10 +123,10 @@ flowchart TB | 영역 | 도구 | 역할 | |------|------|------| -| shared | Vitest | 스키마·도메인 규칙 | -| web | Vitest + RTL | UI 계약 | -| web | Playwright | 브라우저 플로우 | -| mobile | jest-expo + RNTL | 네이티브 화면 | +| shared | Vitest | 스키마·도메인 규칙 (**7**) | +| web | Vitest + RTL | UI 계약 (**2**) | +| web | Playwright | 브라우저 플로우 (**1**) | +| mobile | jest-expo + RNTL | 화면·차트 HTML (**3**) | --- @@ -154,14 +155,33 @@ 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) +- 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 이후 로드맵 +### 1.6 Phase 0 — Auth + CRUD (2026-08-13, `feat/phase-0-auth-crud`) -- [ ] Phase 0: Supabase Auth + CRUD (앱) -- [ ] Phase 1: 웹 검색·종목 상세 +**백엔드** + +- `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 이후 로드맵 + +- [x] Phase 0: Supabase Auth + 모바일 CRUD + 웹 세션/목록 (`feat/phase-0-auth-crud`) +- [ ] Phase 1: 웹 entries 검색·종목 상세 - [ ] Phase 2: EAS → App Store / Play Store - [ ] v1.1 공유 시트 / v2 AI 브리핑 @@ -175,8 +195,11 @@ flowchart TB | 아키텍처 | WebView 래퍼 감점 위험 | 네이티브 셸 + WebView 차트 | | 성공 조건 | 내부 배포만으론 이력서 신호 약함 | 양 스토어를 Success Criteria로 | | 모노레포 | 앱별 lock/workspace 충돌 | 루트 workspace만 유지 | -| 테스트 | React 19.2.3 vs react-dom 19.2.8 → RTL 빈 DOM | `pnpm.overrides`로 정렬 | +| 테스트 | React 19.2.3 vs react-dom 19.2.8 → RTL 빈 DOM | overrides로 정렬 (`pnpm-workspace.yaml`) | | RN 테스트 | Vitest 통일 유혹 | mobile만 jest-expo + RNTL | +| Phase 0 | Supabase 없이 앱만 만들면 E2E 검증 불가 | 마이그레이션·RLS·env를 코드와 같이 고정 | +| Next 16 | `middleware` deprecation | `proxy.ts`로 세션 갱신 이전 | +| Auth | 모바일/웹 redirect URL이 다름 | `tickerjournal://…` + `localhost:3000/auth/callback` 둘 다 등록 | --- @@ -184,9 +207,11 @@ flowchart TB - 포트폴리오 모바일은 **배포·네이티브 표면 + 수치**가 기능 나열보다 세다. - 경쟁 상대는 Tradervue가 아니라 **노션+시트 분산**. -- 모노레포 공유 스키마 = “풀스택 모바일” 한 줄. +- 모노레포 공유 Zod + RLS = “클라이언트가 달라도 권한 모델은 하나”. - React 버전 불일치는 RTL이 **조용히 빈 트리**를 그린다. -- 다이어그램(와이어·머메이드)을 문서에 두면 면접·노션 포트폴리오에 바로 붙일 수 있다. +- 다이어그램을 문서에 두면 면접·노션 포트폴리오에 바로 붙일 수 있다. +- **문서 동기화**: 코드 마일스톤마다 `portfolio.md` / `resume-bullets.md`를 같이 갱신하지 않으면 이력서 문장이 코드보다 뒤처진다. +- Next 16에서는 edge 세션 갱신을 `proxy` 컨벤션으로 맞추는 편이 경고·미래 호환에 유리하다. --- @@ -194,19 +219,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 · 관심종목/엔트리 CRUD · TradingView WebView +- Next.js `@supabase/ssr` 로그인·콜백·관심종목 조회 +- Postgres 스키마 + RLS, Zod 공유 스키마로 입력 검증 +- Vitest / RTL / Playwright / jest-expo 테스트 계층 (**13+** 케이스) +- (예정) entries 웹 검색, 스토어 2곳 배포 ### 성과 / 임팩트 -- *(스토어 URL, 실사용 종목 N·주간 M — `docs/resume-bullets.md` 지표 표 참고)* +- 앱·웹 **동일 Supabase 계정**으로 Auth + 종목 목록 동기화 경로 확보 +- *(실사용 종목 N·주간 entry M, 스토어 URL — `docs/resume-bullets.md` 지표 표)* --- @@ -219,4 +247,6 @@ 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`로 레포에 포함 | diff --git a/docs/resume-bullets.md b/docs/resume-bullets.md index fb889be..f775f34 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 기준) -구현 자랑 대신 **규모·품질 게이트**로 쓴다. +- 웹·모바일·공유 패키지 **3앱 모노레포**로 도메인 스키마를 한곳(`packages/shared`)에 두고, 단위·컴포넌트·E2E **4계층** 테스트 **13+ 케이스** 통과 (Vitest 7 + 웹 RTL 2 + Playwright 1 + jest-expo 3) +- 관심종목 → 종목 상세(WebView) → 웹 아카이브까지 **핵심 루프 3화면**으로 MVP 고정, 브로커 SaaS 범위는 제외 +- Supabase **매직링크 Auth + 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건·스토어 심사용 경로 충족” | **13+** 로컬 green | +| 범위 통제 | 제외한 기능 **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계층 / 13+건**으로 회귀 방지, 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를 붙이고, Postgres `tickers`/`entries` + RLS로 모바일 CRUD·웹 관심종목 조회까지 연결 +- `[x]` 관심종목·엔트리(memo/link/trade) 입력 경로를 Zod 공유 스키마로 검증하고, US TradingView WebView / KR fallback으로 차트 표면을 분리 +- `[x]` 모노레포 테스트 **4계층 / 13+건** + Biome + TypeScript 7으로 품질 게이트 유지 +- `[ ]` 본인 계정으로 관심종목 **__**개, entry **__**건 생성 후 웹에서 동일 목록 end-to-end 검증 (SQL 적용·실로그인 후 채움) + +### 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계층/13+**, 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:`)에 포함한다. From b3d6ab79618573206894fa194a03b9c38714b414 Mon Sep 17 00:00:00 2001 From: scs0209 Date: Sat, 15 Aug 2026 19:06:20 +0900 Subject: [PATCH 05/34] =?UTF-8?q?feat(web):=20=EC=BD=9C=EB=B0=B1=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EA=B2=B0=EC=A0=95=EC=9D=84=20=EC=88=9C?= =?UTF-8?q?=EC=88=98=20=ED=95=A8=EC=88=98=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 성공/실패·외부 next를 resolveAuthCallbackPath에서 처리 - 라우트는 코드 교환 후 경로만 리다이렉트 Co-authored-by: Cursor --- .../src/app/auth/callback/redirect.test.ts | 37 +++++++++++++++++++ apps/web/src/app/auth/callback/redirect.ts | 18 +++++++++ apps/web/src/app/auth/callback/route.ts | 9 +++-- 3 files changed, 60 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/app/auth/callback/redirect.test.ts create mode 100644 apps/web/src/app/auth/callback/redirect.ts 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..f7d4cec --- /dev/null +++ b/apps/web/src/app/auth/callback/redirect.test.ts @@ -0,0 +1,37 @@ +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('/'); + }); +}); 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..c718c47 --- /dev/null +++ b/apps/web/src/app/auth/callback/redirect.ts @@ -0,0 +1,18 @@ +const LOGIN_ERROR_PATH = '/login?error=auth'; + +const isSafeNextPath = (next: string): boolean => next.startsWith('/') && !next.startsWith('//'); + +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 index 2dd41ce..ec7fea6 100644 --- a/apps/web/src/app/auth/callback/route.ts +++ b/apps/web/src/app/auth/callback/route.ts @@ -2,18 +2,19 @@ import { NextResponse } from 'next/server'; import { createClient } from '@/lib/supabase/server'; +import { resolveAuthCallbackPath } from './redirect'; + export const GET = async (request: Request) => { const { searchParams, origin } = new URL(request.url); const code = searchParams.get('code'); const next = searchParams.get('next') ?? '/'; + let exchangeOk = false; if (code) { const supabase = await createClient(); const { error } = await supabase.auth.exchangeCodeForSession(code); - if (!error) { - return NextResponse.redirect(new URL(next, origin)); - } + exchangeOk = !error; } - return NextResponse.redirect(new URL('/login?error=auth', origin)); + return NextResponse.redirect(new URL(resolveAuthCallbackPath({ code, exchangeOk, next }), origin)); }; From a8064ff9e103bc0cc3983c844f5a0528fc699d2f Mon Sep 17 00:00:00 2001 From: scs0209 Date: Sat, 15 Aug 2026 19:06:31 +0900 Subject: [PATCH 06/34] =?UTF-8?q?test:=20=EA=B2=B0=EA=B3=BC=20=EC=A4=91?= =?UTF-8?q?=EC=8B=AC=EC=9C=BC=EB=A1=9C=20=EA=B8=B0=EC=A4=80=EC=9D=84=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=ED=95=98=EA=B3=A0=20CI=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 라우터 mock 화면 테스트를 제거하고 it 설명을 한국어로 통일 - Playwright 로그인 스모크와 GitHub Actions(check/typecheck/test/e2e)를 추가 Co-authored-by: Cursor --- .cursor/rules/testing.mdc | 35 ++++++++++++ .github/workflows/ci.yml | 38 +++++++++++++ README.md | 1 + apps/mobile/__tests__/chart.test.ts | 4 +- apps/mobile/__tests__/watchlist.test.tsx | 65 ---------------------- apps/web/e2e/home.spec.ts | 2 +- apps/web/e2e/login.spec.ts | 8 +++ apps/web/src/components/home-view.test.tsx | 9 +-- apps/web/vitest.setup.ts | 6 ++ biome.json | 2 +- docs/testing.md | 43 +++++++++----- package.json | 1 + packages/shared/src/index.test.ts | 14 ++--- 13 files changed, 130 insertions(+), 98 deletions(-) create mode 100644 .cursor/rules/testing.mdc create mode 100644 .github/workflows/ci.yml delete mode 100644 apps/mobile/__tests__/watchlist.test.tsx create mode 100644 apps/web/e2e/login.spec.ts 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/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a5ef7c3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - 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 + + e2e: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - 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/web exec playwright install --with-deps chromium + - run: pnpm test:e2e diff --git a/README.md b/README.md index 077f6ce..fb9d054 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 ci # Biome + typecheck + unit pnpm check # Biome lint + format check pnpm check:fix # Biome auto-fix pnpm typecheck diff --git a/apps/mobile/__tests__/chart.test.ts b/apps/mobile/__tests__/chart.test.ts index b198fbd..5f11bde 100644 --- a/apps/mobile/__tests__/chart.test.ts +++ b/apps/mobile/__tests__/chart.test.ts @@ -1,13 +1,13 @@ import { buildChartHtml } from '../lib/chart'; describe('buildChartHtml', () => { - it('embeds TradingView for US symbols', () => { + it('US 심볼에 TradingView 위젯을 넣는다', () => { const html = buildChartHtml('US', 'aapl'); expect(html).toContain('TradingView.widget'); expect(html).toContain('AAPL'); }); - it('uses fallback for KR symbols', () => { + it('KR 심볼은 fallback HTML을 쓴다', () => { const html = buildChartHtml('KR', '005930'); expect(html).toContain('KR 차트 fallback'); expect(html).toContain('005930'); diff --git a/apps/mobile/__tests__/watchlist.test.tsx b/apps/mobile/__tests__/watchlist.test.tsx deleted file mode 100644 index c25861b..0000000 --- a/apps/mobile/__tests__/watchlist.test.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { render, screen, waitFor } 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}, - Redirect: () => null, - useFocusEffect: (cb: () => void | (() => void)) => { - React.useEffect(() => { - const cleanup = cb(); - return typeof cleanup === 'function' ? cleanup : undefined; - }, []); - }, - Stack: { - Screen: () => null, - }, - }; -}); - -jest.mock('../lib/auth', () => ({ - useAuth: () => ({ - session: { user: { id: '00000000-0000-4000-8000-000000000001' } }, - loading: false, - configured: true, - signInWithMagicLink: jest.fn(), - signOut: jest.fn(), - }), -})); - -jest.mock('../lib/api', () => ({ - listTickers: jest.fn(async () => [ - { - id: '11111111-1111-4111-8111-111111111111', - user_id: '00000000-0000-4000-8000-000000000001', - market: 'US', - symbol: 'AAPL', - name: 'Apple', - created_at: '2026-08-13T00:00:00.000Z', - }, - { - id: '22222222-2222-4222-8222-222222222222', - user_id: '00000000-0000-4000-8000-000000000001', - market: 'KR', - symbol: '005930', - name: '삼성전자', - created_at: '2026-08-13T00:00:00.000Z', - }, - ]), - createTicker: jest.fn(), - deleteTicker: jest.fn(), -})); - -describe('WatchlistScreen', () => { - it('shows tickers from api', async () => { - render(); - await waitFor(() => { - expect(screen.getByText('관심종목')).toBeOnTheScreen(); - expect(screen.getByText(/AAPL/)).toBeOnTheScreen(); - expect(screen.getByText(/005930/)).toBeOnTheScreen(); - }); - }); -}); diff --git a/apps/web/e2e/home.spec.ts b/apps/web/e2e/home.spec.ts index 4f03e73..f1fd119 100644 --- a/apps/web/e2e/home.spec.ts +++ b/apps/web/e2e/home.spec.ts @@ -1,6 +1,6 @@ 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.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..3962844 --- /dev/null +++ b/apps/web/e2e/login.spec.ts @@ -0,0 +1,8 @@ +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(); +}); diff --git a/apps/web/src/components/home-view.test.tsx b/apps/web/src/components/home-view.test.tsx index bb2d359..6e7da39 100644 --- a/apps/web/src/components/home-view.test.tsx +++ b/apps/web/src/components/home-view.test.tsx @@ -7,14 +7,7 @@ vi.mock('@/app/login/actions', () => ({ })); describe('HomeView', () => { - it('renders archive scaffold copy when logged out', () => { - render(); - expect(screen.getByRole('heading', { name: '웹 아카이브' })).toBeInTheDocument(); - expect(screen.getByText(/Ticker Journal/i)).toBeInTheDocument(); - expect(screen.getByRole('link', { name: '로그인' })).toBeInTheDocument(); - }); - - it('renders tickers when logged in', () => { + it('로그인하면 관심종목을 보여준다', () => { render( { + cleanup(); +}); diff --git a/biome.json b/biome.json index fbd0034..9463f1c 100644 --- a/biome.json +++ b/biome.json @@ -22,7 +22,7 @@ "!**/public", "!**/public", "!**/.turbo", - "!**/.turbo" + "!**/next-env.d.ts" ] }, "formatter": { diff --git a/docs/testing.md b/docs/testing.md index a3480bc..0496148 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,24 +1,39 @@ -# 테스트 전략 +# 테스트 기준 -| 영역 | 도구 | 이유 | -|------|------|------| -| `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를 검토 (스토어 전 스모크). +## 레이어 + +| 레이어 | 대상 | 도구 | 하지 않는 것 | +|--------|------|------|----------------| +| 단위 | Zod, `buildChartHtml`, `resolveAuthCallbackPath` | Vitest / Jest | supabase/OTP를 목킹하고 `toHaveBeenCalled` | +| 컴포넌트 | 웹 순수 뷰만 (`HomeView` props → 텍스트) | RTL | 라우터/Auth를 목킹한 화면 테스트 | +| E2E | 웹 사용자 플로우: 홈, 로그인 페이지 | Playwright | 매직링크 메일·실세션 (인박스 없음). 모바일은 Maestro 전까지 E2E 없음 | + +웹 페이지 플로우 = Playwright. 웹 RTL은 **props → 텍스트**인 순수 뷰만 (예: 로그인된 `HomeView` 종목). 라우팅·폼 제출·Auth는 E2E. + +## 지금 허용된 테스트 + +- `packages/shared` — 스키마 정규화/거부 +- `apps/mobile/lib/chart.ts` — US 위젯 / KR fallback HTML +- `apps/web` `HomeView` — 로그인된 종목 표시 (E2E에 세션 없음) +- `resolveAuthCallbackPath` — 콜백 성공/실패 경로 +- Playwright — `/`, `/login`이 뜨는지 + +모바일 화면(관심종목·상세)은 라우터 없이 마운트되지 않는다. 구현 mock으로 목록을 그리는 테스트는 하지 않고, Maestro E2E에서 다룬다. ## 명령 ```bash -pnpm test # 전 패키지 unit/component +pnpm test # 단위 + 컴포넌트 pnpm test:e2e # Playwright (web) -pnpm --filter @ticker-journal/mobile test +pnpm ci # Biome + typecheck + unit ``` + +CI: `.github/workflows/ci.yml` — `check` · `typecheck` · `test` + Playwright E2E. diff --git a/package.json b/package.json index bc39263..a315f42 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "typecheck": "turbo run typecheck", "test": "turbo run test", "test:e2e": "pnpm --filter @ticker-journal/web test:e2e", + "ci": "pnpm check && pnpm typecheck && pnpm test", "format": "biome format --write .", "format:check": "biome format .", "lint:biome": "biome lint .", 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']); }); }); From f60953f3dabe2f56e083f8bc78f6f867be3feed7 Mon Sep 17 00:00:00 2001 From: scs0209 Date: Sat, 15 Aug 2026 19:06:39 +0900 Subject: [PATCH 07/34] =?UTF-8?q?docs:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=EA=B3=BC=20CI=EB=A5=BC=20=EB=AC=B8=EC=84=9C?= =?UTF-8?q?=EC=97=90=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 모바일 화면은 Maestro로 미루고 단위/E2E 경계를 맞춤 - 이력서 품질 지표에 GitHub Actions를 추가 Co-authored-by: Cursor --- docs/architecture.md | 7 +++++-- docs/portfolio.md | 22 ++++++++++++++-------- docs/resume-bullets.md | 10 +++++----- 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 9254004..dc16118 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -294,12 +294,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 스토어 전 스모크 --- @@ -360,6 +361,7 @@ Phase 0 추가 권장: - [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 +370,4 @@ Phase 0 추가 권장: | 날짜 | 내용 | |------|------| | 2026-08-13 | Phase 0 전 기준선 문서 초안 작성 | +| 2026-08-15 | Auth/CRUD 테스트 보강, GitHub Actions CI 추가 | diff --git a/docs/portfolio.md b/docs/portfolio.md index 75cdd87..8dc4c3f 100644 --- a/docs/portfolio.md +++ b/docs/portfolio.md @@ -115,18 +115,18 @@ gantt ```mermaid flowchart TB E2E[Playwright · 웹 E2E] - COMP[RTL · 웹 컴포넌트
RNTL · 모바일 화면] - UNIT[Vitest · shared Zod] + COMP[RTL · 웹 순수 뷰] + UNIT[Vitest · Zod · 차트 HTML · 콜백 경로] E2E --> COMP --> UNIT ``` | 영역 | 도구 | 역할 | |------|------|------| -| shared | Vitest | 스키마·도메인 규칙 (**7**) | -| web | Vitest + RTL | UI 계약 (**2**) | -| web | Playwright | 브라우저 플로우 (**1**) | -| mobile | jest-expo + RNTL | 화면·차트 HTML (**3**) | +| shared | Vitest | 스키마·도메인 규칙 | +| web | Vitest + RTL | 로그인된 HomeView 종목 표시 | +| web | Playwright | `/`, `/login` | +| mobile | jest-expo | `buildChartHtml` (화면은 Maestro 예정) | --- @@ -178,7 +178,12 @@ flowchart TB - `/login` 매직링크, `/auth/callback` 코드 교환 - 홈에서 동일 계정 관심종목 조회 (검색·상세는 Phase 1) -### 1.7 이후 로드맵 +### 1.7 테스트 · CI (2026-08-15) + +- 라우터 mock 없이 못 도는 모바일 화면 테스트는 삭제. 화면은 Maestro E2E +- GitHub Actions: `pnpm check` · `typecheck` · `test` + Playwright E2E + +### 1.8 이후 로드맵 - [x] Phase 0: Supabase Auth + 모바일 CRUD + 웹 세션/목록 (`feat/phase-0-auth-crud`) - [ ] Phase 1: 웹 entries 검색·종목 상세 @@ -228,7 +233,7 @@ Ticker Journal은 주식 리서치 스크랩과 매매 이유를 종목 타임 - Expo Router · 매직링크 Auth · 관심종목/엔트리 CRUD · TradingView WebView - Next.js `@supabase/ssr` 로그인·콜백·관심종목 조회 - Postgres 스키마 + RLS, Zod 공유 스키마로 입력 검증 -- Vitest / RTL / Playwright / jest-expo 테스트 계층 (**13+** 케이스) +- Vitest / RTL / Playwright / jest-expo 테스트 계층 + GitHub Actions CI - (예정) entries 웹 검색, 스토어 2곳 배포 ### 성과 / 임팩트 @@ -250,3 +255,4 @@ Ticker Journal은 주식 리서치 스크랩과 매매 이유를 종목 타임 | 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 | diff --git a/docs/resume-bullets.md b/docs/resume-bullets.md index f775f34..107d4db 100644 --- a/docs/resume-bullets.md +++ b/docs/resume-bullets.md @@ -9,7 +9,7 @@ ## 지금 당장 쓸 수 있는 수치 (Phase 0 기준) -- 웹·모바일·공유 패키지 **3앱 모노레포**로 도메인 스키마를 한곳(`packages/shared`)에 두고, 단위·컴포넌트·E2E **4계층** 테스트 **13+ 케이스** 통과 (Vitest 7 + 웹 RTL 2 + Playwright 1 + jest-expo 3) +- 웹·모바일·공유 패키지 **3앱 모노레포**로 도메인 스키마를 한곳(`packages/shared`)에 두고, 단위·컴포넌트·E2E **4계층** 테스트 + **GitHub Actions CI** (`check` / `typecheck` / `test` / Playwright) - 관심종목 → 종목 상세(WebView) → 웹 아카이브까지 **핵심 루프 3화면**으로 MVP 고정, 브로커 SaaS 범위는 제외 - Supabase **매직링크 Auth + RLS**로 앱·웹 **동일 계정** 경로 확보: 모바일 CRUD(관심종목·memo/link/trade) + 웹 관심종목 조회 - 차트는 TradingView WebView(US) / KR fallback으로 분리해 네이티브 차트 공수를 제외하고 Phase 0 일정 유지 @@ -28,7 +28,7 @@ | 본인 실사용 | 관심종목 **≥10**, 주간 entry **≥20** | “본인 워크플로로 종목 N개·주간 기록 M건 운영” | 마이그레이션 적용 후 채움 | | 입력 속도 | 노션 대비 기록 시간 **X%↓** 또는 **N초** | “모바일 입력으로 스크랩·매매 이유 기록 평균 Ns” | 미측정 | | 검색 회수 | 웹에서 과거 메모 찾기 **성공률** / 시간 | “웹 검색으로 과거 리서치 회수 시간 Y분→Z분” | Phase 1 | -| 품질 | 테스트 **N건**, CI 통과, 크래시 **0** | “단위·E2E N건·스토어 심사용 경로 충족” | **13+** 로컬 green | +| 품질 | 테스트 **N건**, CI 통과, 크래시 **0** | “단위·E2E N건·스토어 심사용 경로 충족” | GitHub Actions CI + 로컬 green | | 범위 통제 | 제외한 기능 **K개**, 출시까지 **D주** | “브로커 연동 등 K개 제외, D주 내 스토어 제출” | 브로커·AI 브리핑 제외 | --- @@ -38,7 +38,7 @@ | 약함 (구현 나열) | 강함 (수치·결과) | |------------------|------------------| | Expo Router로 네비게이션 구성 | 핵심 플로우 **3화면**으로 MVP 고정 후 스토어 **2곳** 배포 목표 | -| Vitest, Playwright 도입 | 테스트 **4계층 / 13+건**으로 회귀 방지, React·TS 버전 정렬 후 전 패키지 green | +| Vitest, Playwright 도입 | 테스트 **4계층** + GitHub Actions CI로 회귀 방지, React·TS 버전 정렬 후 전 패키지 green | | Supabase CRUD 구현 | 앱·웹 **동일 계정** + RLS로 종목 목록 동기화 경로 확보 | | WebView로 차트 넣음 | 차트는 WebView로 분리해 네이티브 범위 **축소**, Phase 0에서 US embed / KR fallback | @@ -50,7 +50,7 @@ - `[x]` Expo·Next **동일 Supabase 프로젝트**에 매직링크 Auth를 붙이고, Postgres `tickers`/`entries` + RLS로 모바일 CRUD·웹 관심종목 조회까지 연결 - `[x]` 관심종목·엔트리(memo/link/trade) 입력 경로를 Zod 공유 스키마로 검증하고, US TradingView WebView / KR fallback으로 차트 표면을 분리 -- `[x]` 모노레포 테스트 **4계층 / 13+건** + Biome + TypeScript 7으로 품질 게이트 유지 +- `[x]` 모노레포 테스트 **4계층** + GitHub Actions(`check`/`typecheck`/`test`/E2E) + Biome + TypeScript 7으로 품질 게이트 유지 - `[ ]` 본인 계정으로 관심종목 **__**개, entry **__**건 생성 후 웹에서 동일 목록 end-to-end 검증 (SQL 적용·실로그인 후 채움) ### Phase 1 (웹 검색·상세) @@ -68,7 +68,7 @@ 1. 문제: 노션+시트에 리서치가 흩어짐 2. 결과: 종목 타임라인 앱+웹, (목표) 스토어 **2곳**, 실사용 종목 **N**·주간 **M** -3. 어떻게: MVP **3화면**, 테스트 **4계층/13+**, Zod 1곳, Supabase Auth+RLS +3. 어떻게: MVP **3화면**, 테스트 **4계층 + CI**, Zod 1곳, Supabase Auth+RLS 4. 링크: 레포 / 웹 / (스토어) --- From 1f82854df3096f1f35a86ce8c53296881efeefd1 Mon Sep 17 00:00:00 2001 From: scs0209 Date: Sat, 15 Aug 2026 19:16:02 +0900 Subject: [PATCH 08/34] =?UTF-8?q?fix:=20CI=20typecheck=EC=99=80=20E2E=20sh?= =?UTF-8?q?ared=20=EB=B9=8C=EB=93=9C=20=EC=8B=A4=ED=8C=A8=EB=A5=BC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LayoutProps 대신 ReactNode를 써서 생성 타입에 의존하지 않음 - Playwright·Actions에서 shared 패키지를 먼저 빌드 Co-authored-by: Cursor --- .github/workflows/ci.yml | 1 + apps/web/playwright.config.ts | 2 +- apps/web/src/app/layout.tsx | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5ef7c3..8437cac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,5 +34,6 @@ jobs: 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/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/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} From 4379ad8b9ad77ed6d7f4dbdb5917a2265aa7a594 Mon Sep 17 00:00:00 2001 From: scs0209 Date: Sat, 15 Aug 2026 19:16:12 +0900 Subject: [PATCH 09/34] =?UTF-8?q?chore:=20=EC=BB=A4=EB=B0=8B=20=EC=A0=84?= =?UTF-8?q?=20pnpm=20run=20ci=EB=A5=BC=20Husky=EB=A1=9C=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pnpm ci는 내장 명령이라 스크립트는 pnpm run ci로 호출 - E2E는 브라우저 설치 때문에 GitHub Actions에만 유지 Co-authored-by: Cursor --- .husky/pre-commit | 1 + README.md | 2 +- docs/portfolio.md | 1 + docs/testing.md | 4 ++-- package.json | 4 +++- pnpm-lock.yaml | 10 ++++++++++ 6 files changed, 18 insertions(+), 4 deletions(-) create mode 100755 .husky/pre-commit 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/README.md b/README.md index fb9d054..eefc8f5 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ pnpm dev:web # http://localhost:3000 pnpm dev:mobile # Expo pnpm test # Vitest + jest-expo pnpm test:e2e # Playwright (web) -pnpm ci # Biome + typecheck + unit +pnpm run ci # Biome + typecheck + unit pnpm check # Biome lint + format check pnpm check:fix # Biome auto-fix pnpm typecheck diff --git a/docs/portfolio.md b/docs/portfolio.md index 8dc4c3f..720f6e1 100644 --- a/docs/portfolio.md +++ b/docs/portfolio.md @@ -182,6 +182,7 @@ flowchart TB - 라우터 mock 없이 못 도는 모바일 화면 테스트는 삭제. 화면은 Maestro E2E - GitHub Actions: `pnpm check` · `typecheck` · `test` + Playwright E2E +- Husky pre-commit: `pnpm run ci` (E2E는 CI만) ### 1.8 이후 로드맵 diff --git a/docs/testing.md b/docs/testing.md index 0496148..5bbd409 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -33,7 +33,7 @@ ```bash pnpm test # 단위 + 컴포넌트 pnpm test:e2e # Playwright (web) -pnpm ci # Biome + typecheck + unit +pnpm run ci # Biome + typecheck + unit (pre-commit 훅과 동일) ``` -CI: `.github/workflows/ci.yml` — `check` · `typecheck` · `test` + Playwright E2E. +로컬: Husky `pre-commit`이 `pnpm run ci`를 실행한다. Playwright E2E는 브라우저 설치 때문에 커밋 훅에 넣지 않고 GitHub Actions만 돌린다. diff --git a/package.json b/package.json index a315f42..d959dfd 100644 --- a/package.json +++ b/package.json @@ -17,10 +17,12 @@ "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/pnpm-lock.yaml b/pnpm-lock.yaml index 510cd94..5b60e93 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 @@ -3318,6 +3321,11 @@ 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'} @@ -8479,6 +8487,8 @@ snapshots: human-signals@2.1.0: {} + husky@9.1.7: {} + iceberg-js@0.8.1: {} iconv-lite@0.6.3: From afc67735f25f680e66cb69ce062e09a08bb72536 Mon Sep 17 00:00:00 2001 From: scs0209 Date: Wed, 19 Aug 2026 21:53:23 +0900 Subject: [PATCH 10/34] =?UTF-8?q?fix:=20=EB=A7=A4=EC=A7=81=EB=A7=81?= =?UTF-8?q?=ED=81=AC=20=EC=BD=9C=EB=B0=B1=C2=B7=EC=84=B8=EC=85=98=20?= =?UTF-8?q?=EA=B5=90=ED=99=98=20=EB=B2=84=EA=B7=B8=EB=A5=BC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - middleware가 /auth/callback을 바이패스해 code_verifier 쿠키를 보존 - callback route에서 직접 createServerClient로 교환 후 쿠키를 리다이렉트에 복사 - 모바일 Linking으로 딥링크 수신 → exchangeCodeForSession / setSession - auth-callback.ts 순수 파서 분리, callback.tsx 화면 추가 - 로그인 페이지에 콜백 실패 안내 메시지 표시 - Linking.createURL로 Expo Go / dev build 겸용 redirect Co-authored-by: Cursor --- apps/mobile/app/_layout.tsx | 9 ++++- apps/mobile/app/auth/callback.tsx | 28 +++++++++++++ apps/mobile/app/login.tsx | 2 +- apps/mobile/lib/auth-callback.ts | 26 ++++++++++++ apps/mobile/lib/auth.tsx | 46 ++++++++++++++++++--- apps/web/e2e/login.spec.ts | 5 +++ apps/web/src/app/auth/callback/route.ts | 54 +++++++++++++++++++++---- apps/web/src/app/login/page.tsx | 16 +++++++- apps/web/src/lib/supabase/middleware.ts | 9 ++++- apps/web/src/lib/supabase/server.ts | 2 +- 10 files changed, 177 insertions(+), 20 deletions(-) create mode 100644 apps/mobile/app/auth/callback.tsx create mode 100644 apps/mobile/lib/auth-callback.ts diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index 35d387e..a584aa3 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -12,8 +12,12 @@ const LogoutButton = () => { return ( { - await signOut(); - router.replace('/login'); + try { + await signOut(); + router.replace('/login'); + } catch { + // 세션이 남아 있으면 로그인 화면으로 보내지 않는다. + } }} accessibilityRole='button' accessibilityLabel='로그아웃' @@ -30,6 +34,7 @@ export default function RootLayout() { + ; + } + + 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/login.tsx b/apps/mobile/app/login.tsx index a2a8d5b..7438e3c 100644 --- a/apps/mobile/app/login.tsx +++ b/apps/mobile/app/login.tsx @@ -43,7 +43,7 @@ export default function LoginScreen() { 로그인 {configured - ? '이메일 매직링크로 로그인합니다. Supabase Auth redirect에 tickerjournal://auth/callback 을 등록하세요.' + ? '이메일 매직링크로 로그인합니다. Supabase Auth redirect에 tickerjournal://auth/callback 과 Expo Go용 exp:// 콜백을 등록하세요.' : 'EXPO_PUBLIC_SUPABASE_URL / EXPO_PUBLIC_SUPABASE_KEY 가 비어 있습니다. apps/mobile/.env 를 채운 뒤 Expo를 재시작하세요.'} 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 index 29ab10a..3050e23 100644 --- a/apps/mobile/lib/auth.tsx +++ b/apps/mobile/lib/auth.tsx @@ -1,6 +1,8 @@ import type { Session, User } from '@supabase/supabase-js'; +import * as Linking from 'expo-linking'; import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from 'react'; +import { parseAuthCallbackUrl } from './auth-callback'; import { isSupabaseConfigured, supabase } from './supabase'; type AuthContextValue = { @@ -14,6 +16,24 @@ type AuthContextValue = { 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); @@ -25,20 +45,36 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { } let mounted = true; - supabase.auth.getSession().then(({ data }) => { + + 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); - setLoading(false); - }); + await applyUrl(await Linking.getInitialURL()); + if (mounted) setLoading(false); + }; + + void init(); const { data: subscription } = supabase.auth.onAuthStateChange((_event, next) => { setSession(next); - setLoading(false); + }); + const urlSub = Linking.addEventListener('url', ({ url }) => { + void applyUrl(url); }); return () => { mounted = false; subscription.subscription.unsubscribe(); + urlSub.remove(); }; }, []); @@ -55,7 +91,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { const { error } = await supabase.auth.signInWithOtp({ email: email.trim(), options: { - emailRedirectTo: 'tickerjournal://auth/callback', + emailRedirectTo: Linking.createURL('auth/callback'), }, }); if (error) throw error; diff --git a/apps/web/e2e/login.spec.ts b/apps/web/e2e/login.spec.ts index 3962844..5522765 100644 --- a/apps/web/e2e/login.spec.ts +++ b/apps/web/e2e/login.spec.ts @@ -6,3 +6,8 @@ test('로그인 페이지가 열린다', async ({ page }) => { await expect(page.getByLabel('이메일')).toBeVisible(); await expect(page.getByRole('button', { name: '매직링크 보내기' })).toBeVisible(); }); + +test('콜백 실패 시 안내를 보여준다', async ({ page }) => { + await page.goto('/login?error=auth'); + await expect(page.getByText('로그인에 실패했습니다')).toBeVisible(); +}); diff --git a/apps/web/src/app/auth/callback/route.ts b/apps/web/src/app/auth/callback/route.ts index ec7fea6..26f8edb 100644 --- a/apps/web/src/app/auth/callback/route.ts +++ b/apps/web/src/app/auth/callback/route.ts @@ -1,20 +1,58 @@ -import { NextResponse } from 'next/server'; +import { createServerClient } from '@supabase/ssr'; +import { type NextRequest, NextResponse } from 'next/server'; -import { createClient } from '@/lib/supabase/server'; +import { getSupabaseEnv } from '@/lib/supabase/env'; import { resolveAuthCallbackPath } from './redirect'; -export const GET = async (request: Request) => { - const { searchParams, origin } = new URL(request.url); +const applyAuthCacheHeaders = (response: NextResponse) => { + 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; +}; + +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 supabase = await createClient(); - const { error } = await supabase.auth.exchangeCodeForSession(code); - exchangeOk = !error; + 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); + } + } } - return NextResponse.redirect(new URL(resolveAuthCallbackPath({ code, exchangeOk, next }), origin)); + 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/login/page.tsx b/apps/web/src/app/login/page.tsx index 409dc0e..15fbc2c 100644 --- a/apps/web/src/app/login/page.tsx +++ b/apps/web/src/app/login/page.tsx @@ -1,16 +1,28 @@ 'use client'; import Link from 'next/link'; -import { useState } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { Suspense, useState } from 'react'; import { createClient } from '@/lib/supabase/client'; import { getSupabaseEnv } from '@/lib/supabase/env'; +const CALLBACK_ERROR = '로그인에 실패했습니다. 매직링크는 요청한 같은 브라우저에서 열어 주세요.'; + export default function LoginPage() { + return ( + + + + ); +} + +function LoginForm() { const { configured } = getSupabaseEnv(); + const searchParams = useSearchParams(); const [email, setEmail] = useState(''); const [message, setMessage] = useState(null); - const [error, setError] = useState(null); + const [error, setError] = useState(searchParams.get('error') === 'auth' ? CALLBACK_ERROR : null); const [pending, setPending] = useState(false); const handleSubmit = async (event: React.FormEvent) => { diff --git a/apps/web/src/lib/supabase/middleware.ts b/apps/web/src/lib/supabase/middleware.ts index 3c352fe..5fb1368 100644 --- a/apps/web/src/lib/supabase/middleware.ts +++ b/apps/web/src/lib/supabase/middleware.ts @@ -4,6 +4,10 @@ import { type NextRequest, NextResponse } from 'next/server'; 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(); @@ -16,7 +20,7 @@ export const updateSession = async (request: NextRequest) => { getAll() { return request.cookies.getAll(); }, - setAll(cookiesToSet) { + setAll(cookiesToSet, headers) { for (const { name, value } of cookiesToSet) { request.cookies.set(name, value); } @@ -24,6 +28,9 @@ export const updateSession = async (request: NextRequest) => { for (const { name, value, options } of cookiesToSet) { supabaseResponse.cookies.set(name, value, options); } + for (const [key, value] of Object.entries(headers)) { + supabaseResponse.headers.set(key, value); + } }, }, }); diff --git a/apps/web/src/lib/supabase/server.ts b/apps/web/src/lib/supabase/server.ts index 129876f..99de9aa 100644 --- a/apps/web/src/lib/supabase/server.ts +++ b/apps/web/src/lib/supabase/server.ts @@ -12,7 +12,7 @@ export const createClient = async () => { getAll() { return cookieStore.getAll(); }, - setAll(cookiesToSet) { + setAll(cookiesToSet, _headers) { try { for (const { name, value, options } of cookiesToSet) { cookieStore.set(name, value, options); From e74bb7fa789519754a3e7fa02970b181ae8a3bb3 Mon Sep 17 00:00:00 2001 From: scs0209 Date: Wed, 19 Aug 2026 21:53:45 +0900 Subject: [PATCH 11/34] =?UTF-8?q?fix:=20=EC=B0=A8=ED=8A=B8=20XSS=C2=B7?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=20=EC=97=90=EB=9F=AC=C2=B7?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=8B=A4=ED=8C=A8=20=ED=91=9C=EC=8B=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 차트 심볼을 JSON.stringify로 삽입해 JS 인젝션 방지 - 로그아웃 실패 시 로그인 화면으로 보내지 않도록 try/catch - 웹 관심종목 조회 실패를 빈 목록과 구분하는 loadError 상태 - trade 날짜를 ko-KR 로케일로 표시 Co-authored-by: Cursor --- apps/mobile/__tests__/chart.test.ts | 8 +++++++- apps/mobile/app/ticker/[id].tsx | 8 +++++++- apps/mobile/lib/chart.ts | 4 ++-- apps/web/src/app/page.tsx | 7 +++++-- apps/web/src/components/home-view.test.tsx | 6 ++++++ apps/web/src/components/home-view.tsx | 9 +++++++-- 6 files changed, 34 insertions(+), 8 deletions(-) diff --git a/apps/mobile/__tests__/chart.test.ts b/apps/mobile/__tests__/chart.test.ts index 5f11bde..ad9a212 100644 --- a/apps/mobile/__tests__/chart.test.ts +++ b/apps/mobile/__tests__/chart.test.ts @@ -4,7 +4,13 @@ describe('buildChartHtml', () => { it('US 심볼에 TradingView 위젯을 넣는다', () => { const html = buildChartHtml('US', 'aapl'); expect(html).toContain('TradingView.widget'); - expect(html).toContain('AAPL'); + 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을 쓴다', () => { diff --git a/apps/mobile/app/ticker/[id].tsx b/apps/mobile/app/ticker/[id].tsx index 75ae929..684d800 100644 --- a/apps/mobile/app/ticker/[id].tsx +++ b/apps/mobile/app/ticker/[id].tsx @@ -272,10 +272,16 @@ export default function TickerDetailScreen() { ); } +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()} · ${entry.traded_at}${entry.reason ? `\n${entry.reason}` : ''}`; + return `${entry.side.toUpperCase()} · ${formatTradedAt(entry.traded_at)}${entry.reason ? `\n${entry.reason}` : ''}`; }; const styles = StyleSheet.create({ diff --git a/apps/mobile/lib/chart.ts b/apps/mobile/lib/chart.ts index 1864204..fd777ae 100644 --- a/apps/mobile/lib/chart.ts +++ b/apps/mobile/lib/chart.ts @@ -23,7 +23,7 @@ export const buildChartHtml = (market: Market, symbol: string): string => { `; } - const tvSymbol = escapeHtml(symbol.toUpperCase()); + const tvSymbol = JSON.stringify(symbol.toUpperCase()).replaceAll('<', '\\u003c'); return ` @@ -38,7 +38,7 @@ export const buildChartHtml = (market: Market, symbol: string): string => {