From b4a9d8cee8204d665940740ec6a977622e86f7ad Mon Sep 17 00:00:00 2001 From: aryansoni-dev Date: Mon, 22 Jun 2026 12:51:25 +0530 Subject: [PATCH] Changes : [+] Implemented account & data deletion feature for users. [+] Added privacy policy, and terms & conditions pages. [+] Bug fixes and improvements to existing features. --- .env.example | 8 + README.md | 62 ++- app/_layout.tsx | 2 + app/legal/privacy-policy.tsx | 17 + app/legal/terms.tsx | 13 + app/settings/privacy.tsx | 34 ++ components/auth/auth-screen.tsx | 73 +-- components/auth/auth-utils.ts | 20 +- components/auth/reset-password-screen.tsx | 40 +- components/legal/legal-document-screen.tsx | 120 +++++ components/profile/profile-screen.tsx | 245 ++++++++-- content/legal/legalVersions.ts | 10 + content/legal/privacyPolicy.ts | 74 +++ content/legal/termsAndConditions.ts | 71 +++ data/profile.ts | 2 +- docs/database.md | 25 + docs/play-store-account-deletion.md | 24 + docs/privacy.md | 38 ++ docs/user-data-inventory.md | 31 ++ hooks/useAutoSync.ts | 5 + lib/account/accountDeletionErrors.ts | 24 + lib/account/accountDeletionService.ts | 215 ++++++++ lib/account/clearLocalUserData.ts | 95 ++++ store/journal-store.ts | 13 + store/useAIInsightReportStore.ts | 12 + store/useAccountDeletionStore.ts | 57 +++ store/useSyncStore.ts | 15 + supabase/config.toml | 3 + supabase/functions/delete-account/deno.json | 5 + supabase/functions/delete-account/index.ts | 459 ++++++++++++++++++ ...1104500_add_delete_deardiary_user_data.sql | 73 +++ types/accountDeletion.ts | 33 ++ 32 files changed, 1804 insertions(+), 114 deletions(-) create mode 100644 .env.example create mode 100644 app/legal/privacy-policy.tsx create mode 100644 app/legal/terms.tsx create mode 100644 components/legal/legal-document-screen.tsx create mode 100644 content/legal/legalVersions.ts create mode 100644 content/legal/privacyPolicy.ts create mode 100644 content/legal/termsAndConditions.ts create mode 100644 docs/database.md create mode 100644 docs/play-store-account-deletion.md create mode 100644 docs/privacy.md create mode 100644 docs/user-data-inventory.md create mode 100644 lib/account/accountDeletionErrors.ts create mode 100644 lib/account/accountDeletionService.ts create mode 100644 lib/account/clearLocalUserData.ts create mode 100644 store/useAccountDeletionStore.ts create mode 100644 supabase/functions/delete-account/deno.json create mode 100644 supabase/functions/delete-account/index.ts create mode 100644 supabase/migrations/20260621104500_add_delete_deardiary_user_data.sql create mode 100644 types/accountDeletion.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4832fd7 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY= +EXPO_PUBLIC_SUPABASE_URL= +EXPO_PUBLIC_SUPABASE_ANON_KEY= +EXPO_PUBLIC_ACCOUNT_DELETION_URL= + +# Server-side only. Set these in Supabase Edge Function secrets, never in Expo public env. +SUPABASE_SERVICE_ROLE_KEY= +CLERK_SECRET_KEY= diff --git a/README.md b/README.md index 48dd63f..f683d0a 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,46 @@ -# Welcome to your Expo app 👋 +# DearDiary -This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app). +DearDiary is an Expo, React Native, TypeScript, Expo Router, NativeWind, Zustand, Clerk, and Supabase journaling app. -## Get started +## Setup -1. Install dependencies - - ```bash - npm install - ``` - -2. Start the app - - ```bash - npx expo start - ``` +```bash +npm install +``` -In the output, you'll find options to open the app in a +Copy `.env.example` to your local environment file and fill the public Expo values. Keep server secrets out of the mobile app. -- [development build](https://docs.expo.dev/develop/development-builds/introduction/) -- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/) -- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/) -- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo +## Account Deletion -You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction). +The app includes a production-readiness deletion flow: -## Get a fresh project +- Profile action: **Delete My Data and Account** +- Supabase Edge Function: `delete-account` +- Database RPC: `delete_deardiary_user_data(target_user_id)` +- Local cleanup: `lib/account/clearLocalUserData.ts` -When you're ready, run: +Required Edge Function secrets: -```bash -npm run reset-project +```txt +SUPABASE_URL +SUPABASE_SERVICE_ROLE_KEY +CLERK_SECRET_KEY ``` -This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing. +Deploy the function and migration with your normal Supabase workflow. Do not put service-role or Clerk secret keys in `EXPO_PUBLIC_*` variables. -## Learn more +Related docs: -To learn more about developing your project with Expo, look at the following resources: +- `docs/user-data-inventory.md` +- `docs/database.md` +- `docs/privacy.md` +- `docs/play-store-account-deletion.md` -- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides). -- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web. +## Legal Drafts -## Join the community +Privacy Policy and Terms screens exist at: -Join our community of developers creating universal apps. +- `/legal/privacy-policy` +- `/legal/terms` -- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute. -- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions. +They contain visible placeholders and require legal review before public release. diff --git a/app/_layout.tsx b/app/_layout.tsx index 92aa379..2864eac 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -67,6 +67,8 @@ function RootNavigator() { options={{ headerShown: false }} /> + + ); diff --git a/app/legal/privacy-policy.tsx b/app/legal/privacy-policy.tsx new file mode 100644 index 0000000..8e34254 --- /dev/null +++ b/app/legal/privacy-policy.tsx @@ -0,0 +1,17 @@ +import { LegalDocumentScreen } from "@/components/legal/legal-document-screen"; +import { privacyPolicy } from "@/content/legal/privacyPolicy"; + +const accountDeletionUrl = + process.env.EXPO_PUBLIC_ACCOUNT_DELETION_URL?.trim() || null; + +export default function PrivacyPolicyScreen() { + return ( + + ); +} diff --git a/app/legal/terms.tsx b/app/legal/terms.tsx new file mode 100644 index 0000000..a4f602c --- /dev/null +++ b/app/legal/terms.tsx @@ -0,0 +1,13 @@ +import { LegalDocumentScreen } from "@/components/legal/legal-document-screen"; +import { termsAndConditions } from "@/content/legal/termsAndConditions"; + +export default function TermsScreen() { + return ( + + ); +} diff --git a/app/settings/privacy.tsx b/app/settings/privacy.tsx index bd4cf2e..768fbcc 100644 --- a/app/settings/privacy.tsx +++ b/app/settings/privacy.tsx @@ -21,6 +21,10 @@ import { useAppLockSettings } from "@/hooks/useAppLockSettings"; import type { AppLockDelay } from "@/types/appLock"; const changePinHref = "/settings/app-lock/change-pin" as Href; +const privacyPolicyHref = "/legal/privacy-policy" as Href; +const termsHref = "/legal/terms" as Href; +const accountDeletionUrl = + process.env.EXPO_PUBLIC_ACCOUNT_DELETION_URL?.trim() || null; const delayOptions: { label: string; value: AppLockDelay }[] = [ { label: "Immediately", value: "immediately" }, @@ -90,6 +94,36 @@ export default function PrivacySettingsScreen() { + Privacy & Data + + router.push(privacyPolicyHref)} + value="" + /> + + router.push(termsHref)} + value="" + /> + {accountDeletionUrl ? ( + <> + + router.push(privacyPolicyHref)} + value="View" + /> + + ) : null} + + + + + + By continuing, you agree to the{" "} + + Terms + {" "} + and acknowledge the{" "} + + + Privacy Policy + + + . + diff --git a/components/auth/auth-utils.ts b/components/auth/auth-utils.ts index 50625e3..522ae8a 100644 --- a/components/auth/auth-utils.ts +++ b/components/auth/auth-utils.ts @@ -1,7 +1,8 @@ import { isClerkAPIResponseError } from "@clerk/expo"; import { router } from "expo-router"; import type { Href } from "expo-router"; -import { Alert } from "react-native"; + +import type { AppDialogOptions } from "@/components/ui/AppDialog"; export type FieldFeedback = { message: string; @@ -76,8 +77,16 @@ export function getClerkErrorMessage(error: unknown) { return "Something went wrong. Please try again."; } -export function showAuthError(message: string) { - Alert.alert("Authentication error", message); +export function showAuthError( + showDialog: (options: AppDialogOptions) => void, + message: string, +) { + showDialog({ + confirmText: "OK", + message, + title: "Authentication error", + variant: "destructive", + }); } export async function finalizeAuth(resource: { @@ -86,11 +95,12 @@ export async function finalizeAuth(resource: { session: { currentTask?: { key?: string } } | null; }) => void; }) => Promise<{ error: unknown | null }>; -}) { +}, showDialog: (options: AppDialogOptions) => void) { const { error } = await resource.finalize({ navigate: ({ session }) => { if (session?.currentTask) { showAuthError( + showDialog, "Your account needs one more setup step before opening.", ); return; @@ -101,6 +111,6 @@ export async function finalizeAuth(resource: { }); if (error) { - showAuthError(getClerkErrorMessage(error)); + showAuthError(showDialog, getClerkErrorMessage(error)); } } diff --git a/components/auth/reset-password-screen.tsx b/components/auth/reset-password-screen.tsx index ba74ca8..32b8b79 100644 --- a/components/auth/reset-password-screen.tsx +++ b/components/auth/reset-password-screen.tsx @@ -16,6 +16,7 @@ import { } from "react-native"; import { images } from "@/constants/images"; +import { useAppDialog } from "@/hooks/useAppDialog"; import { AuthTextField } from "./auth-text-field"; import { @@ -51,6 +52,7 @@ export function ResetPasswordScreen() { useState(false); const { isLoaded: isAuthLoaded, isSignedIn } = useAuth(); const { fetchStatus, signIn } = useSignIn(); + const { showDialog } = useAppDialog(); const isClerkReady = isAuthLoaded && fetchStatus !== "fetching"; const emailFeedback = getEmailFeedback(email); const passwordFeedback = getPasswordFeedback(newPassword, false); @@ -65,6 +67,10 @@ export function ResetPasswordScreen() { } }, [isSignedIn]); + function showError(message: string) { + showAuthError(showDialog, message); + } + async function handlePrimaryPress() { if (!isClerkReady || isSubmitting) { return; @@ -80,12 +86,12 @@ export function ResetPasswordScreen() { async function handleGetCode() { if (!email.trim()) { - showAuthError("Enter your email address to receive a reset code."); + showError("Enter your email address to receive a reset code."); return; } if (emailFeedback?.tone === "error") { - showAuthError(emailFeedback.message); + showError(emailFeedback.message); return; } @@ -94,20 +100,20 @@ export function ResetPasswordScreen() { try { const createResult = await signIn.create({ identifier: email.trim() }); if (createResult.error) { - showAuthError(getClerkErrorMessage(createResult.error)); + showError(getClerkErrorMessage(createResult.error)); return; } const codeResult = await signIn.resetPasswordEmailCode.sendCode(); if (codeResult.error) { - showAuthError(getClerkErrorMessage(codeResult.error)); + showError(getClerkErrorMessage(codeResult.error)); return; } setVerificationCode(""); setIsVerificationVisible(true); } catch (error) { - showAuthError(getClerkErrorMessage(error)); + showError(getClerkErrorMessage(error)); } finally { setIsSubmitting(false); } @@ -135,7 +141,7 @@ export function ResetPasswordScreen() { }); if (error) { - showAuthError(getClerkErrorMessage(error)); + showError(getClerkErrorMessage(error)); return; } @@ -145,9 +151,9 @@ export function ResetPasswordScreen() { return; } - showAuthError("Clerk needs a new password to finish this reset."); + showError("Clerk needs a new password to finish this reset."); } catch (error) { - showAuthError(getClerkErrorMessage(error)); + showError(getClerkErrorMessage(error)); } finally { setIsVerifying(false); } @@ -158,29 +164,29 @@ export function ResetPasswordScreen() { const { error } = await signIn.resetPasswordEmailCode.sendCode(); if (error) { - showAuthError(getClerkErrorMessage(error)); + showError(getClerkErrorMessage(error)); return; } setVerificationCode(""); } catch (error) { - showAuthError(getClerkErrorMessage(error)); + showError(getClerkErrorMessage(error)); } } async function handleResetPassword() { if (!newPassword || !confirmPassword) { - showAuthError("Enter and confirm your new password to continue."); + showError("Enter and confirm your new password to continue."); return; } if (passwordFeedback?.tone === "error") { - showAuthError(passwordFeedback.message); + showError(passwordFeedback.message); return; } if (confirmPasswordFeedback?.tone === "error") { - showAuthError(confirmPasswordFeedback.message); + showError(confirmPasswordFeedback.message); return; } @@ -192,20 +198,20 @@ export function ResetPasswordScreen() { }); if (error) { - showAuthError(getClerkErrorMessage(error)); + showError(getClerkErrorMessage(error)); return; } if (signIn.status === "complete") { - await finalizeAuth(signIn); + await finalizeAuth(signIn, showDialog); return; } - showAuthError( + showError( "Password reset needs one more Clerk step before opening.", ); } catch (error) { - showAuthError(getClerkErrorMessage(error)); + showError(getClerkErrorMessage(error)); } finally { setIsSubmitting(false); } diff --git a/components/legal/legal-document-screen.tsx b/components/legal/legal-document-screen.tsx new file mode 100644 index 0000000..41d21f3 --- /dev/null +++ b/components/legal/legal-document-screen.tsx @@ -0,0 +1,120 @@ +import { Feather } from "@expo/vector-icons"; +import { LinearGradient } from "expo-linear-gradient"; +import { router } from "expo-router"; +import { ScrollView, Text, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AnimatedIconButton } from "@/components/ui/animated-icon-button"; +import type { LegalSection } from "@/content/legal/privacyPolicy"; + +type LegalDocumentScreenProps = { + accountDeletionUrl?: string | null; + effectiveDate: string; + sections: LegalSection[]; + title: string; + version: string; +}; + +export function LegalDocumentScreen({ + accountDeletionUrl, + effectiveDate, + sections, + title, + version, +}: LegalDocumentScreenProps) { + const insets = useSafeAreaInsets(); + + function handleBackPress() { + if (router.canGoBack()) { + router.back(); + return; + } + + router.replace("/login"); + } + + return ( + + + + + + + + + + {title} + + + + + + + + + Version {version} + + + Effective date: {effectiveDate} + + + + + + {sections.map((section) => ( + + + {section.title} + + {section.body.map((paragraph) => ( + + {paragraph} + + ))} + + ))} + + + {accountDeletionUrl ? ( + + + External account-deletion page + + + {accountDeletionUrl} + + + ) : null} + + + ); +} diff --git a/components/profile/profile-screen.tsx b/components/profile/profile-screen.tsx index d028293..4d947d8 100644 --- a/components/profile/profile-screen.tsx +++ b/components/profile/profile-screen.tsx @@ -9,6 +9,7 @@ import { Pressable, ScrollView, Text, + TextInput, View, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -27,18 +28,20 @@ import { type ProfileStat, } from "@/data/profile"; import { useAppDialog } from "@/hooks/useAppDialog"; +import { getAccountDeletionFailureMessage } from "@/lib/account/accountDeletionErrors"; +import { deleteCurrentAccount } from "@/lib/account/accountDeletionService"; import { getAchievements, getWordCount } from "@/lib/achievements"; import { exportJournalAsJson, exportJournalAsMarkdown, JournalExportError, } from "@/lib/exportJournal"; -import { clearEntriesForUser } from "@/lib/local-data"; import { setSupabaseAccessTokenProvider } from "@/lib/supabase"; import { syncAchievementStatesTwoWay } from "@/lib/sync/achievementSync"; import { syncJournalEntriesTwoWay } from "@/lib/sync/journalTwoWaySync"; import { syncProfileToCloud } from "@/lib/sync/profileSync"; import { useJournalStore } from "@/store/journal-store"; +import { useAccountDeletionStore } from "@/store/useAccountDeletionStore"; import { useAchievementStore } from "@/store/useAchievementStore"; import { useSyncStore } from "@/store/useSyncStore"; import type { AchievementCategory } from "@/types/achievement"; @@ -53,6 +56,7 @@ const profileNotificationsHref = "/profile-notifications" as Href; const achievementsHref = "/achievements" as Href; const privacySettingsHref = "/settings/privacy" as Href; const cloudSyncItemLabel = "Backup & Sync Data"; +const deleteAccountItemLabel = "Delete My Data and Account"; const syncStatusRefreshIntervalMs = 60 * 1000; const moodLabels: Record = { @@ -108,10 +112,16 @@ export function ProfileScreen() { const lastSyncedAt = useSyncStore((state) => state.lastSyncedAt); const lastSyncFailedAt = useSyncStore((state) => state.lastSyncFailedAt); const lastSyncUserId = useSyncStore((state) => state.lastSyncUserId); + const deletionStage = useAccountDeletionStore((state) => state.stage); + const deletionInProgress = useAccountDeletionStore( + (state) => state.deletionInProgress, + ); const setIsSyncing = useSyncStore((state) => state.setIsSyncing); const setSyncFailure = useSyncStore((state) => state.setSyncFailure); const setSyncSuccess = useSyncStore((state) => state.setSyncSuccess); - const [isClearingData, setIsClearingData] = useState(false); + const [deleteConfirmationText, setDeleteConfirmationText] = useState(""); + const [isDeleteConfirmationVisible, setIsDeleteConfirmationVisible] = + useState(false); const [isExportingJournal, setIsExportingJournal] = useState(false); const [isSigningOut, setIsSigningOut] = useState(false); const [syncStatusNow, setSyncStatusNow] = useState(() => Date.now()); @@ -210,7 +220,7 @@ export function ProfileScreen() { } async function handleSyncNow() { - if (isSyncing) { + if (isSyncing || deletionInProgress) { return; } @@ -390,8 +400,8 @@ export function ProfileScreen() { } } - function handleClearCurrentUserData() { - if (isClearingData) { + function handleDeleteAccountPress() { + if (deletionInProgress) { return; } @@ -400,7 +410,7 @@ export function ProfileScreen() { if (!userId) { showDialog({ confirmText: "OK", - message: "Please sign in before clearing journal data.", + message: "Please sign in before deleting your account.", title: "Sign in required", variant: "destructive", }); @@ -411,46 +421,58 @@ export function ProfileScreen() { actions: [ { onPress: () => { - void clearCurrentUserData(userId); + setDeleteConfirmationText(""); + setIsDeleteConfirmationVisible(true); }, - text: "Clear Journal Data", + text: "I understand", variant: "destructive", }, ], cancelText: "Cancel", message: - "This will delete your local journal entries on this device. This cannot be undone.", + "This permanently removes your journal entries, moods, AI conversations, reports, achievements, reminders, App Lock settings, local DearDiary data, and sign-in account. Export your journal first if you want to keep a copy.", showCancel: true, - title: "Clear journal data?", + title: "Delete your account?", variant: "destructive", }); } - async function clearCurrentUserData(userId: string) { - setIsClearingData(true); + async function handleConfirmDeleteAccount() { + const userId = user?.id; - try { - await clearEntriesForUser(userId); + if (!userId || deleteConfirmationText !== "DELETE") { + return; + } + + const result = await deleteCurrentAccount({ + confirmationPhrase: deleteConfirmationText, + getToken, + signOut: () => signOut(), + userId, + }); + + if (result.success) { + setIsDeleteConfirmationVisible(false); + setDeleteConfirmationText(""); + router.replace("/login"); showDialog({ confirmText: "Done", - message: "Your local journal entries were deleted from this device.", - title: "Journal data cleared", + message: "Your DearDiary account and data have been deleted.", + title: "Account deleted", variant: "success", }); - } catch (error) { - const message = - error instanceof Error - ? error.message - : "We could not clear local data. Please try again."; + return; + } - showDialog({ - confirmText: "OK", - message, - title: "Clear data failed", - variant: "destructive", - }); - } finally { - setIsClearingData(false); + showDialog({ + confirmText: "OK", + message: getAccountDeletionFailureMessage(result.code), + title: "Deletion paused", + variant: "destructive", + }); + + if (!result.remoteDataDeleted) { + setIsDeleteConfirmationVisible(false); } } @@ -794,8 +816,8 @@ export function ProfileScreen() { return; } - if (item.label === "Clear My Journal Data") { - handleClearCurrentUserData(); + if (item.label === deleteAccountItemLabel) { + handleDeleteAccountPress(); return; } @@ -804,6 +826,25 @@ export function ProfileScreen() { title="Account" /> + {isDeleteConfirmationVisible ? ( + { + if (deletionInProgress) { + return; + } + + setIsDeleteConfirmationVisible(false); + setDeleteConfirmationText(""); + }} + onChangeConfirmationText={setDeleteConfirmationText} + onConfirm={() => void handleConfirmDeleteAccount()} + onExport={handleExportJournalPress} + /> + ) : null} + ; } +function DeleteAccountConfirmationCard({ + confirmationText, + deletionInProgress, + deletionStage, + onCancel, + onChangeConfirmationText, + onConfirm, + onExport, +}: { + confirmationText: string; + deletionInProgress: boolean; + deletionStage: string; + onCancel: () => void; + onChangeConfirmationText: (value: string) => void; + onConfirm: () => void; + onExport: () => void; +}) { + const canConfirm = confirmationText === "DELETE" && !deletionInProgress; + + return ( + + + + + Final confirmation + + + This action cannot be undone. Export your journal first if you want + to keep a copy. + + + + + + The following data will be permanently deleted from our servers and your device: + + {[ + "Journal entries, moods, tags, and reflections", + "AI conversations, generated reflections, and reports", + "Achievements, reminders, preferences, and App Lock settings", + "Cloud data, local DearDiary data, and your sign-in account", + ].map((item) => ( + + • + + {item} + + + ))} + + + + + Export Journal First + + + + + + Type DELETE to continue + + + + + {deletionInProgress ? ( + + + + + {getDeletionProgressLabel(deletionStage)} + + + + Please keep the app open. Do not close DearDiary yet. + + + ) : null} + + + + + Delete My Data and Account + + + + + + Cancel + + + + + + ); +} + +function getDeletionProgressLabel(stage: string) { + if (stage === "clearing_local_data") { + return "Clearing private data from this device..."; + } + + if (stage === "deleting_auth_account") { + return "Closing your sign-in account..."; + } + + return "Deleting your DearDiary data..."; +} + function getSyncResultMessage(pushedCount: number, restoredCount: number) { const backedUpLabel = pushedCount === 1 ? "entry" : "entries"; const restoredLabel = restoredCount === 1 ? "update" : "updates"; diff --git a/content/legal/legalVersions.ts b/content/legal/legalVersions.ts new file mode 100644 index 0000000..b9ac108 --- /dev/null +++ b/content/legal/legalVersions.ts @@ -0,0 +1,10 @@ +export const PRIVACY_POLICY_VERSION = "1.0"; +export const TERMS_VERSION = "1.0"; + +export const legalPlaceholders = { + companyName: "[Developer/Company Name]", + effectiveDate: "[Privacy Policy Effective Date]", + supportEmail: "[Support Email]", + termsEffectiveDate: "[Terms Effective Date]", + websiteUrl: "[Website URL]", +} as const; diff --git a/content/legal/privacyPolicy.ts b/content/legal/privacyPolicy.ts new file mode 100644 index 0000000..d58c6cc --- /dev/null +++ b/content/legal/privacyPolicy.ts @@ -0,0 +1,74 @@ +import { + legalPlaceholders, + PRIVACY_POLICY_VERSION, +} from "@/content/legal/legalVersions"; + +export type LegalSection = { + body: string[]; + title: string; +}; + +export const privacyPolicy = { + effectiveDate: legalPlaceholders.effectiveDate, + sections: [ + { + body: [ + "DearDiary is an AI-powered journaling companion. This draft explains what data the app handles and how account deletion works. It requires legal review before public release.", + `Operator: ${legalPlaceholders.companyName}. Website: ${legalPlaceholders.websiteUrl}.`, + ], + title: "Overview", + }, + { + body: [ + "Account sign-in and account identifiers are handled through Clerk. DearDiary may receive your Clerk user ID, name, profile image, and email address so the app can show your profile and connect your journal data to your account.", + ], + title: "Account Information", + }, + { + body: [ + "Journal entries, moods, tags, prompts, reflections, AI conversations, generated reports, achievements, and preferences may be stored on your device and synchronized with Supabase for backup and cross-device access.", + "App Lock settings, including PIN-derived security data and biometric preferences, are stored locally on your device.", + ], + title: "Journal And App Data", + }, + { + body: [ + "DearDiary uses configured AI services to generate prompts, entry reflections, chat responses, and reports. AI responses may be incomplete or incorrect and are not medical diagnoses, treatment, therapy, or emergency assistance.", + ], + title: "AI Processing", + }, + { + body: [ + "If you enable reminders, DearDiary schedules local notifications on your device. Notification permission can be changed in your device settings or in the app.", + ], + title: "Notifications", + }, + { + body: [ + "You can export your journal from the app. Exported files are created on your device and may be shared through your operating system. You are responsible for where exported files are saved or sent.", + ], + title: "Export And Backup", + }, + { + body: [ + "You can request deletion from inside the app. Deletion removes your DearDiary cloud data, your Clerk authentication account, and user-scoped DearDiary data on the device after the server-side deletion completes.", + "Some infrastructure backups or logs may persist for a limited period if required for operations, security, or legal obligations. This section requires legal review before release.", + ], + title: "Deletion And Retention", + }, + { + body: [ + `For privacy questions or account deletion help, contact ${legalPlaceholders.supportEmail}.`, + ], + title: "Contact", + }, + { + body: [ + "We may update this policy as DearDiary changes. The app will show the current policy version and effective date.", + ], + title: "Updates", + }, + ] satisfies LegalSection[], + title: "Privacy Policy", + version: PRIVACY_POLICY_VERSION, +}; diff --git a/content/legal/termsAndConditions.ts b/content/legal/termsAndConditions.ts new file mode 100644 index 0000000..7182d92 --- /dev/null +++ b/content/legal/termsAndConditions.ts @@ -0,0 +1,71 @@ +import { + legalPlaceholders, + TERMS_VERSION, +} from "@/content/legal/legalVersions"; +import type { LegalSection } from "@/content/legal/privacyPolicy"; + +export const termsAndConditions = { + effectiveDate: legalPlaceholders.termsEffectiveDate, + sections: [ + { + body: [ + `These Terms are a draft between you and ${legalPlaceholders.companyName}. They require legal review before public release.`, + ], + title: "Acceptance", + }, + { + body: [ + "You are responsible for keeping your sign-in credentials secure and for activity on your account. Use DearDiary only for lawful, personal reflection and journaling.", + ], + title: "Account Responsibilities", + }, + { + body: [ + "Your journal content belongs to you. You grant DearDiary the limited permission needed to store, sync, process, display, export, and delete your content as part of the app experience.", + ], + title: "Your Content", + }, + { + body: [ + "DearDiary AI features are reflective tools. AI-generated content may be incomplete or incorrect and is not medical advice, diagnosis, therapy, emergency service, or a substitute for professional care.", + ], + title: "AI Features", + }, + { + body: [ + "DearDiary may be unavailable from time to time. You are responsible for exporting or backing up content you want to keep outside the app.", + ], + title: "Availability And Backups", + }, + { + body: [ + "You may delete your account and associated DearDiary data through the app. Deletion is permanent once completed and cannot be undone.", + ], + title: "Account Deletion", + }, + { + body: [ + "DearDiary, including its design, branding, software, and generated app materials, is protected by intellectual property laws. Legal ownership details require review before release.", + ], + title: "Intellectual Property", + }, + { + body: [ + "Limitations of liability, warranties, governing law, and dispute terms require legal review and should not be considered final in this draft.", + ], + title: "Legal Limitations", + }, + { + body: [ + "We may update these Terms as the app changes. Continued use after updates means you accept the updated Terms where legally permitted.", + ], + title: "Changes", + }, + { + body: [`Questions can be sent to ${legalPlaceholders.supportEmail}.`], + title: "Contact", + }, + ] satisfies LegalSection[], + title: "Terms And Conditions", + version: TERMS_VERSION, +}; diff --git a/data/profile.ts b/data/profile.ts index 4061cd8..6a1a2d2 100644 --- a/data/profile.ts +++ b/data/profile.ts @@ -156,6 +156,6 @@ export const accountItems: ProfileMenuItem[] = [ backgroundColor: "#FFE1EE", icon: "trash-2", iconColor: "#FF2056", - label: "Clear My Journal Data", + label: "Delete My Data and Account", }, ]; diff --git a/docs/database.md b/docs/database.md new file mode 100644 index 0000000..014b4d4 --- /dev/null +++ b/docs/database.md @@ -0,0 +1,25 @@ +# Database Notes + +## Account Deletion + +The migration `supabase/migrations/20260621104500_add_delete_deardiary_user_data.sql` adds `public.delete_deardiary_user_data(target_user_id text)`. + +The function deletes current known user-owned tables in this order: + +1. `achievement_states`, when present +2. `entry_ai_reflections`, when present +3. `ai_insights`, when present +4. `journal_entries`, when present +5. `profiles`, when present + +The function includes soft-deleted journal entries because it deletes by `user_id` without filtering `deleted_at`. + +Execution is revoked from `public`, `anon`, and `authenticated`, then granted to `service_role`. It is intended to be called only by the `delete-account` Edge Function using server-side credentials. + +## Verification Checklist + +- Create data for two users across every listed table. +- Delete User A through the app. +- Confirm no User A rows remain. +- Confirm User B rows remain unchanged. +- Retry deletion for User A and confirm idempotency. diff --git a/docs/play-store-account-deletion.md b/docs/play-store-account-deletion.md new file mode 100644 index 0000000..d038224 --- /dev/null +++ b/docs/play-store-account-deletion.md @@ -0,0 +1,24 @@ +# Play Store Account Deletion Preparation + +DearDiary now supports in-app account deletion from the existing Profile account action, renamed to **Delete My Data and Account**. + +## Public URL Requirement + +Google Play also requires an externally accessible account-deletion page. This repository does not include a landing-page project, so no public route was created here. + +Configure this placeholder when a public page exists: + +```txt +EXPO_PUBLIC_ACCOUNT_DELETION_URL=https://[Website URL]/account-deletion +``` + +The public page must: + +- Explain how signed-in users delete their account inside DearDiary. +- Offer a support-based deletion request when the app is inaccessible. +- Explain what data is deleted. +- Explain any legally retained data, if applicable. +- Use the placeholder support contact `[Support Email]` until a real reviewed address exists. +- Verify account ownership before deletion. + +Do not create a public endpoint that deletes accounts based only on an email address. diff --git a/docs/privacy.md b/docs/privacy.md new file mode 100644 index 0000000..aaec882 --- /dev/null +++ b/docs/privacy.md @@ -0,0 +1,38 @@ +# Privacy And Legal Draft Notes + +The in-app Privacy Policy and Terms are draft foundations that require legal review before public release. + +Visible placeholders are intentionally used: + +- `[Developer/Company Name]` +- `[Support Email]` +- `[Privacy Policy Effective Date]` +- `[Terms Effective Date]` +- `[Website URL]` + +The current drafts avoid unsupported claims such as end-to-end encryption, zero-knowledge encryption, HIPAA compliance, medical treatment, therapist-client confidentiality, or third-party-free processing. + +## Deletion Architecture + +1. User starts deletion from Profile with **Delete My Data and Account**. +2. User reviews consequences. +3. User types `DELETE`. +4. Client sets the deletion guard and stops sync/write operations. +5. `supabase/functions/delete-account` derives the Clerk user ID from the authenticated token. +6. The function deletes user-scoped storage objects where configured. +7. The function calls `delete_deardiary_user_data(target_user_id)`. +8. The function deletes the Clerk user with `CLERK_SECRET_KEY`. +9. The app clears local user-scoped stores, SecureStore App Lock data, reminders, and export files. +10. The app returns to the public auth flow. + +Required Edge Function secrets: + +```txt +SUPABASE_URL +SUPABASE_SERVICE_ROLE_KEY +CLERK_SECRET_KEY +``` + +`SUPABASE_ANON_KEY` or `SUPABASE_PUBLISHABLE_KEY` must also be available for the user-token auth probe. + +The current Clerk Expo SDK in this project does not expose a mobile reverification helper in the installed package. Clerk dashboard/session reverification requirements should be reviewed before public release. diff --git a/docs/user-data-inventory.md b/docs/user-data-inventory.md new file mode 100644 index 0000000..3e6b27e --- /dev/null +++ b/docs/user-data-inventory.md @@ -0,0 +1,31 @@ +# DearDiary User Data Inventory + +Draft source of truth for account deletion testing. Update this file whenever a new user-owned table, store, file, SecureStore key, or storage bucket is added. + +| Resource | Location | Ownership field | Deletion method | +| --- | --- | --- | --- | +| Profile | `public.profiles` | `id` and optional `user_id` | `delete_deardiary_user_data(target_user_id)` deletes profile last. | +| Journal entries, moods, tags, prompts, daily reflections, morning intentions, evening reflections, gratitude entries | `public.journal_entries` | `user_id` | `delete_deardiary_user_data(target_user_id)` deletes all rows, including soft-deleted rows with `deleted_at`. | +| Per-entry AI reflections | `public.entry_ai_reflections` | `user_id`, `entry_id` | Explicit delete by `user_id`; also protected by `journal_entries` cascade. | +| Weekly/monthly AI insight reports | `public.ai_insights` | `user_id`, `related_entry_ids` | Explicit delete by `user_id`. | +| Achievement sync state | `public.achievement_states` | `user_id` | Explicit delete by `user_id` when the table exists. | +| AI chat messages | `deardiary-chat-store-v1` AsyncStorage | `message.userId` | `clearLocalUserData(userId)` removes only the deleted user's messages. No remote chat table exists in V1. | +| Journal local cache and pending sync state | `dear-diary-journal` AsyncStorage | `entry.userId` | `clearLocalUserData(userId)` removes all entries for the user, including pending, failed, and soft-deleted entries. | +| Entry AI reflection cache | `deardiary-entry-reflections-v1` AsyncStorage | `reflection.userId` | `clearLocalUserData(userId)` removes only the deleted user's cached reflections. | +| AI insight report cache | `dear-diary-ai-insight-reports` AsyncStorage | `reportsByUser[userId]` | `clearLocalUserData(userId)` deletes the deleted user's report cache. | +| Achievement notifications | `deardiary-achievement-store-v1` AsyncStorage | `achievementNotificationsByUserId[userId]` | `clearLocalUserData(userId)` deletes the user's notification state. | +| Sync metadata | `deardiary-sync-store-v1` AsyncStorage | `lastSyncUserId` | Cleared when it belongs to the deleted user. | +| Notification preferences and reminder schedule | `dear-diary-notification-preferences` AsyncStorage and local scheduled notifications | Current device settings | Reset during deletion; scheduled DearDiary reminder identifiers are cancelled best-effort. | +| Onboarding state | `dear-diary-onboarding` AsyncStorage | Device-level public state | Reset after deletion so the app returns to the public flow. | +| App Lock configuration | SecureStore key generated by `getAppLockStorageKey(userId)` | User-scoped key | `deleteAppLockConfig(userId)` removes PIN hash, PIN salt, biometric preference, lock delay, enabled state, and failed-attempt state. | +| Export files | `FileSystem.documentDirectory` | File name prefix `deardiary-export-` | `clearLocalUserData(userId)` removes generated export files. | +| Supabase Storage objects | Supabase Storage | Planned user path `{userId}/...` | No user-upload buckets are used in V1. The Edge Function has bucket support ready for future user-scoped buckets. | +| Backup metadata | Not present in current schema | N/A | Documented as not implemented in V1. Add here before shipping backup tables. | +| Remote device tokens | Not present in current schema | N/A | Local reminders only in V1. Add here before shipping remote push-token tables. | + +## Deletion Coverage Notes + +- The server deletion function is idempotent and safe to retry. +- The client never sends a target user ID. The Edge Function derives the user ID from the authenticated token subject. +- Local cleanup is scoped by Clerk user ID wherever the current stores support per-user data. +- Notification cleanup is best-effort and must not falsely report remote deletion failure. diff --git a/hooks/useAutoSync.ts b/hooks/useAutoSync.ts index 724ed97..d46183e 100644 --- a/hooks/useAutoSync.ts +++ b/hooks/useAutoSync.ts @@ -10,6 +10,7 @@ import { syncAchievementStatesTwoWay } from "@/lib/sync/achievementSync"; import { syncJournalEntriesTwoWay } from "@/lib/sync/journalTwoWaySync"; import { syncProfileToCloud } from "@/lib/sync/profileSync"; import { useJournalStore } from "@/store/journal-store"; +import { useAccountDeletionStore } from "@/store/useAccountDeletionStore"; import { useAchievementStore } from "@/store/useAchievementStore"; import { useSyncStore } from "@/store/useSyncStore"; import type { JournalEntry } from "@/types/journal"; @@ -51,6 +52,10 @@ export function useAutoSync() { const userId = user.id; const syncState = useSyncStore.getState(); + if (useAccountDeletionStore.getState().deletionInProgress) { + return; + } + if (syncState.isSyncing) { return; } diff --git a/lib/account/accountDeletionErrors.ts b/lib/account/accountDeletionErrors.ts new file mode 100644 index 0000000..52d1865 --- /dev/null +++ b/lib/account/accountDeletionErrors.ts @@ -0,0 +1,24 @@ +import type { AccountDeletionFailureCode } from "@/types/accountDeletion"; + +export function getAccountDeletionFailureMessage( + code: AccountDeletionFailureCode, +) { + switch (code) { + case "network_unavailable": + return "You need an internet connection to permanently delete your account. Your account has not been deleted."; + case "remote_data_deletion_failed": + return "We couldn't delete your account data yet. Nothing has been reported as completed. Please try again."; + case "auth_account_deletion_failed": + return "Your DearDiary data was removed, but your sign-in account could not be closed. Please retry account removal or contact support."; + case "verification_required": + return "Please finish the required account verification, then try deleting your account again."; + case "already_in_progress": + return "Account deletion is already in progress. Please keep DearDiary open."; + case "local_cleanup_failed": + return "Your account deletion reached local cleanup, but some device data could not be cleared. Please try again before using another account."; + case "unauthenticated": + return "Please sign in before deleting your account."; + case "unknown": + return "We couldn't delete your account right now. Please try again."; + } +} diff --git a/lib/account/accountDeletionService.ts b/lib/account/accountDeletionService.ts new file mode 100644 index 0000000..d092877 --- /dev/null +++ b/lib/account/accountDeletionService.ts @@ -0,0 +1,215 @@ +import { clearLocalUserData } from "@/lib/account/clearLocalUserData"; +import { setSupabaseAccessTokenProvider } from "@/lib/supabase"; +import { useAccountDeletionStore } from "@/store/useAccountDeletionStore"; +import type { + AccountDeletionFailureCode, + AccountDeletionResult, + DeleteAccountFunctionResponse, +} from "@/types/accountDeletion"; + +type GetToken = () => Promise; +type AccountDeletionFailureResult = Extract< + AccountDeletionResult, + { success: false } +>; + +type DeleteCurrentAccountParams = { + confirmationPhrase: string; + getToken: GetToken; + signOut: () => Promise; + userId: string | null | undefined; +}; + +const expectedConfirmationPhrase = "DELETE"; + +export async function deleteCurrentAccount({ + confirmationPhrase, + getToken, + signOut, + userId, +}: DeleteCurrentAccountParams): Promise { + const deletionStore = useAccountDeletionStore.getState(); + + if ( + deletionStore.deletionInProgress && + deletionStore.failureCode !== "auth_account_deletion_failed" + ) { + return { + code: "already_in_progress", + retryable: false, + success: false, + }; + } + + if (!userId) { + return { + code: "unauthenticated", + retryable: false, + success: false, + }; + } + + if (confirmationPhrase !== expectedConfirmationPhrase) { + return { + code: "verification_required", + retryable: false, + success: false, + }; + } + + deletionStore.beginDeletion(); + setSupabaseAccessTokenProvider(() => getToken()); + + try { + deletionStore.setStage("deleting_remote_data"); + const response = await invokeDeleteAccountFunction({ + confirmationPhrase, + getToken, + }); + + if (!response.success) { + if (response.remoteDataDeleted) { + deletionStore.setStage("clearing_local_data"); + await clearLocalUserData(userId); + deletionStore.failDeletion(response.code, { + keepGuardActive: response.code === "auth_account_deletion_failed", + requestId: response.requestId, + }); + } else { + deletionStore.failDeletion(response.code, { + requestId: response.requestId, + }); + } + + return response; + } + + deletionStore.setStage("clearing_local_data"); + await clearLocalUserData(userId); + + try { + await signOut(); + } catch { + // The Clerk account may already be deleted. Local data cleanup is complete. + } + + deletionStore.completeDeletion(response.requestId); + + return response; + } catch (error) { + const result = getDeletionErrorResult(error); + + deletionStore.failDeletion(result.code, { + requestId: result.requestId, + }); + + return result; + } finally { + setSupabaseAccessTokenProvider(null); + } +} + +async function invokeDeleteAccountFunction({ + confirmationPhrase, + getToken, +}: { + confirmationPhrase: string; + getToken: GetToken; +}): Promise { + const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL?.trim(); + const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY?.trim(); + + if (!supabaseUrl || !supabaseAnonKey) { + return { + code: "remote_data_deletion_failed", + retryable: true, + success: false, + }; + } + + const token = await getToken(); + + if (!token) { + return { + code: "unauthenticated", + retryable: false, + success: false, + }; + } + + const response = await fetch(`${supabaseUrl}/functions/v1/delete-account`, { + body: JSON.stringify({ confirmationPhrase }), + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + apikey: supabaseAnonKey, + }, + method: "POST", + }); + const data: unknown = await response.json().catch(() => null); + + if (isDeleteAccountFunctionResponse(data)) { + return data; + } + + return { + code: response.ok ? "unknown" : "remote_data_deletion_failed", + retryable: true, + success: false, + }; +} + +function getDeletionErrorResult(error: unknown): AccountDeletionFailureResult { + if (error instanceof TypeError) { + return { + code: "network_unavailable", + retryable: true, + success: false, + }; + } + + return { + code: "unknown", + retryable: true, + success: false, + }; +} + +function isDeleteAccountFunctionResponse( + value: unknown, +): value is DeleteAccountFunctionResponse { + if (!isRecord(value) || typeof value.success !== "boolean") { + return false; + } + + if (value.success) { + return typeof value.requestId === "string"; + } + + return ( + isAccountDeletionFailureCode(value.code) && + typeof value.retryable === "boolean" && + (value.requestId === undefined || typeof value.requestId === "string") && + (value.remoteDataDeleted === undefined || + typeof value.remoteDataDeleted === "boolean") + ); +} + +function isAccountDeletionFailureCode( + value: unknown, +): value is AccountDeletionFailureCode { + return ( + value === "unauthenticated" || + value === "verification_required" || + value === "remote_data_deletion_failed" || + value === "auth_account_deletion_failed" || + value === "local_cleanup_failed" || + value === "network_unavailable" || + value === "already_in_progress" || + value === "unknown" + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/lib/account/clearLocalUserData.ts b/lib/account/clearLocalUserData.ts new file mode 100644 index 0000000..34638d7 --- /dev/null +++ b/lib/account/clearLocalUserData.ts @@ -0,0 +1,95 @@ +import * as FileSystem from "expo-file-system/legacy"; + +import { disableJournalReminders } from "@/lib/notifications"; +import { deleteAppLockConfig } from "@/lib/security/appLockStorage"; +import { useJournalStore } from "@/store/journal-store"; +import { useAchievementStore } from "@/store/useAchievementStore"; +import { useAIInsightReportStore } from "@/store/useAIInsightReportStore"; +import { useChatStore } from "@/store/useChatStore"; +import { useEntryReflectionStore } from "@/store/useEntryReflectionStore"; +import { useNotificationPreferencesStore } from "@/store/notification-preferences-store"; +import { useOnboardingStore } from "@/store/onboarding-store"; +import { useSyncStore } from "@/store/useSyncStore"; + +const exportFilePrefix = "deardiary-export-"; + +export async function clearLocalUserData(userId: string): Promise { + const cleanupResults = await Promise.allSettled([ + clearInMemoryAndPersistedStores(userId), + clearAppLockConfig(userId), + clearExportFiles(), + ]); + await clearNotificationState(); + const failedRequiredCleanup = cleanupResults.some( + (result) => result.status === "rejected", + ); + + if (failedRequiredCleanup) { + throw new Error("Local account data could not be fully cleared."); + } +} + +async function clearInMemoryAndPersistedStores(userId: string) { + useJournalStore.getState().clearEntriesForUser(userId); + useJournalStore.getState().setActiveUserId(null); + useChatStore.getState().clearMessagesForUser(userId); + useEntryReflectionStore.getState().clearReflectionsForUser(userId); + useAIInsightReportStore.getState().clearReportsForUser(userId); + useAchievementStore.getState().resetAchievementNotifications(userId); + useAchievementStore.getState().setAchievementSyncUserId(null); + useSyncStore.getState().clearSyncStateForUser(userId); + useNotificationPreferencesStore.getState().resetNotificationPreferences(); + useOnboardingStore.getState().resetOnboarding(); +} + +async function clearAppLockConfig(userId: string) { + await deleteAppLockConfig(userId); +} + +async function clearNotificationState() { + try { + await disableJournalReminders(); + } catch (error) { + if (isExpectedNotificationCleanupError(error)) { + return; + } + + if (__DEV__) { + console.warn("Notification cleanup failed during account deletion", { + name: error instanceof Error ? error.name : "UnknownError", + }); + } + } +} + +function isExpectedNotificationCleanupError(error: unknown) { + if (!(error instanceof Error)) { + return false; + } + + return ( + error.message.includes("Expo Go no longer supports expo-notifications") || + error.message.includes("Notification reminders need a development build") + ); +} + +async function clearExportFiles() { + if (!FileSystem.documentDirectory) { + return; + } + + const fileNames = await FileSystem.readDirectoryAsync( + FileSystem.documentDirectory, + ); + const exportFiles = fileNames.filter((fileName) => + fileName.startsWith(exportFilePrefix), + ); + + await Promise.all( + exportFiles.map((fileName) => + FileSystem.deleteAsync(`${FileSystem.documentDirectory}${fileName}`, { + idempotent: true, + }), + ), + ); +} diff --git a/store/journal-store.ts b/store/journal-store.ts index 1d6725e..682a561 100644 --- a/store/journal-store.ts +++ b/store/journal-store.ts @@ -7,6 +7,7 @@ import { type MergeResult, } from "@/lib/sync/mergeJournalEntries"; import { normalizeTags } from "@/lib/tags"; +import { useAccountDeletionStore } from "@/store/useAccountDeletionStore"; import type { EntryType, JournalEntry, @@ -176,6 +177,10 @@ export const useJournalStore = create()( (set, get) => ({ activeUserId: null, addEntry: (entry) => { + if (useAccountDeletionStore.getState().deletionInProgress) { + throw new Error("Account deletion is in progress."); + } + const userId = get().activeUserId; if (!userId) { @@ -231,6 +236,10 @@ export const useJournalStore = create()( }; }), deleteEntry: (id) => { + if (useAccountDeletionStore.getState().deletionInProgress) { + return; + } + const now = new Date().toISOString(); set((state) => ({ @@ -330,6 +339,10 @@ export const useJournalStore = create()( })), setHasHydrated: (hasHydrated) => set({ hasHydrated }), updateEntry: (id, entry) => { + if (useAccountDeletionStore.getState().deletionInProgress) { + return; + } + const now = new Date().toISOString(); set((state) => ({ diff --git a/store/useAIInsightReportStore.ts b/store/useAIInsightReportStore.ts index b8c2f16..f39f8b2 100644 --- a/store/useAIInsightReportStore.ts +++ b/store/useAIInsightReportStore.ts @@ -11,6 +11,7 @@ type UserReportCache = Record; type AIInsightReportState = { reportsByUser: Record; + clearReportsForUser: (userId: string) => void; getCachedReport: (userId: string | null, cacheKey: string) => AIInsightReport | null; removeCachedReport: (userId: string, cacheKey: string) => void; setCachedReport: (userId: string, cacheKey: string, report: AIInsightReport) => void; @@ -19,6 +20,17 @@ type AIInsightReportState = { export const useAIInsightReportStore = create()( persist( (set, get) => ({ + clearReportsForUser: (userId) => + set((state) => { + if (!state.reportsByUser[userId]) { + return state; + } + + const reportsByUser = { ...state.reportsByUser }; + delete reportsByUser[userId]; + + return { reportsByUser }; + }), getCachedReport: (userId, cacheKey) => { if (!userId) { return null; diff --git a/store/useAccountDeletionStore.ts b/store/useAccountDeletionStore.ts new file mode 100644 index 0000000..bad5b58 --- /dev/null +++ b/store/useAccountDeletionStore.ts @@ -0,0 +1,57 @@ +import { create } from "zustand"; + +import type { + AccountDeletionFailureCode, + AccountDeletionStage, +} from "@/types/accountDeletion"; + +type AccountDeletionState = { + activeRequestId: string | null; + deletionInProgress: boolean; + failureCode: AccountDeletionFailureCode | null; + stage: AccountDeletionStage; + beginDeletion: (requestId?: string) => void; + completeDeletion: (requestId?: string) => void; + failDeletion: ( + code: AccountDeletionFailureCode, + options?: { keepGuardActive?: boolean; requestId?: string }, + ) => void; + resetDeletionState: () => void; + setStage: (stage: AccountDeletionStage) => void; +}; + +export const useAccountDeletionStore = create()((set) => ({ + activeRequestId: null, + beginDeletion: (requestId) => + set({ + activeRequestId: requestId ?? null, + deletionInProgress: true, + failureCode: null, + stage: "verifying", + }), + completeDeletion: (requestId) => + set({ + activeRequestId: requestId ?? null, + deletionInProgress: false, + failureCode: null, + stage: "completed", + }), + deletionInProgress: false, + failDeletion: (code, options) => + set({ + activeRequestId: options?.requestId ?? null, + deletionInProgress: options?.keepGuardActive ?? false, + failureCode: code, + stage: "failed", + }), + failureCode: null, + resetDeletionState: () => + set({ + activeRequestId: null, + deletionInProgress: false, + failureCode: null, + stage: "idle", + }), + setStage: (stage) => set({ stage }), + stage: "idle", +})); diff --git a/store/useSyncStore.ts b/store/useSyncStore.ts index 6eb4891..554755f 100644 --- a/store/useSyncStore.ts +++ b/store/useSyncStore.ts @@ -4,6 +4,7 @@ import { createJSONStorage, persist } from "zustand/middleware"; type SyncState = { clearSyncError: () => void; + clearSyncStateForUser: (userId: string) => void; hasHydrated: boolean; isSyncing: boolean; lastSyncError: string | null; @@ -33,6 +34,20 @@ export const useSyncStore = create()( lastSyncError: null, lastSyncFailedAt: null, }), + clearSyncStateForUser: (userId) => + set((state) => { + if (state.lastSyncUserId !== userId) { + return state; + } + + return { + isSyncing: false, + lastSyncError: null, + lastSyncFailedAt: null, + lastSyncedAt: null, + lastSyncUserId: null, + }; + }), hasHydrated: false, isSyncing: false, lastSyncError: null, diff --git a/supabase/config.toml b/supabase/config.toml index 6aaf431..b63d660 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -6,3 +6,6 @@ verify_jwt = false [functions.generate-insight-report] verify_jwt = false + +[functions.delete-account] +verify_jwt = false diff --git a/supabase/functions/delete-account/deno.json b/supabase/functions/delete-account/deno.json new file mode 100644 index 0000000..b522d19 --- /dev/null +++ b/supabase/functions/delete-account/deno.json @@ -0,0 +1,5 @@ +{ + "imports": { + "@supabase/supabase-js": "npm:@supabase/supabase-js@2.108.1" + } +} diff --git a/supabase/functions/delete-account/index.ts b/supabase/functions/delete-account/index.ts new file mode 100644 index 0000000..d07926c --- /dev/null +++ b/supabase/functions/delete-account/index.ts @@ -0,0 +1,459 @@ +import { createClient } from "@supabase/supabase-js"; + +type AccountDeletionFailureCode = + | "unauthenticated" + | "verification_required" + | "remote_data_deletion_failed" + | "auth_account_deletion_failed" + | "unknown"; + +type DeleteAccountFailureResponse = { + code: AccountDeletionFailureCode; + remoteDataDeleted?: boolean; + requestId: string; + retryable: boolean; + success: false; +}; + +type JwtClaims = { + sub: string; +}; + +const corsHeaders = { + "Access-Control-Allow-Headers": + "authorization, x-client-info, apikey, content-type", + "Access-Control-Allow-Methods": "POST, OPTIONS", + "Access-Control-Allow-Origin": "*", +}; + +const expectedConfirmationPhrase = "DELETE"; +const userOwnedStorageBuckets: string[] = []; + +Deno.serve(async (request) => { + const requestId = crypto.randomUUID(); + + if (request.method === "OPTIONS") { + return new Response("ok", { headers: corsHeaders }); + } + + if (request.method !== "POST") { + return jsonResponse( + { + code: "unknown", + requestId, + retryable: false, + success: false, + }, + 405, + ); + } + + const authorization = request.headers.get("Authorization")?.trim(); + const bearerToken = authorization ? getBearerToken(authorization) : null; + + if (!bearerToken || !authorization) { + return failureResponse( + { + code: "unauthenticated", + requestId, + retryable: false, + success: false, + }, + 401, + ); + } + + const claims = parseJwtClaims(bearerToken); + + if (!claims) { + return failureResponse( + { + code: "unauthenticated", + requestId, + retryable: false, + success: false, + }, + 401, + ); + } + + const parsedRequest = await parseRequest(request); + + if ( + !parsedRequest.ok || + parsedRequest.confirmationPhrase !== expectedConfirmationPhrase + ) { + return failureResponse( + { + code: "verification_required", + requestId, + retryable: false, + success: false, + }, + 400, + ); + } + + const env = getRequiredEnvironment(); + + if (!env.ok) { + console.error("delete-account configuration_missing", { + requestId, + stage: "configuration", + }); + + return failureResponse( + { + code: "unknown", + requestId, + retryable: true, + success: false, + }, + 500, + ); + } + + const userClient = createClient(env.supabaseUrl, env.supabaseAnonKey, { + auth: { + autoRefreshToken: false, + persistSession: false, + }, + global: { + headers: { + Authorization: authorization, + }, + }, + }); + const authProbe = await userClient + .from("profiles") + .select("id") + .eq("id", claims.sub) + .limit(1); + + if (authProbe.error) { + console.info("delete-account token_rejected", { + code: authProbe.error.code, + requestId, + stage: "authentication", + }); + + return failureResponse( + { + code: "unauthenticated", + requestId, + retryable: false, + success: false, + }, + 401, + ); + } + + const adminClient = createClient(env.supabaseUrl, env.supabaseServiceRoleKey, { + auth: { + autoRefreshToken: false, + persistSession: false, + }, + }); + + const storageResult = await deleteUserStorageObjects(adminClient, claims.sub); + + if (!storageResult.ok) { + console.error("delete-account storage_cleanup_failed", { + requestId, + stage: "storage", + }); + + return failureResponse( + { + code: "remote_data_deletion_failed", + requestId, + retryable: true, + success: false, + }, + 500, + ); + } + + const databaseResult = await adminClient.rpc("delete_deardiary_user_data", { + target_user_id: claims.sub, + }); + + if (databaseResult.error) { + console.error("delete-account database_cleanup_failed", { + code: databaseResult.error.code, + requestId, + stage: "database", + }); + + return failureResponse( + { + code: "remote_data_deletion_failed", + requestId, + retryable: true, + success: false, + }, + 500, + ); + } + + const clerkResult = await deleteClerkUser({ + clerkSecretKey: env.clerkSecretKey, + requestId, + userId: claims.sub, + }); + + if (!clerkResult.ok) { + return failureResponse( + { + code: "auth_account_deletion_failed", + remoteDataDeleted: true, + requestId, + retryable: true, + success: false, + }, + 502, + ); + } + + console.info("delete-account completed", { + requestId, + stage: "completed", + }); + + return jsonResponse( + { + requestId, + success: true, + }, + 200, + ); +}); + +async function parseRequest( + request: Request, +): Promise< + | { confirmationPhrase: string; ok: true } + | { ok: false } +> { + try { + const body: unknown = await request.json(); + + if (!isRecord(body) || typeof body.confirmationPhrase !== "string") { + return { ok: false }; + } + + return { + confirmationPhrase: body.confirmationPhrase, + ok: true, + }; + } catch { + return { ok: false }; + } +} + +function getRequiredEnvironment(): + | { + clerkSecretKey: string; + ok: true; + supabaseAnonKey: string; + supabaseServiceRoleKey: string; + supabaseUrl: string; + } + | { ok: false } { + const supabaseUrl = Deno.env.get("SUPABASE_URL")?.trim(); + const supabaseAnonKey = + Deno.env.get("SUPABASE_ANON_KEY")?.trim() ?? + Deno.env.get("SUPABASE_PUBLISHABLE_KEY")?.trim(); + const supabaseServiceRoleKey = + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")?.trim() ?? + Deno.env.get("SUPABASE_SECRET_KEY")?.trim() ?? + getDefaultSecretKey(); + const clerkSecretKey = Deno.env.get("CLERK_SECRET_KEY")?.trim(); + + if ( + !supabaseUrl || + !supabaseAnonKey || + !supabaseServiceRoleKey || + !clerkSecretKey + ) { + return { ok: false }; + } + + return { + clerkSecretKey, + ok: true, + supabaseAnonKey, + supabaseServiceRoleKey, + supabaseUrl, + }; +} + +function getDefaultSecretKey() { + const rawSecretKeys = Deno.env.get("SUPABASE_SECRET_KEYS"); + + if (!rawSecretKeys) { + return null; + } + + try { + const secretKeys: unknown = JSON.parse(rawSecretKeys); + + if (!isRecord(secretKeys) || typeof secretKeys.default !== "string") { + return null; + } + + return secretKeys.default.trim() || null; + } catch { + return null; + } +} + +async function deleteUserStorageObjects( + client: ReturnType, + userId: string, +) { + for (const bucket of userOwnedStorageBuckets) { + const result = await deleteStoragePrefix(client, bucket, userId, ""); + + if (!result.ok) { + return result; + } + } + + return { ok: true }; +} + +async function deleteStoragePrefix( + client: ReturnType, + bucket: string, + userId: string, + path: string, +): Promise<{ ok: true } | { ok: false }> { + const prefix = path ? `${userId}/${path}` : userId; + const listResult = await client.storage.from(bucket).list(prefix); + + if (listResult.error) { + return { ok: false }; + } + + const filesToRemove: string[] = []; + + for (const item of listResult.data ?? []) { + const itemPath = path ? `${path}/${item.name}` : item.name; + + if (item.id === null) { + const nestedResult = await deleteStoragePrefix( + client, + bucket, + userId, + itemPath, + ); + + if (!nestedResult.ok) { + return nestedResult; + } + + continue; + } + + filesToRemove.push(`${userId}/${itemPath}`); + } + + if (filesToRemove.length === 0) { + return { ok: true }; + } + + const removeResult = await client.storage.from(bucket).remove(filesToRemove); + + return removeResult.error ? { ok: false } : { ok: true }; +} + +async function deleteClerkUser({ + clerkSecretKey, + requestId, + userId, +}: { + clerkSecretKey: string; + requestId: string; + userId: string; +}) { + const response = await fetch( + `https://api.clerk.com/v1/users/${encodeURIComponent(userId)}`, + { + headers: { + Authorization: `Bearer ${clerkSecretKey}`, + }, + method: "DELETE", + }, + ); + + if (response.ok || response.status === 404) { + return { ok: true }; + } + + console.error("delete-account clerk_cleanup_failed", { + requestId, + stage: "auth_account", + status: response.status, + }); + + return { ok: false }; +} + +function getBearerToken(authorization: string) { + const match = authorization.match(/^Bearer\s+(.+)$/i); + const token = match?.[1]?.trim(); + + return token || null; +} + +function parseJwtClaims(token: string): JwtClaims | null { + const [, payload] = token.split("."); + + if (!payload) { + return null; + } + + try { + const normalizedPayload = payload.replace(/-/g, "+").replace(/_/g, "/"); + const paddedPayload = normalizedPayload.padEnd( + normalizedPayload.length + ((4 - (normalizedPayload.length % 4)) % 4), + "=", + ); + const claims: unknown = JSON.parse(atob(paddedPayload)); + + if (!isRecord(claims) || typeof claims.sub !== "string") { + return null; + } + + return { + sub: claims.sub, + }; + } catch { + return null; + } +} + +function failureResponse(body: DeleteAccountFailureResponse, status: number) { + console.info("delete-account failed", { + code: body.code, + requestId: body.requestId, + stage: "failed", + }); + + return jsonResponse(body, status); +} + +function jsonResponse(body: unknown, status = 200) { + return Response.json(body, { + headers: { + ...corsHeaders, + "Content-Type": "application/json", + }, + status, + }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} diff --git a/supabase/migrations/20260621104500_add_delete_deardiary_user_data.sql b/supabase/migrations/20260621104500_add_delete_deardiary_user_data.sql new file mode 100644 index 0000000..0a5d06f --- /dev/null +++ b/supabase/migrations/20260621104500_add_delete_deardiary_user_data.sql @@ -0,0 +1,73 @@ +create or replace function public.delete_deardiary_user_data(target_user_id text) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + deleted_achievement_states integer := 0; + deleted_ai_insights integer := 0; + deleted_entry_ai_reflections integer := 0; + deleted_journal_entries integer := 0; + deleted_profiles integer := 0; +begin + if target_user_id is null or length(trim(target_user_id)) = 0 then + raise exception 'target_user_id is required' using errcode = '22023'; + end if; + + if to_regclass('public.achievement_states') is not null then + execute 'delete from public.achievement_states where user_id = $1' + using target_user_id; + get diagnostics deleted_achievement_states = row_count; + end if; + + if to_regclass('public.entry_ai_reflections') is not null then + execute 'delete from public.entry_ai_reflections where user_id = $1' + using target_user_id; + get diagnostics deleted_entry_ai_reflections = row_count; + end if; + + if to_regclass('public.ai_insights') is not null then + execute 'delete from public.ai_insights where user_id = $1' + using target_user_id; + get diagnostics deleted_ai_insights = row_count; + end if; + + if to_regclass('public.journal_entries') is not null then + execute 'delete from public.journal_entries where user_id = $1' + using target_user_id; + get diagnostics deleted_journal_entries = row_count; + end if; + + if to_regclass('public.profiles') is not null then + if exists ( + select 1 + from information_schema.columns + where table_schema = 'public' + and table_name = 'profiles' + and column_name = 'user_id' + ) then + execute 'delete from public.profiles where id = $1 or user_id = $1' + using target_user_id; + else + execute 'delete from public.profiles where id = $1' + using target_user_id; + end if; + + get diagnostics deleted_profiles = row_count; + end if; + + return jsonb_build_object( + 'deletedAchievementStates', deleted_achievement_states, + 'deletedAIInsights', deleted_ai_insights, + 'deletedEntryAIReflections', deleted_entry_ai_reflections, + 'deletedJournalEntries', deleted_journal_entries, + 'deletedProfiles', deleted_profiles + ); +end; +$$; + +revoke execute on function public.delete_deardiary_user_data(text) from public; +revoke execute on function public.delete_deardiary_user_data(text) from anon; +revoke execute on function public.delete_deardiary_user_data(text) from authenticated; +grant execute on function public.delete_deardiary_user_data(text) to service_role; diff --git a/types/accountDeletion.ts b/types/accountDeletion.ts new file mode 100644 index 0000000..7aa972c --- /dev/null +++ b/types/accountDeletion.ts @@ -0,0 +1,33 @@ +export type AccountDeletionStage = + | "idle" + | "verifying" + | "deleting_remote_data" + | "deleting_auth_account" + | "clearing_local_data" + | "completed" + | "failed"; + +export type AccountDeletionFailureCode = + | "unauthenticated" + | "verification_required" + | "remote_data_deletion_failed" + | "auth_account_deletion_failed" + | "local_cleanup_failed" + | "network_unavailable" + | "already_in_progress" + | "unknown"; + +export type AccountDeletionResult = + | { + requestId: string; + success: true; + } + | { + code: AccountDeletionFailureCode; + remoteDataDeleted?: boolean; + requestId?: string; + retryable: boolean; + success: false; + }; + +export type DeleteAccountFunctionResponse = AccountDeletionResult;