diff --git a/.env.example b/.env.example index a908e05..38c9750 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,10 @@ EXPO_PUBLIC_SUPABASE_KEY= NEXT_PUBLIC_SUPABASE_URL= NEXT_PUBLIC_SUPABASE_ANON_KEY= +# Phase 2: 스토어·프라이버시 링크 (배포 URL) +EXPO_PUBLIC_WEB_URL= +NEXT_PUBLIC_SITE_URL= + # Auth redirects (Supabase Dashboard → Authentication → URL Configuration) # - mobile (dev build / 스토어): tickerjournal://auth/callback # - mobile (Expo Go): Linking.createURL('auth/callback') 결과(exp://…)도 등록 diff --git a/README.md b/README.md index ee94ec5..7d3557a 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ pnpm typecheck - Phase 0: Auth + CRUD (앱) — 브랜치 `feat/phase-0-auth-crud` - Phase 1: 웹 검색/상세 -- Phase 2: App Store + Play Store +- Phase 2: App Store + Play Store — [`docs/phase-2-store.md`](docs/phase-2-store.md) - v1.1 공유 시트 / v2 AI 주간 브리핑 ### Phase 0 로컬 설정 diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 5fe4c84..2dfa5dc 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -7,9 +7,17 @@ "orientation": "portrait", "icon": "./assets/icon.png", "userInterfaceStyle": "light", + "splash": { + "image": "./assets/splash-icon.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, "ios": { "supportsTablet": true, - "bundleIdentifier": "com.tickerjournal.app" + "bundleIdentifier": "com.tickerjournal.app", + "infoPlist": { + "ITSAppUsesNonExemptEncryption": false + } }, "android": { "package": "com.tickerjournal.app", diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index 29fcd27..60f498a 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -1,30 +1,19 @@ -import { Stack, useRouter } from 'expo-router'; +import { Link, Stack } from 'expo-router'; import { StatusBar } from 'expo-status-bar'; -import { Alert, Pressable, Text } from 'react-native'; +import { Pressable, Text } from 'react-native'; import { AuthProvider, useAuth } from '../lib/auth'; -const LogoutButton = () => { - const { session, signOut } = useAuth(); - const router = useRouter(); +const HeaderActions = () => { + const { session } = useAuth(); if (!session) return null; return ( - { - try { - await signOut(); - router.replace('/login'); - } catch (err) { - Alert.alert('로그아웃 실패', err instanceof Error ? err.message : '다시 시도해 주세요.'); - } - }} - accessibilityRole='button' - accessibilityLabel='로그아웃' - style={{ paddingHorizontal: 8 }} - > - 로그아웃 - + + + 설정 + + ); }; @@ -40,9 +29,10 @@ export default function RootLayout() { name='index' options={{ title: '관심종목', - headerRight: () => , + headerRight: () => , }} /> + diff --git a/apps/mobile/app/settings.tsx b/apps/mobile/app/settings.tsx new file mode 100644 index 0000000..5d796b5 --- /dev/null +++ b/apps/mobile/app/settings.tsx @@ -0,0 +1,140 @@ +import { Redirect, useRouter } from 'expo-router'; +import * as WebBrowser from 'expo-web-browser'; +import { Alert, Linking, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; + +import { deleteOwnAccount } from '../lib/account'; +import { useAuth } from '../lib/auth'; + +const privacyPolicyUrl = () => { + const base = process.env.EXPO_PUBLIC_WEB_URL?.replace(/\/$/, ''); + return base ? `${base}/privacy` : null; +}; + +export default function SettingsScreen() { + const { session, loading, signOut } = useAuth(); + const router = useRouter(); + + if (loading) { + return ( + + 불러오는 중… + + ); + } + + if (!session) { + return ; + } + + const email = session.user.email ?? '(이메일 없음)'; + const policyUrl = privacyPolicyUrl(); + + const openPrivacy = async () => { + if (!policyUrl) { + Alert.alert('설정 필요', 'EXPO_PUBLIC_WEB_URL 에 배포된 웹 URL을 넣어 주세요.'); + return; + } + await WebBrowser.openBrowserAsync(policyUrl); + }; + + const handleDelete = () => { + Alert.alert('계정 삭제', '관심종목·타임라인 기록이 모두 삭제되며 되돌릴 수 없습니다. 계속할까요?', [ + { text: '취소', style: 'cancel' }, + { + text: '삭제', + style: 'destructive', + onPress: () => { + void (async () => { + try { + await deleteOwnAccount(); + router.replace('/login'); + } catch (err) { + Alert.alert('삭제 실패', err instanceof Error ? err.message : '다시 시도해 주세요.'); + } + })(); + }, + }, + ]); + }; + + return ( + + 로그인 + {email} + + + 세션 + { + try { + await signOut(); + router.replace('/login'); + } catch (err) { + Alert.alert('로그아웃 실패', err instanceof Error ? err.message : '다시 시도해 주세요.'); + } + }} + accessibilityRole='button' + style={styles.button} + > + 로그아웃 + + + + + 법적 고지 + void openPrivacy()} accessibilityRole='button' style={styles.linkButton}> + 개인정보 처리방침 + + {policyUrl ? ( + void Linking.openURL(policyUrl)} accessibilityRole='link' style={styles.linkButton}> + {policyUrl} + + ) : ( + 스토어 제출 전 EXPO_PUBLIC_WEB_URL 을 설정하세요. + )} + + + + 계정 삭제 + 관심종목·타임라인 기록이 모두 삭제되며 되돌릴 수 없습니다. + + 계정 삭제 + + + + ); +} + +const styles = StyleSheet.create({ + container: { padding: 20, gap: 12, backgroundColor: '#fff', flexGrow: 1 }, + center: { flex: 1, alignItems: 'center', justifyContent: 'center' }, + label: { fontSize: 12, color: '#666', textTransform: 'uppercase', letterSpacing: 1 }, + email: { fontSize: 16, fontWeight: '600', color: '#111' }, + section: { marginTop: 12, gap: 8, borderWidth: 1, borderColor: '#ddd', borderRadius: 8, padding: 14 }, + sectionTitle: { fontSize: 15, fontWeight: '600', color: '#111' }, + button: { + alignSelf: 'flex-start', + borderWidth: 1, + borderColor: '#ccc', + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 8, + }, + buttonText: { fontSize: 14, color: '#333' }, + linkButton: { alignSelf: 'flex-start' }, + linkText: { fontSize: 14, color: '#2563eb', textDecorationLine: 'underline' }, + linkTextMuted: { fontSize: 12, color: '#666' }, + muted: { fontSize: 13, color: '#666', lineHeight: 18 }, + dangerSection: { borderColor: '#fecaca', backgroundColor: '#fef2f2' }, + dangerTitle: { fontSize: 15, fontWeight: '600', color: '#991b1b' }, + dangerBody: { fontSize: 13, color: '#991b1b', lineHeight: 18 }, + dangerButton: { + alignSelf: 'flex-start', + backgroundColor: '#b91c1c', + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 8, + marginTop: 4, + }, + dangerButtonText: { fontSize: 14, fontWeight: '600', color: '#fff' }, +}); diff --git a/apps/mobile/eas.json b/apps/mobile/eas.json new file mode 100644 index 0000000..d187c09 --- /dev/null +++ b/apps/mobile/eas.json @@ -0,0 +1,24 @@ +{ + "cli": { + "version": ">= 16.0.0", + "appVersionSource": "remote" + }, + "build": { + "development": { + "developmentClient": true, + "distribution": "internal" + }, + "preview": { + "distribution": "internal", + "android": { + "buildType": "apk" + } + }, + "production": { + "autoIncrement": true + } + }, + "submit": { + "production": {} + } +} diff --git a/apps/mobile/lib/account.ts b/apps/mobile/lib/account.ts new file mode 100644 index 0000000..59dff5e --- /dev/null +++ b/apps/mobile/lib/account.ts @@ -0,0 +1,9 @@ +import { supabase } from './supabase'; + +export const deleteOwnAccount = async (): Promise => { + const { error } = await supabase.rpc('delete_own_account'); + if (error) throw error; + + const { error: signOutError } = await supabase.auth.signOut(); + if (signOutError) throw signOutError; +}; diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 527ae22..b1a5dd9 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -40,7 +40,9 @@ "typecheck": "tsc --noEmit", "lint": "biome lint .", "test": "jest", - "test:coverage": "jest --coverage" + "test:coverage": "jest --coverage", + "eas:build:ios": "eas build --platform ios --profile production", + "eas:build:android": "eas build --platform android --profile production" }, "private": true } diff --git a/apps/web/e2e/settings.spec.ts b/apps/web/e2e/settings.spec.ts new file mode 100644 index 0000000..ece4b7e --- /dev/null +++ b/apps/web/e2e/settings.spec.ts @@ -0,0 +1,12 @@ +import { expect, test } from '@playwright/test'; + +test('개인정보 처리방침 페이지가 열린다', async ({ page }) => { + await page.goto('/privacy'); + await expect(page.getByRole('heading', { name: '개인정보 처리방침' })).toBeVisible(); + await expect(page.getByText(/계정 삭제/)).toBeVisible(); +}); + +test('설정은 비로그인 시 로그인으로 보낸다', async ({ page }) => { + await page.goto('/settings'); + await expect(page).toHaveURL(/\/login/); +}); diff --git a/apps/web/src/app/privacy/page.tsx b/apps/web/src/app/privacy/page.tsx new file mode 100644 index 0000000..b7e30f5 --- /dev/null +++ b/apps/web/src/app/privacy/page.tsx @@ -0,0 +1,65 @@ +import { APP_NAME } from '@ticker-journal/shared'; +import Link from 'next/link'; + +export default function PrivacyPage() { + return ( +
+
+
+

{APP_NAME}

+

개인정보 처리방침

+

최종 갱신: 2026-08-31

+
+ +
+

수집하는 정보

+

{APP_NAME}은(는) 서비스 제공을 위해 아래 정보를 수집·저장합니다.

+
    +
  • 계정: 이메일 주소 (Supabase Auth)
  • +
  • 사용자 콘텐츠: 관심종목, 메모·링크·매매 기록
  • +
  • 기술 정보: 로그인 세션 토큰 (기기 로컬 저장)
  • +
+
+ +
+

이용 목적

+
    +
  • 동일 계정으로 모바일 입력·웹 검색을 연결
  • +
  • 종목 타임라인 저장·조회·삭제
  • +
  • 인증 및 보안 (RLS로 본인 데이터만 접근)
  • +
+
+ +
+

보관·처리 위탁

+

+ 데이터는 Supabase(Postgres, Auth)에 저장됩니다. 차트는 TradingView embed(WebView)를 사용하며, 차트 제공자는 + 별도 정책이 적용될 수 있습니다. +

+
+ +
+

제3자 제공

+

사용자 데이터를 판매하거나 광고 목적으로 제공하지 않습니다.

+
+ +
+

계정 삭제

+

+ 앱·웹 설정에서 계정을 삭제할 수 있습니다. 삭제 시 tickers·entries 등 사용자 데이터는 함께 제거되며 복구할 수 + 없습니다. +

+
+ +
+

문의

+

개인정보 관련 문의: 레포 이슈 또는 프로젝트 maintainer 이메일로 연락해 주세요.

+
+ + + 홈으로 + +
+
+ ); +} diff --git a/apps/web/src/app/settings/actions.ts b/apps/web/src/app/settings/actions.ts new file mode 100644 index 0000000..df8bdec --- /dev/null +++ b/apps/web/src/app/settings/actions.ts @@ -0,0 +1,16 @@ +'use server'; + +import { redirect } from 'next/navigation'; + +import { createClient } from '@/lib/supabase/server'; + +export const deleteAccount = async () => { + const supabase = await createClient(); + const { error } = await supabase.rpc('delete_own_account'); + if (error) { + throw new Error(error.message); + } + + await supabase.auth.signOut(); + redirect('/'); +}; diff --git a/apps/web/src/app/settings/page.tsx b/apps/web/src/app/settings/page.tsx new file mode 100644 index 0000000..5aaa6cc --- /dev/null +++ b/apps/web/src/app/settings/page.tsx @@ -0,0 +1,17 @@ +import { redirect } from 'next/navigation'; + +import { SettingsView } from '@/components/settings-view'; +import { createClient } from '@/lib/supabase/server'; + +export default async function SettingsPage() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + if (!user?.email) { + redirect('/login?next=/settings'); + } + + return ; +} diff --git a/apps/web/src/components/home-view.tsx b/apps/web/src/components/home-view.tsx index 768f363..14b6174 100644 --- a/apps/web/src/components/home-view.tsx +++ b/apps/web/src/components/home-view.tsx @@ -21,14 +21,22 @@ export function HomeView({ email = null, tickers = [], configured = false, loadE

웹 아카이브

{email ? ( -
- -
+ 설정 + +
+ +
+ ) : ( ({ + signOut: vi.fn(), +})); + +vi.mock('@/app/settings/actions', () => ({ + deleteAccount: vi.fn(), +})); + +describe('SettingsView', () => { + it('계정 이메일과 삭제 경고를 보여준다', () => { + render(); + expect(screen.getByText('you@example.com')).toBeInTheDocument(); + expect(screen.getByText(/되돌릴 수 없습니다/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '계정 삭제' })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: '개인정보 처리방침' })).toHaveAttribute('href', '/privacy'); + }); +}); diff --git a/apps/web/src/components/settings-view.tsx b/apps/web/src/components/settings-view.tsx new file mode 100644 index 0000000..7476acf --- /dev/null +++ b/apps/web/src/components/settings-view.tsx @@ -0,0 +1,66 @@ +import Link from 'next/link'; + +import { signOut } from '@/app/login/actions'; +import { deleteAccount } from '@/app/settings/actions'; + +type SettingsViewProps = { + email: string; + privacyPolicyPath?: string; +}; + +export function SettingsView({ email, privacyPolicyPath = '/privacy' }: SettingsViewProps) { + return ( +
+
+
+
+

설정

+

계정

+
+ + 홈 + +
+ +

+ 로그인: {email} +

+ +
+

세션

+
+ +
+
+ +
+

법적 고지

+ + 개인정보 처리방침 + +
+ +
+

계정 삭제

+

관심종목·타임라인 기록이 모두 삭제되며 되돌릴 수 없습니다.

+
+ +
+
+
+
+ ); +} diff --git a/docs/phase-2-store.md b/docs/phase-2-store.md new file mode 100644 index 0000000..2619cfb --- /dev/null +++ b/docs/phase-2-store.md @@ -0,0 +1,101 @@ +# Phase 2 — App Store · Play Store + +> 목표: EAS Build/Submit으로 **iOS + Android 2곳** 라이브. +> 코드·문서 기준선: `feat/phase-2-store` + +--- + +## 완료 정의 + +| 항목 | 상태 | +|------|------| +| 프라이버시 정책 URL (`/privacy`) | ✅ 웹 페이지 | +| 로그아웃 (앱·웹) | ✅ Phase 0~1 | +| 계정 삭제 (앱·웹) | ✅ `delete_own_account` RPC | +| 빈 상태·에러 UI | ✅ Phase 0~1 | +| EAS `eas.json` 프로필 | ✅ development / preview / production | +| Apple Developer · Play Console 계정 | ⬜ 수동 | +| EAS 프로젝트 연결 (`eas init`) | ⬜ 수동 | +| 스토어 메타·스크린샷·데모 계정 | ⬜ 수동 | +| iOS + Android 라이브 URL | ⬜ 제출 후 | + +--- + +## 선행 체크리스트 (수동) + +### 계정·비용 + +- [ ] [Apple Developer Program](https://developer.apple.com/programs/) ($99/yr) +- [ ] [Google Play Console](https://play.google.com/console) ($25 일회) + +### Supabase + +- [ ] `supabase db push` 또는 Dashboard에서 `20260831100000_delete_own_account.sql` 적용 +- [ ] Auth redirect URL에 **프로덕션** 추가: + - `tickerjournal://auth/callback` + - `https:///auth/callback` +- [ ] 심사용 **데모 계정** (이메일/비번) — Play/App Store 리뷰 노트에 기재 + +### 웹 (Vercel) + +- [ ] 프로덕션 URL 확정 → `NEXT_PUBLIC_SITE_URL` / `EXPO_PUBLIC_WEB_URL` +- [ ] App Store Connect · Play Console **Privacy Policy URL** = `https:///privacy` + +### EAS + +```bash +cd apps/mobile +pnpm dlx eas-cli login +pnpm dlx eas-cli init # projectId → app.json extra.eas +pnpm dlx eas-cli build --platform ios --profile production +pnpm dlx eas-cli build --platform android --profile production +pnpm dlx eas-cli submit --platform ios --latest +pnpm dlx eas-cli submit --platform android --latest +``` + +환경 변수(EAS Secrets 또는 `eas.json` env): + +| 이름 | 용도 | +|------|------| +| `EXPO_PUBLIC_SUPABASE_URL` | 앱 빌드 | +| `EXPO_PUBLIC_SUPABASE_KEY` | 앱 빌드 (anon) | +| `EXPO_PUBLIC_WEB_URL` | 설정 화면 프라이버시 링크 | + +--- + +## 심사 대응 (4종 경로) + +1. **빈 목록** — 관심종목 0개 안내 (`apps/mobile/app/index.tsx`, 웹 홈) +2. **에러** — API 실패 메시지 (목록·상세) +3. **로그아웃** — 웹 홈·설정, 앱 설정 +4. **계정 삭제** — 웹 `/settings`, 앱 설정 → 확인 다이얼로그 → RPC + +데모 계정으로 **2분 플로우** 녹화/기재: + +로그인 → 관심종목 → 종목 상세(차트+엔트리) → (웹) 검색 회수 + +--- + +## 스토어 메타 초안 + +| 필드 | 초안 | +|------|------| +| 앱 이름 | Ticker Journal | +| 부제 | 종목 타임라인 리서치 저널 | +| 카테고리 | Finance / Productivity | +| 연령 | 4+ (금융 데이터 표시, 투자 조언 없음) | +| Privacy Policy | `https:///privacy` | + +--- + +## 일정 버퍼 + +기능 동결 후 심사·수정 **2–4주** 가정 (`docs/design.md` Distribution Plan). + +--- + +## 갱신 로그 + +| 날짜 | 내용 | +|------|------| +| 2026-08-31 | Phase 2 착수: RPC·설정·프라이버시·EAS 스캐폴드 | diff --git a/docs/portfolio.md b/docs/portfolio.md index 08ec47d..fdd45b0 100644 --- a/docs/portfolio.md +++ b/docs/portfolio.md @@ -10,7 +10,7 @@ | 스택 | 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-1-web-search-detail` | +| 현재 브랜치 | `feat/phase-2-store` | --- @@ -195,6 +195,22 @@ flowchart TB - 단위: `search-query`, `entry-format`, `SearchView` RTL; E2E: `/search` 비로그인 → `/login` - **2026-08-30** 실계정 스모크: 홈·검색·종목 상세·앱→웹 회수 **4항목** 수동 확인 +### 1.10 Phase 2 — 스토어 (2026-08-31 착수) + +**백엔드** + +- `delete_own_account()` RPC — auth.users 삭제 시 tickers/entries cascade + +**앱·웹** + +- `/privacy` 개인정보 처리방침 (스토어 URL용) +- `/settings` · 앱 설정: 로그아웃·계정 삭제·프라이버시 링크 +- `eas.json` (development / preview / production) + +**문서** + +- `docs/phase-2-store.md` — EAS·심사·데모 계정 체크리스트 + ### 1.7 테스트 · CI (2026-08-15) - 라우터 mock 없이 못 도는 모바일 화면 테스트는 삭제. 화면은 Maestro E2E @@ -206,7 +222,7 @@ flowchart TB - [x] Phase 0 구현: Supabase Auth + 모바일 CRUD + 웹 세션/목록 (`feat/phase-0-auth-crud`) - [x] Phase 0 실계정 스모크: 앱에서 종목·entry 생성 후 웹에서 동일 관심종목 목록 확인 - [x] Phase 1: 웹 entries 검색·종목 상세 (구현 + **2026-08-30 실계정 스모크 4항목**) -- [ ] Phase 2: EAS → App Store / Play Store +- [ ] Phase 2: EAS → App Store / Play Store (**2026-08-31** 심사 경로·EAS 스캐폴드 착수) - [ ] v1.1 공유 시트 / v2 AI 브리핑 --- @@ -295,3 +311,4 @@ Ticker Journal은 주식 리서치 스크랩과 매매 이유를 종목 타임 | 2026-08-29 | Phase 1: 웹 `/search`·`/ticker/[id]`, `buildChartHtml` shared 이동, 테스트 25건(web) | | 2026-08-29 | 검색 회수 측정: Cursor 세션 + Playwright — 홈 1.2s, `/search` 평균 1.2s, entries 0 → 성공률 0% | | 2026-08-30 | Phase 1 실계정 스모크 4항목 수동 확인 → Phase 1 마감 | +| 2026-08-31 | Phase 2 착수: 계정 삭제 RPC, privacy/settings, EAS 프로필, `docs/phase-2-store.md` | diff --git a/docs/testing.md b/docs/testing.md index 5a910d4..7eae1a8 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -28,8 +28,9 @@ - `apps/web` `resolveAuthCallbackPath` — 콜백 성공/실패 경로 (웹 단위) - `apps/web` `HomeView` — 로그인된 종목 표시, 조회 실패 메시지 (E2E에 세션 없음) - `apps/web` `SearchView` — 검색 실패 vs 빈 결과 구분 (E2E에 세션 없음) +- `apps/web` `SettingsView` — 계정 이메일·삭제 경고·프라이버시 링크 - `apps/web` `search-query` — ILIKE escape, merge·페이지 (deterministic) -- Playwright — `/`, `/login`, `/search` 비로그인 가드 (dev 서버에 Supabase placeholder env) +- Playwright — `/`, `/login`, `/search` 비로그인 가드, `/privacy`, `/settings` → `/login` (dev 서버에 Supabase placeholder env) Playwright `webServer`는 `NEXT_PUBLIC_SUPABASE_*` placeholder를 넣어 `configured=true`·세션 없음 상태를 만든다. CI에 실 Supabase/매직링크 세션은 없다. diff --git a/packages/shared/src/database.ts b/packages/shared/src/database.ts index 38cec6e..8bb37a0 100644 --- a/packages/shared/src/database.ts +++ b/packages/shared/src/database.ts @@ -108,7 +108,10 @@ export type Database = { [_ in never]: never } Functions: { - [_ in never]: never + delete_own_account: { + Args: Record + Returns: undefined + } } Enums: { entry_type: "memo" | "link" | "trade" diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 39994c4..8312545 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -97,6 +97,7 @@ export const TimelineFilterSchema = z.enum(['all', 'memo', 'link', 'trade']); export type TimelineFilter = z.infer; export const APP_NAME = 'Ticker Journal'; +export const PRIVACY_POLICY_PATH = '/privacy'; export { type AuthErrorLike, formatAuthError } from './auth-errors'; export { buildChartHtml } from './chart'; diff --git a/supabase/migrations/20260831100000_delete_own_account.sql b/supabase/migrations/20260831100000_delete_own_account.sql new file mode 100644 index 0000000..3be3b01 --- /dev/null +++ b/supabase/migrations/20260831100000_delete_own_account.sql @@ -0,0 +1,20 @@ +-- Phase 2: 스토어 심사용 계정 자가 삭제 (auth.users cascade → tickers / entries) +create or replace function public.delete_own_account() +returns void +language plpgsql +security definer +set search_path = '' +as $$ +declare + uid uuid := auth.uid(); +begin + if uid is null then + raise exception 'not authenticated'; + end if; + + delete from auth.users where id = uid; +end; +$$; + +revoke all on function public.delete_own_account() from public; +grant execute on function public.delete_own_account() to authenticated;