diff --git a/apps/mobile/app/(tabs)/profile.tsx b/apps/mobile/app/(tabs)/profile.tsx index 0d05f5874..22838a3d5 100644 --- a/apps/mobile/app/(tabs)/profile.tsx +++ b/apps/mobile/app/(tabs)/profile.tsx @@ -8,8 +8,10 @@ import { StyleSheet, ScrollView, Modal, + Share, type TextInput, } from 'react-native' +import { File, Paths } from 'expo-file-system' import AsyncStorage from '@react-native-async-storage/async-storage' import { useLocalSearchParams, useRouter, type Href } from 'expo-router' import { SafeAreaView } from 'react-native-safe-area-context' @@ -17,6 +19,7 @@ import { useTranslation } from 'react-i18next' import { useQueryClient } from '@tanstack/react-query' import { parseISO } from 'date-fns' import { API } from '@orbit/shared/api' +import type { UserDataExport } from '@orbit/shared' import { profileKeys } from '@orbit/shared/query' import { buildFreshStartDeletedItems, @@ -201,6 +204,34 @@ export default function ProfileScreen() { } } + const [isExporting, setIsExporting] = useState(false) + const [exportError, setExportError] = useState('') + + async function handleExportData() { + if (isExporting) return + if (!isOnline) { + setExportError(t('calendarSync.notConnected')) + return + } + setIsExporting(true) + setExportError('') + try { + const data = await apiClient(API.profile.export) + const fileName = `orbit-data-export-${new Date().toISOString().slice(0, 10)}.json` + const file = new File(Paths.cache, fileName) + file.create({ overwrite: true }) + file.write(JSON.stringify(data, null, 2)) + await Share.share({ + title: t('dataExport.shareTitle'), + url: file.uri, + }) + } catch (err: unknown) { + setExportError(getErrorMessage(err, t('dataExport.error'))) + } finally { + setIsExporting(false) + } + } + const [showTourReplay, setShowTourReplay] = useState(false) const [showDeleteModal, setShowDeleteModal] = useState(false) const [deleteStep, setDeleteStep] = useState<'confirm' | 'code' | 'deactivated'>('confirm') @@ -502,6 +533,17 @@ export default function ProfileScreen() { {t('profile.sections.accountActions')} + { + void handleExportData() + }} + label={isExporting ? t('dataExport.preparing') : t('dataExport.button')} + /> + {exportError ? ( + + {exportError} + + ) : null} logout()} label={t('profile.logout')} diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index 4de5bffbe..80001b0e4 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -278,6 +278,7 @@ function RootLayoutNav() { options={{ animation: 'slide_from_right' }} /> + diff --git a/apps/mobile/app/chat.styles.ts b/apps/mobile/app/chat.styles.ts index 7dce07ef4..f10bfe094 100644 --- a/apps/mobile/app/chat.styles.ts +++ b/apps/mobile/app/chat.styles.ts @@ -60,6 +60,14 @@ export function createStyles(tokens: Tokens) { letterSpacing: -0.2, textAlign: "center", }, + aiDisclaimer: { + fontFamily: "Geist", + fontSize: 11, + lineHeight: 15, + fontStyle: "italic", + textAlign: "center", + maxWidth: 300, + }, messageList: { paddingVertical: 16, }, diff --git a/apps/mobile/app/login.tsx b/apps/mobile/app/login.tsx index a7c810f5f..16e47b535 100644 --- a/apps/mobile/app/login.tsx +++ b/apps/mobile/app/login.tsx @@ -353,7 +353,7 @@ export default function LoginScreen() { } function openTerms() { - Linking.openURL('https://app.useorbit.org/about') + Linking.openURL('https://app.useorbit.org/terms') } const canSubmitEmail = Boolean(email.trim()) && !isSubmitting && isOnline diff --git a/apps/mobile/app/privacy.tsx b/apps/mobile/app/privacy.tsx index 7614ddc34..eb229e0a2 100644 --- a/apps/mobile/app/privacy.tsx +++ b/apps/mobile/app/privacy.tsx @@ -28,6 +28,13 @@ export default function PrivacyScreen() { 'openai', 'resend', ] as const + const retentionKeys = [ + 'account', + 'sessions', + 'ai', + 'afterDeletion', + ] as const + const googleScopesKeys = ['auth', 'calendar', 'control'] as const return ( + {t('privacy.controller.title')} + + + {t('privacy.controller.body')} + + + {t('privacy.dataCollected.title')} {dataCollectedKeys.map((key) => ( @@ -92,6 +106,58 @@ export default function PrivacyScreen() { ))} + {t('privacy.retention.title')} + + + {t('privacy.retention.intro')} + + {retentionKeys.map((key) => ( + + {`• ${t(`privacy.retention.${key}`)}`} + + ))} + + + {t('privacy.googleScopes.title')} + + + {t('privacy.googleScopes.intro')} + + {googleScopesKeys.map((key) => ( + + {`• ${t(`privacy.googleScopes.${key}`)}`} + + ))} + + + {t('privacy.dataResidency.title')} + + + {t('privacy.dataResidency.body')} + + + + {t('privacy.automatedProcessing.title')} + + + {t('privacy.automatedProcessing.body')} + + + + {t('privacy.minors.title')} + + + {t('privacy.minors.body')} + + + + {t('privacy.export.title')} + + + {t('privacy.export.body')} + + + {t('privacy.noSell.title')} diff --git a/apps/mobile/app/retrospective.tsx b/apps/mobile/app/retrospective.tsx index 1f5667076..893d2e243 100644 --- a/apps/mobile/app/retrospective.tsx +++ b/apps/mobile/app/retrospective.tsx @@ -442,6 +442,9 @@ export default function RetrospectiveScreen() { text={displayedRetrospective} tokens={tokens} /> + + {t('aiDisclosure.notMedicalAdvice')} + {displayedFromCache ? ( createTokensV2(currentScheme, currentTheme), + [currentScheme, currentTheme], + ) + const isAuthenticated = useAuthStore((state) => state.isAuthenticated) + + const subscriptionKeys = ['intro', 'autoRenew', 'cancel', 'refunds'] as const + + return ( + + goBackOrFallback(isAuthenticated ? '/' : '/login')} + title={t('terms.title')} + subtitle={t('terms.lastUpdated')} + backLabel={t('common.goBack')} + /> + + {t('terms.intro.title')} + + + {t('terms.intro.body')} + + + + {t('terms.provider.title')} + + + {t('terms.provider.body')} + + + + {t('terms.eligibility.title')} + + + {t('terms.eligibility.body')} + + + + {t('terms.license.title')} + + + {t('terms.license.body')} + + + + {t('terms.subscription.title')} + + {subscriptionKeys.map((key) => ( + + {t(`terms.subscription.${key}`)} + + ))} + + + {t('terms.ai.title')} + + + {t('terms.ai.body')} + + + + {t('terms.noMedicalAdvice.title')} + + + {t('terms.noMedicalAdvice.body')} + + + + {t('terms.warranty.title')} + + + {t('terms.warranty.body')} + + + + {t('terms.liability.title')} + + + {t('terms.liability.body')} + + + + {t('terms.termination.title')} + + + {t('terms.termination.body')} + + + + {t('terms.governingLaw.title')} + + + {t('terms.governingLaw.body')} + + + + {t('terms.changes.title')} + + + {t('terms.changes.body')} + + + + {t('terms.contact.title')} + + + {t('terms.contact.body')} + + + + + + + ) +} + +const styles = StyleSheet.create({ + safeArea: { flex: 1 }, + container: { flex: 1 }, + scrollContent: { paddingBottom: 40 }, + bodyBlock: { + paddingHorizontal: 20, + paddingBottom: 18, + gap: 6, + }, + bodyText: { + fontFamily: 'Geist', + fontSize: 14, + lineHeight: 22, + }, +}) diff --git a/apps/mobile/components/chat/chat-empty-state.tsx b/apps/mobile/components/chat/chat-empty-state.tsx index d61e4fd66..63fd6f1e8 100644 --- a/apps/mobile/components/chat/chat-empty-state.tsx +++ b/apps/mobile/components/chat/chat-empty-state.tsx @@ -22,6 +22,9 @@ export const ChatEmptyState = forwardRef>( {t("chat.suggestion.prompt")} + + {t("aiDisclosure.notMedicalAdvice")} + ); }, diff --git a/apps/mobile/components/habits/today-ai-summary.tsx b/apps/mobile/components/habits/today-ai-summary.tsx index b3f3a4b67..467e36ea5 100644 --- a/apps/mobile/components/habits/today-ai-summary.tsx +++ b/apps/mobile/components/habits/today-ai-summary.tsx @@ -28,7 +28,7 @@ export function TodayAISummary({ date }: Readonly) { const { profile } = useProfile() const { currentScheme, currentTheme } = useAppTheme() const tokens = createTokensV2(currentScheme, currentTheme) - const styles = useMemo(() => createStyles(tokens.fg1, tokens.fg2, tokens.primary), [tokens.fg1, tokens.fg2, tokens.primary]) + const styles = useMemo(() => createStyles(tokens.fg1, tokens.fg2, tokens.fg3, tokens.primary, tokens.hairline), [tokens.fg1, tokens.fg2, tokens.fg3, tokens.primary, tokens.hairline]) const hasProAccess = profile?.hasProAccess ?? false const aiSummaryEnabled = profile?.aiSummaryEnabled ?? false @@ -77,6 +77,9 @@ export function TodayAISummary({ date }: Readonly) { const resolved = body() if (!resolved) return null + const showDisclaimer = + hasProAccess && aiSummaryEnabled && !isLoading && !error && !!summary + return ( ) { strokeWidth={1.5} /> Astra + {t('aiDisclosure.isAiLabel')} {resolved.text} + {showDisclaimer ? ( + + {t('aiDisclosure.notMedicalAdvice')} + + ) : null} ) } -function createStyles(fg1: string, fg2: string, primary: string) { +function createStyles( + fg1: string, + fg2: string, + fg3: string, + primary: string, + hairline: string, +) { return StyleSheet.create({ wrap: { paddingHorizontal: 20, @@ -144,11 +159,31 @@ function createStyles(fg1: string, fg2: string, primary: string) { color: fg1, letterSpacing: -0.2, }, + aiBadge: { + fontFamily: 'GeistMono', + fontSize: 10, + fontWeight: '600', + letterSpacing: 0.6, + color: fg3, + borderWidth: StyleSheet.hairlineWidth, + borderColor: hairline, + borderRadius: 4, + paddingHorizontal: 5, + paddingVertical: 1, + overflow: 'hidden', + }, message: { fontFamily: 'Geist', fontSize: 14, lineHeight: 20, color: fg2, }, + disclaimer: { + fontFamily: 'Geist', + fontSize: 11, + lineHeight: 15, + color: fg3, + fontStyle: 'italic', + }, }) } diff --git a/apps/mobile/test-mocks/expo-file-system.ts b/apps/mobile/test-mocks/expo-file-system.ts new file mode 100644 index 000000000..1ca80199d --- /dev/null +++ b/apps/mobile/test-mocks/expo-file-system.ts @@ -0,0 +1,18 @@ +export class File { + readonly uri: string + + constructor(...segments: Array<{ uri: string } | string>) { + this.uri = segments + .map((segment) => (typeof segment === 'string' ? segment : segment.uri)) + .join('/') + } + + create() {} + + write() {} +} + +export const Paths = { + cache: { uri: 'file:///cache' }, + document: { uri: 'file:///document' }, +} diff --git a/apps/mobile/vitest.config.ts b/apps/mobile/vitest.config.ts index 1032ec065..f9a9fab29 100644 --- a/apps/mobile/vitest.config.ts +++ b/apps/mobile/vitest.config.ts @@ -83,6 +83,10 @@ export default defineConfig({ find: 'expo-store-review', replacement: path.resolve(__dirname, './test-mocks/expo-store-review.ts'), }, + { + find: 'expo-file-system', + replacement: path.resolve(__dirname, './test-mocks/expo-file-system.ts'), + }, { find: '@react-native-community/datetimepicker', replacement: path.resolve(__dirname, './test-mocks/react-native-datetimepicker.tsx'), diff --git a/apps/web/__tests__/proxy.test.ts b/apps/web/__tests__/proxy.test.ts index 1a2331034..f704ae12e 100644 --- a/apps/web/__tests__/proxy.test.ts +++ b/apps/web/__tests__/proxy.test.ts @@ -48,10 +48,12 @@ describe('proxy', () => { vi.mocked(setSessionCookies).mockReset() }) - it('allows public paths without resolving a session', async () => { - const response = await proxy(createRequest('/privacy')) + it('allows public legal pages without resolving a session', async () => { + for (const path of ['/terms', '/privacy', '/delete-account']) { + const response = await proxy(createRequest(path)) - expect(response).toMatchObject({ type: 'next' }) + expect(response).toMatchObject({ type: 'next' }) + } expect(resolveSessionTokens).not.toHaveBeenCalled() }) diff --git a/apps/web/app/(app)/profile/page.tsx b/apps/web/app/(app)/profile/page.tsx index 6cf2f38f6..9c740d0c9 100644 --- a/apps/web/app/(app)/profile/page.tsx +++ b/apps/web/app/(app)/profile/page.tsx @@ -31,6 +31,7 @@ import { DeleteAccountModal } from './_components/delete-account-modal' import { ProfileNavIcon } from './_components/profile-nav-icon' import { ProfileActionButton } from './_components/profile-action-button' import { TourReplayModal } from './_components/tour-replay-modal' +import { exportUserData } from '@/app/actions/profile' export default function ProfilePage() { const t = useTranslations() @@ -78,6 +79,30 @@ export default function ProfilePage() { const [showResetModal, setShowResetModal] = useState(false) const [showDeleteModal, setShowDeleteModal] = useState(false) const [showTourReplay, setShowTourReplay] = useState(false) + const [isExporting, setIsExporting] = useState(false) + const [exportError, setExportError] = useState(null) + + async function handleExportData() { + if (isExporting) return + setIsExporting(true) + setExportError(null) + try { + const data = await exportUserData() + const blob = new Blob([JSON.stringify(data, null, 2)], { + type: 'application/json', + }) + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = `orbit-data-export-${new Date().toISOString().slice(0, 10)}.json` + anchor.click() + URL.revokeObjectURL(url) + } catch { + setExportError(t('dataExport.error')) + } finally { + setIsExporting(false) + } + } function handleNavClick(item: ProfileNavItem) { if (shouldRedirectProfileNavItem(item, profile)) { @@ -299,6 +324,24 @@ export default function ProfilePage() { {t('profile.sections.accountActions')} + { + void handleExportData() + }} + label={isExporting ? t('dataExport.preparing') : t('dataExport.button')} + /> + {exportError && ( +

+ {exportError} +

+ )} logout()} label={t('profile.logout')} diff --git a/apps/web/app/(app)/retrospective/page.tsx b/apps/web/app/(app)/retrospective/page.tsx index 0349a889e..cc464591f 100644 --- a/apps/web/app/(app)/retrospective/page.tsx +++ b/apps/web/app/(app)/retrospective/page.tsx @@ -304,6 +304,18 @@ export default function RetrospectivePage() { className="[&_strong]:block [&_strong]:mt-4 [&_strong]:font-semibold [&_strong]:text-[var(--fg-1)] [&_strong:first-child]:mt-0" dangerouslySetInnerHTML={{ __html: renderMarkdown(retrospective) }} /> +

+ {t('aiDisclosure.notMedicalAdvice')} +

{fromCache && (

{t('auth.legalPrefix')}{' '} - + {t('auth.terms')} {' '} {t('auth.legalConjunction')}{' '} diff --git a/apps/web/app/(chat)/chat/page.tsx b/apps/web/app/(chat)/chat/page.tsx index ee8da44db..09e7802b0 100644 --- a/apps/web/app/(chat)/chat/page.tsx +++ b/apps/web/app/(chat)/chat/page.tsx @@ -201,6 +201,20 @@ export default function ChatPage() { {t('chat.suggestion.prompt')} sendMessage(s)} /> +

+ {t('aiDisclosure.notMedicalAdvice')} +
)} diff --git a/apps/web/app/(public)/delete-account/page.tsx b/apps/web/app/(public)/delete-account/page.tsx new file mode 100644 index 000000000..5614a4306 --- /dev/null +++ b/apps/web/app/(public)/delete-account/page.tsx @@ -0,0 +1,82 @@ +'use client' + +import { useTranslations } from 'next-intl' +import { AppBar } from '@/components/ui/app-bar' +import { SectionLabel } from '@/components/ui/section-label' +import { useGoBackOrFallback } from '@/hooks/use-go-back-or-fallback' +import { useAuthStore } from '@/stores/auth-store' + +export default function DeleteAccountPage() { + const t = useTranslations() + const goBackOrFallback = useGoBackOrFallback() + const isAuthenticated = useAuthStore((state) => state.isAuthenticated) + + const sections: { label: string; body: string }[] = [ + { label: t('deleteAccount.intro.title'), body: t('deleteAccount.intro.body') }, + { label: t('deleteAccount.inApp.title'), body: [ + t('deleteAccount.inApp.intro'), + t('deleteAccount.inApp.step1'), + t('deleteAccount.inApp.step2'), + t('deleteAccount.inApp.step3'), + t('deleteAccount.inApp.step4'), + ].join(' ') }, + { label: t('deleteAccount.grace.title'), body: t('deleteAccount.grace.body') }, + { label: t('deleteAccount.data.title'), body: t('deleteAccount.data.body') }, + ] + + return ( +
+ goBackOrFallback(isAuthenticated ? '/' : '/login')} + title={t('deleteAccount.title')} + /> +
+ {sections.map(({ label, body }) => ( +
+ {label} +
+ {body} +
+
+ ))} + + {t('deleteAccount.webFallback.title')} +
+ {t('deleteAccount.webFallback.body')} +
+ +
+
+ ) +} diff --git a/apps/web/app/(public)/privacy/page.tsx b/apps/web/app/(public)/privacy/page.tsx index a5323edef..bd00fa72d 100644 --- a/apps/web/app/(public)/privacy/page.tsx +++ b/apps/web/app/(public)/privacy/page.tsx @@ -13,6 +13,7 @@ export default function PrivacyPage() { const sections: { label: string; body: string }[] = [ { label: t('privacy.intro.title'), body: t('privacy.intro.body') }, + { label: t('privacy.controller.title'), body: t('privacy.controller.body') }, { label: t('privacy.dataCollected.title'), body: [ t('privacy.dataCollected.account'), t('privacy.dataCollected.habits'), @@ -32,6 +33,23 @@ export default function PrivacyPage() { t('privacy.thirdParty.openai'), t('privacy.thirdParty.resend'), ].join(' ') }, + { label: t('privacy.retention.title'), body: [ + t('privacy.retention.intro'), + t('privacy.retention.account'), + t('privacy.retention.sessions'), + t('privacy.retention.ai'), + t('privacy.retention.afterDeletion'), + ].join(' ') }, + { label: t('privacy.googleScopes.title'), body: [ + t('privacy.googleScopes.intro'), + t('privacy.googleScopes.auth'), + t('privacy.googleScopes.calendar'), + t('privacy.googleScopes.control'), + ].join(' ') }, + { label: t('privacy.dataResidency.title'), body: t('privacy.dataResidency.body') }, + { label: t('privacy.automatedProcessing.title'), body: t('privacy.automatedProcessing.body') }, + { label: t('privacy.minors.title'), body: t('privacy.minors.body') }, + { label: t('privacy.export.title'), body: t('privacy.export.body') }, { label: t('privacy.noSell.title'), body: t('privacy.noSell.body') }, { label: t('privacy.security.title'), body: t('privacy.security.body') }, { label: t('privacy.deletion.title'), body: [ diff --git a/apps/web/app/(public)/terms/page.tsx b/apps/web/app/(public)/terms/page.tsx new file mode 100644 index 000000000..ebd1c27a3 --- /dev/null +++ b/apps/web/app/(public)/terms/page.tsx @@ -0,0 +1,64 @@ +'use client' + +import { useTranslations } from 'next-intl' +import { AppBar } from '@/components/ui/app-bar' +import { SectionLabel } from '@/components/ui/section-label' +import { useGoBackOrFallback } from '@/hooks/use-go-back-or-fallback' +import { useAuthStore } from '@/stores/auth-store' + +export default function TermsPage() { + const t = useTranslations() + const goBackOrFallback = useGoBackOrFallback() + const isAuthenticated = useAuthStore((state) => state.isAuthenticated) + + const sections: { label: string; body: string }[] = [ + { label: t('terms.intro.title'), body: t('terms.intro.body') }, + { label: t('terms.provider.title'), body: t('terms.provider.body') }, + { label: t('terms.eligibility.title'), body: t('terms.eligibility.body') }, + { label: t('terms.license.title'), body: t('terms.license.body') }, + { label: t('terms.subscription.title'), body: [ + t('terms.subscription.intro'), + t('terms.subscription.autoRenew'), + t('terms.subscription.cancel'), + t('terms.subscription.refunds'), + ].join(' ') }, + { label: t('terms.ai.title'), body: t('terms.ai.body') }, + { label: t('terms.noMedicalAdvice.title'), body: t('terms.noMedicalAdvice.body') }, + { label: t('terms.warranty.title'), body: t('terms.warranty.body') }, + { label: t('terms.liability.title'), body: t('terms.liability.body') }, + { label: t('terms.termination.title'), body: t('terms.termination.body') }, + { label: t('terms.governingLaw.title'), body: t('terms.governingLaw.body') }, + { label: t('terms.changes.title'), body: t('terms.changes.body') }, + { label: t('terms.contact.title'), body: t('terms.contact.body') }, + ] + + return ( +
+ goBackOrFallback(isAuthenticated ? '/' : '/login')} + title={t('terms.title')} + subtitle={t('terms.lastUpdated')} + /> +
+ {sections.map(({ label, body }) => ( +
+ {label} +
+ {body} +
+
+ ))} +
+
+ ) +} diff --git a/apps/web/app/actions/profile.ts b/apps/web/app/actions/profile.ts index 79112975d..d535b5817 100644 --- a/apps/web/app/actions/profile.ts +++ b/apps/web/app/actions/profile.ts @@ -8,6 +8,7 @@ import type { SetWeekStartDayRequest, SetThemePreferenceRequest, SetColorSchemeRequest, + UserDataExport, } from '@orbit/shared' import { serverAuthFetch } from '@/lib/server-fetch' @@ -84,6 +85,12 @@ export async function resetAccount(): Promise { }) } +export async function exportUserData(): Promise { + return serverAuthFetch('/api/profile/export', { + method: 'GET', + }) +} + export async function dismissCalendarImport(): Promise { await serverAuthFetch('/api/calendar/dismiss', { method: 'PUT', diff --git a/apps/web/components/habits/today-ai-summary.tsx b/apps/web/components/habits/today-ai-summary.tsx index 83adf5bc8..f1079274b 100644 --- a/apps/web/components/habits/today-ai-summary.tsx +++ b/apps/web/components/habits/today-ai-summary.tsx @@ -75,6 +75,9 @@ export function TodayAISummary({ date }: Readonly) { const resolved = resolveBody() if (!resolved) return null + const showDisclaimer = + hasProAccess && aiSummaryEnabled && !isLoading && !error && !!summary + return ( diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index 790603e48..9b0ecad5b 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -12,7 +12,9 @@ const PUBLIC_PATHS = [ '/login', '/auth-callback', '/r/', + '/terms', '/privacy', + '/delete-account', '/.well-known', '/app-ads.txt', ] diff --git a/packages/shared/src/__tests__/data-export.test.ts b/packages/shared/src/__tests__/data-export.test.ts new file mode 100644 index 000000000..11227a8e0 --- /dev/null +++ b/packages/shared/src/__tests__/data-export.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest' +import { userDataExportSchema } from '../types/data-export' + +const validExport = { + exportedAtUtc: '2026-06-04T10:00:00Z', + account: { + name: 'Ada', + email: 'ada@example.com', + createdAtUtc: '2026-01-01T00:00:00Z', + plan: 'pro', + }, + settings: { + timeZone: 'America/Sao_Paulo', + language: 'en', + weekStartDay: 1, + themePreference: 'dark', + colorScheme: 'blue', + aiMemoryEnabled: true, + aiSummaryEnabled: false, + }, + habits: [ + { + id: '11111111-1111-1111-1111-111111111111', + title: 'Meditate', + description: null, + emoji: null, + isBadHabit: false, + isGeneral: false, + dueDate: '2026-06-04', + endDate: null, + frequencyUnit: 'Day', + frequencyQuantity: 1, + days: ['Monday'], + checklistItems: [{ text: 'Breathe', isChecked: false }], + createdAtUtc: '2026-01-02T00:00:00Z', + logs: [ + { + date: '2026-06-03', + value: 1, + note: null, + createdAtUtc: '2026-06-03T08:00:00Z', + }, + ], + }, + ], + goals: [], + tags: [], + facts: [], +} + +describe('userDataExportSchema', () => { + it('parses a full export payload', () => { + const result = userDataExportSchema.parse(validExport) + expect(result.account.email).toBe('ada@example.com') + expect(result.habits[0]?.logs[0]?.value).toBe(1) + }) + + it('rejects a payload missing required top-level collections', () => { + const { habits: _habits, ...withoutHabits } = validExport + const result = userDataExportSchema.safeParse(withoutHabits) + expect(result.success).toBe(false) + }) +}) diff --git a/packages/shared/src/api/endpoints.ts b/packages/shared/src/api/endpoints.ts index 4ac517390..671e00da9 100644 --- a/packages/shared/src/api/endpoints.ts +++ b/packages/shared/src/api/endpoints.ts @@ -21,6 +21,7 @@ export const API = { themePreference: '/api/profile/theme-preference', colorScheme: '/api/profile/color-scheme', reset: '/api/profile/reset', + export: '/api/profile/export', }, habits: { diff --git a/packages/shared/src/i18n/en.json b/packages/shared/src/i18n/en.json index 47fc80c6f..2c75ca4f0 100644 --- a/packages/shared/src/i18n/en.json +++ b/packages/shared/src/i18n/en.json @@ -195,7 +195,7 @@ "refresh": "Refresh", "login": "Log in", "codeDigit": "Digit {n} of 6", - "legalPrefix": "By signing in you agree to the", + "legalPrefix": "By signing in you confirm you are at least 13 years old and agree to the", "legalConjunction": "and", "terms": "Terms", "privacy": "Privacy", @@ -852,10 +852,14 @@ }, "privacy": { "title": "Privacy Policy", - "lastUpdated": "Last updated: April 2026", + "lastUpdated": "Last updated: June 2026", "intro": { "title": "Introduction", - "body": "Orbit is a personal habit tracking app. We take your privacy seriously and are committed to protecting your personal data. This policy explains what data we store, how we use it, and your rights." + "body": "Orbit is a personal habit tracking app. We take your privacy seriously and are committed to protecting your personal data. This policy explains what data we store, how we use it, and your rights under the LGPD (Lei Geral de Proteção de Dados) and equivalent data-protection laws." + }, + "controller": { + "title": "Who Controls Your Data", + "body": "The data controller is TL SOFTWARE ENGINEERING LTDA (CNPJ 58.429.979/0001-06), the company that operates Orbit. For privacy requests or questions, contact our data protection lead at contact@useorbit.org." }, "dataCollected": { "title": "Data We Store", @@ -887,6 +891,37 @@ "title": "Data Security", "body": "All connections to Orbit use HTTPS encryption. Authentication uses rotating refresh sessions. Payment information is handled entirely by Stripe and never touches our servers." }, + "retention": { + "title": "How Long We Keep Your Data", + "intro": "We retain your data only as long as needed to provide the service:", + "account": "Account data: kept for as long as your account is active.", + "sessions": "Authentication sessions and push tokens: kept while active and removed on logout, token expiry, or device unsubscribe.", + "ai": "AI chat history and memory facts: kept while AI features are enabled; deleting your account or clearing AI memory removes them.", + "afterDeletion": "After account deletion or the 7-day grace period, your personal data is permanently erased except where the law requires us to retain limited records." + }, + "googleScopes": { + "title": "Google Account & Calendar Access", + "intro": "If you connect Google, we request only the scopes needed for the features you use:", + "auth": "Basic profile and email, to create and identify your account.", + "calendar": "Read access to your Google Calendar events, used solely to show events alongside your habits and to suggest imports.", + "control": "We never expose raw Google event payloads to the AI, and you can disconnect Google at any time, which deletes the stored access and refresh tokens." + }, + "dataResidency": { + "title": "Where Your Data Is Stored", + "body": "Orbit's database and application servers are hosted with Supabase and Render. Some subprocessors (such as OpenAI, Stripe, and Google) may process data outside Brazil. International transfers rely on adequacy decisions or standard contractual clauses, consistent with LGPD Chapter V." + }, + "automatedProcessing": { + "title": "AI & Automated Processing", + "body": "Orbit's AI assistant, Astra, processes your habit and chat data to generate summaries, retrospectives, and suggestions. This is assistive only and does not make legally significant decisions about you. You can disable AI memory and AI summaries at any time in AI Settings, and you may object to automated processing by contacting us." + }, + "minors": { + "title": "Age Requirement", + "body": "Orbit is not directed to children. You must be at least 13 years old to create an account. If we learn that we have collected data from a child under 13 without appropriate consent, we will delete it." + }, + "export": { + "title": "Your Right to Export Your Data", + "body": "You can download a copy of all your Orbit data (habits, logs, goals, tags, AI memory facts, and settings) as a JSON file from the Profile page, in line with your data-portability rights under LGPD Art. 18." + }, "deletion": { "title": "Data Deletion", "body": "You can delete your account and all associated data at any time from the Profile page in the app. The process requires email confirmation:", @@ -1990,5 +2025,103 @@ "description": "Earn XP and level up as you build habits. Unlock achievements for milestones like streak records, completion rates, and more." } } + }, + "terms": { + "title": "Terms of Service", + "lastUpdated": "Last updated: June 2026", + "intro": { + "title": "Agreement", + "body": "These Terms of Service govern your use of Orbit, a personal habit tracking app. By creating an account or using Orbit, you agree to these terms. If you do not agree, do not use the app." + }, + "provider": { + "title": "Who Provides Orbit", + "body": "Orbit is provided by TL SOFTWARE ENGINEERING LTDA (CNPJ 58.429.979/0001-06). Throughout these terms, \"we\", \"us\", and \"Orbit\" refer to this company." + }, + "eligibility": { + "title": "Eligibility", + "body": "You must be at least 13 years old to use Orbit. By using the app you represent that you meet this age requirement and that the information you provide is accurate." + }, + "license": { + "title": "License & Acceptable Use", + "body": "We grant you a personal, non-transferable, revocable license to use Orbit for tracking your own habits. You agree not to misuse the service, reverse engineer it, disrupt it, attempt unauthorized access, or use it to violate any law or the rights of others." + }, + "subscription": { + "title": "Subscriptions & Billing", + "intro": "Orbit offers a free tier and a paid Pro subscription billed monthly or yearly through Stripe.", + "autoRenew": "Paid subscriptions renew automatically at the end of each billing period at the then-current price, unless you cancel before the renewal date.", + "cancel": "You can cancel at any time from your subscription settings; cancellation is as easy as signing up and takes effect at the end of the current paid period.", + "refunds": "Except where required by applicable consumer-protection law, payments are non-refundable for partial periods. We will honor refund rights granted by Brazilian consumer law (CDC)." + }, + "ai": { + "title": "AI Features", + "body": "Orbit includes an AI assistant (Astra) powered by third-party providers such as OpenAI. AI output is generated automatically, may be inaccurate or incomplete, and is provided for assistance only. You are responsible for how you act on AI suggestions." + }, + "noMedicalAdvice": { + "title": "Not Medical or Professional Advice", + "body": "Orbit and its AI features are for informational and self-organization purposes only. Orbit is not a medical device and does not provide medical, psychological, legal, financial, or other professional advice. Always consult a qualified professional for such matters and never disregard professional advice because of something in the app." + }, + "warranty": { + "title": "Warranty Disclaimer", + "body": "Orbit is provided \"as is\" and \"as available\" without warranties of any kind, express or implied, to the maximum extent permitted by law. We do not warrant that the service will be uninterrupted, error-free, or that data will never be lost." + }, + "liability": { + "title": "Limitation of Liability", + "body": "To the maximum extent permitted by applicable law, we are not liable for indirect, incidental, or consequential damages arising from your use of Orbit. Nothing in these terms limits rights that cannot be limited under Brazilian consumer law." + }, + "termination": { + "title": "Termination", + "body": "You may stop using Orbit and delete your account at any time. We may suspend or terminate access if you violate these terms or use the service in a way that harms Orbit or other users." + }, + "governingLaw": { + "title": "Governing Law", + "body": "These terms are governed by the laws of the Federative Republic of Brazil, without regard to conflict-of-law rules. Disputes are subject to the courts of Brazil, subject to any mandatory consumer-protection venue rules." + }, + "changes": { + "title": "Changes to These Terms", + "body": "We may update these terms from time to time. Material changes will be communicated in the app or by email. Continued use after changes take effect constitutes acceptance of the updated terms." + }, + "contact": { + "title": "Contact", + "body": "Questions about these terms? Contact us at contact@useorbit.org." + } + }, + "deleteAccount": { + "title": "Delete Your Account", + "intro": { + "title": "How to Delete Your Orbit Account", + "body": "You can permanently delete your Orbit account and all associated data at any time. You do not need to be signed in on this page to learn how — deletion is available directly inside the app and by email request." + }, + "inApp": { + "title": "Delete From Inside the App", + "intro": "The fastest way to delete your account is from the app:", + "step1": "1. Open Orbit and go to the Profile tab.", + "step2": "2. Tap \"Delete account\".", + "step3": "3. We email you a 6-digit confirmation code.", + "step4": "4. Enter the code to confirm. Your account is deactivated immediately." + }, + "grace": { + "title": "7-Day Grace Period", + "body": "After you confirm deletion, your account is deactivated right away and permanently deleted after a 7-day grace period. Signing in again within those 7 days reactivates your account and cancels the deletion." + }, + "webFallback": { + "title": "Request Deletion by Email", + "body": "If you can no longer access the app, email contact@useorbit.org from the address associated with your account and ask us to delete it. We will verify your identity and process the request.", + "button": "Email a deletion request" + }, + "data": { + "title": "What Gets Deleted", + "body": "Deletion removes your habits, completion history, goals, tags, AI conversations and memory facts, settings, and account profile. This action is irreversible once the grace period ends." + } + }, + "dataExport": { + "button": "Download my data", + "preparing": "Preparing your export...", + "error": "We couldn't prepare your export. Please try again.", + "shareTitle": "Orbit data export" + }, + "aiDisclosure": { + "isAiLabel": "AI", + "isAiTooltip": "Astra is an AI assistant. Responses are generated automatically.", + "notMedicalAdvice": "Astra is AI and can be wrong. This is not medical or professional advice." } } diff --git a/packages/shared/src/i18n/pt-BR.json b/packages/shared/src/i18n/pt-BR.json index ec0de493e..296790115 100644 --- a/packages/shared/src/i18n/pt-BR.json +++ b/packages/shared/src/i18n/pt-BR.json @@ -195,7 +195,7 @@ "refresh": "Atualizar", "login": "Entrar", "codeDigit": "Dígito {n} de 6", - "legalPrefix": "Ao entrar, você concorda com os", + "legalPrefix": "Ao entrar, você confirma ter pelo menos 13 anos e concorda com os", "legalConjunction": "e a", "terms": "Termos", "privacy": "Privacidade", @@ -852,10 +852,14 @@ }, "privacy": { "title": "Política de Privacidade", - "lastUpdated": "Última atualização: Abril de 2026", + "lastUpdated": "Última atualização: Junho de 2026", "intro": { "title": "Introdução", - "body": "O Orbit é um aplicativo de rastreamento de hábitos pessoais. Levamos sua privacidade a sério e estamos comprometidos em proteger seus dados pessoais. Esta política explica quais dados armazenamos, como os usamos e seus direitos." + "body": "O Orbit é um aplicativo de rastreamento de hábitos pessoais. Levamos sua privacidade a sério e estamos comprometidos em proteger seus dados pessoais. Esta política explica quais dados armazenamos, como os usamos e seus direitos sob a LGPD (Lei Geral de Proteção de Dados) e leis equivalentes de proteção de dados." + }, + "controller": { + "title": "Quem Controla Seus Dados", + "body": "O controlador dos dados é TL SOFTWARE ENGINEERING LTDA (CNPJ 58.429.979/0001-06), a empresa que opera o Orbit. Para solicitações ou dúvidas sobre privacidade, fale com nosso responsável pela proteção de dados em contact@useorbit.org." }, "dataCollected": { "title": "Dados que Armazenamos", @@ -887,6 +891,37 @@ "title": "Segurança dos Dados", "body": "Todas as conexões com o Orbit usam criptografia HTTPS. A autenticação usa sessões de atualização rotativas. Informações de pagamento são tratadas inteiramente pelo Stripe e nunca passam por nossos servidores." }, + "retention": { + "title": "Por Quanto Tempo Guardamos Seus Dados", + "intro": "Mantemos seus dados apenas pelo tempo necessário para fornecer o serviço:", + "account": "Dados da conta: mantidos enquanto sua conta estiver ativa.", + "sessions": "Sessões de autenticação e tokens de push: mantidos enquanto ativos e removidos ao sair, na expiração do token ou ao cancelar a inscrição do dispositivo.", + "ai": "Histórico de chat com IA e fatos de memória: mantidos enquanto os recursos de IA estiverem ativos; excluir sua conta ou limpar a memória da IA os remove.", + "afterDeletion": "Após a exclusão da conta ou o período de carência de 7 dias, seus dados pessoais são apagados permanentemente, exceto quando a lei exige a retenção de registros limitados." + }, + "googleScopes": { + "title": "Acesso à Conta e ao Google Agenda", + "intro": "Se você conectar o Google, solicitamos apenas os escopos necessários para os recursos que você usa:", + "auth": "Perfil básico e e-mail, para criar e identificar sua conta.", + "calendar": "Acesso de leitura aos eventos do seu Google Agenda, usado apenas para exibir eventos junto aos seus hábitos e sugerir importações.", + "control": "Nunca expomos os dados brutos dos eventos do Google à IA, e você pode desconectar o Google a qualquer momento, o que exclui os tokens de acesso e de atualização armazenados." + }, + "dataResidency": { + "title": "Onde Seus Dados São Armazenados", + "body": "O banco de dados e os servidores do Orbit são hospedados na Supabase e na Render. Alguns suboperadores (como OpenAI, Stripe e Google) podem processar dados fora do Brasil. As transferências internacionais se baseiam em decisões de adequação ou cláusulas contratuais padrão, conforme o Capítulo V da LGPD." + }, + "automatedProcessing": { + "title": "IA e Processamento Automatizado", + "body": "O assistente de IA do Orbit, a Astra, processa seus dados de hábitos e de chat para gerar resumos, retrospectivas e sugestões. Isso é apenas assistivo e não toma decisões juridicamente significativas sobre você. Você pode desativar a memória da IA e os resumos da IA a qualquer momento nas Configurações de IA, e pode se opor ao processamento automatizado entrando em contato conosco." + }, + "minors": { + "title": "Requisito de Idade", + "body": "O Orbit não é direcionado a crianças. Você deve ter pelo menos 13 anos para criar uma conta. Se soubermos que coletamos dados de uma criança menor de 13 anos sem o consentimento apropriado, iremos excluí-los." + }, + "export": { + "title": "Seu Direito de Exportar Seus Dados", + "body": "Você pode baixar uma cópia de todos os seus dados do Orbit (hábitos, registros, metas, tags, fatos de memória da IA e configurações) como um arquivo JSON na página de Perfil, de acordo com seu direito à portabilidade de dados previsto no Art. 18 da LGPD." + }, "deletion": { "title": "Exclusão de Dados", "body": "Você pode excluir sua conta e todos os dados associados a qualquer momento pela página de Perfil no app. O processo requer confirmação por e-mail:", @@ -1990,5 +2025,103 @@ "description": "Ganhe XP e suba de nível conforme constrói hábitos. Desbloqueie conquistas para marcos como recordes de sequência, taxas de conclusão e mais." } } + }, + "terms": { + "title": "Termos de Serviço", + "lastUpdated": "Última atualização: Junho de 2026", + "intro": { + "title": "Acordo", + "body": "Estes Termos de Serviço regem o seu uso do Orbit, um aplicativo de rastreamento de hábitos pessoais. Ao criar uma conta ou usar o Orbit, você concorda com estes termos. Se não concordar, não use o aplicativo." + }, + "provider": { + "title": "Quem Fornece o Orbit", + "body": "O Orbit é fornecido por TL SOFTWARE ENGINEERING LTDA (CNPJ 58.429.979/0001-06). Ao longo destes termos, \"nós\" e \"Orbit\" se referem a esta empresa." + }, + "eligibility": { + "title": "Elegibilidade", + "body": "Você deve ter pelo menos 13 anos para usar o Orbit. Ao usar o aplicativo, você declara que atende a esse requisito de idade e que as informações fornecidas são verdadeiras." + }, + "license": { + "title": "Licença e Uso Aceitável", + "body": "Concedemos a você uma licença pessoal, intransferível e revogável para usar o Orbit no rastreamento dos seus próprios hábitos. Você concorda em não usar o serviço de forma indevida, fazer engenharia reversa, interrompê-lo, tentar acesso não autorizado ou usá-lo para violar qualquer lei ou os direitos de terceiros." + }, + "subscription": { + "title": "Assinaturas e Cobrança", + "intro": "O Orbit oferece um plano gratuito e uma assinatura Pro paga, cobrada mensal ou anualmente via Stripe.", + "autoRenew": "As assinaturas pagas renovam automaticamente ao final de cada período de cobrança, pelo preço vigente, a menos que você cancele antes da data de renovação.", + "cancel": "Você pode cancelar a qualquer momento nas configurações de assinatura; cancelar é tão fácil quanto assinar e passa a valer ao final do período pago atual.", + "refunds": "Exceto quando exigido pela legislação de proteção ao consumidor aplicável, os pagamentos não são reembolsáveis por períodos parciais. Respeitaremos os direitos de reembolso garantidos pelo Código de Defesa do Consumidor (CDC)." + }, + "ai": { + "title": "Recursos de IA", + "body": "O Orbit inclui um assistente de IA (Astra) baseado em provedores terceiros como a OpenAI. As respostas da IA são geradas automaticamente, podem ser imprecisas ou incompletas e são fornecidas apenas como auxílio. Você é responsável por como age com base nas sugestões da IA." + }, + "noMedicalAdvice": { + "title": "Não É Aconselhamento Médico ou Profissional", + "body": "O Orbit e seus recursos de IA têm finalidade apenas informativa e de auto-organização. O Orbit não é um dispositivo médico e não fornece aconselhamento médico, psicológico, jurídico, financeiro ou outro aconselhamento profissional. Sempre consulte um profissional qualificado para essas questões e nunca ignore o aconselhamento profissional por causa de algo no aplicativo." + }, + "warranty": { + "title": "Isenção de Garantias", + "body": "O Orbit é fornecido \"como está\" e \"conforme disponível\", sem garantias de qualquer tipo, expressas ou implícitas, na máxima extensão permitida por lei. Não garantimos que o serviço será ininterrupto, livre de erros, nem que os dados nunca serão perdidos." + }, + "liability": { + "title": "Limitação de Responsabilidade", + "body": "Na máxima extensão permitida pela lei aplicável, não somos responsáveis por danos indiretos, incidentais ou consequenciais decorrentes do seu uso do Orbit. Nada nestes termos limita direitos que não podem ser limitados pela legislação de defesa do consumidor brasileira." + }, + "termination": { + "title": "Rescisão", + "body": "Você pode parar de usar o Orbit e excluir sua conta a qualquer momento. Podemos suspender ou encerrar o acesso se você violar estes termos ou usar o serviço de forma que prejudique o Orbit ou outros usuários." + }, + "governingLaw": { + "title": "Legislação Aplicável", + "body": "Estes termos são regidos pelas leis da República Federativa do Brasil, sem considerar regras de conflito de leis. As controvérsias estão sujeitas aos tribunais do Brasil, observadas as regras obrigatórias de foro de defesa do consumidor." + }, + "changes": { + "title": "Alterações nestes Termos", + "body": "Podemos atualizar estes termos periodicamente. Alterações relevantes serão comunicadas no aplicativo ou por e-mail. O uso contínuo após a entrada em vigor das alterações constitui aceitação dos termos atualizados." + }, + "contact": { + "title": "Contato", + "body": "Dúvidas sobre estes termos? Entre em contato pelo contact@useorbit.org." + } + }, + "deleteAccount": { + "title": "Excluir Sua Conta", + "intro": { + "title": "Como Excluir Sua Conta do Orbit", + "body": "Você pode excluir permanentemente sua conta do Orbit e todos os dados associados a qualquer momento. Não é preciso estar conectado nesta página para saber como — a exclusão está disponível diretamente no aplicativo e por solicitação via e-mail." + }, + "inApp": { + "title": "Excluir Dentro do Aplicativo", + "intro": "A forma mais rápida de excluir sua conta é pelo aplicativo:", + "step1": "1. Abra o Orbit e vá até a aba Perfil.", + "step2": "2. Toque em \"Excluir conta\".", + "step3": "3. Enviamos um código de confirmação de 6 dígitos por e-mail.", + "step4": "4. Digite o código para confirmar. Sua conta é desativada imediatamente." + }, + "grace": { + "title": "Período de Carência de 7 Dias", + "body": "Após confirmar a exclusão, sua conta é desativada na hora e excluída permanentemente após um período de carência de 7 dias. Entrar novamente nesses 7 dias reativa sua conta e cancela a exclusão." + }, + "webFallback": { + "title": "Solicitar Exclusão por E-mail", + "body": "Se você não conseguir mais acessar o aplicativo, envie um e-mail para contact@useorbit.org a partir do endereço associado à sua conta e peça a exclusão. Verificaremos sua identidade e processaremos a solicitação.", + "button": "Enviar solicitação de exclusão" + }, + "data": { + "title": "O Que É Excluído", + "body": "A exclusão remove seus hábitos, histórico de conclusão, metas, tags, conversas e fatos de memória da IA, configurações e o perfil da conta. Esta ação é irreversível após o fim do período de carência." + } + }, + "dataExport": { + "button": "Baixar meus dados", + "preparing": "Preparando sua exportação...", + "error": "Não foi possível preparar sua exportação. Tente novamente.", + "shareTitle": "Exportação de dados do Orbit" + }, + "aiDisclosure": { + "isAiLabel": "IA", + "isAiTooltip": "A Astra é um assistente de IA. As respostas são geradas automaticamente.", + "notMedicalAdvice": "A Astra é IA e pode errar. Isto não é aconselhamento médico ou profissional." } } diff --git a/packages/shared/src/types/data-export.ts b/packages/shared/src/types/data-export.ts new file mode 100644 index 000000000..99ff45597 --- /dev/null +++ b/packages/shared/src/types/data-export.ts @@ -0,0 +1,94 @@ +import { z } from 'zod' + +const exportedAccountSchema = z.object({ + name: z.string(), + email: z.string(), + createdAtUtc: z.string(), + plan: z.string(), +}) + +const exportedSettingsSchema = z.object({ + timeZone: z.string().nullable(), + language: z.string().nullable(), + weekStartDay: z.number(), + themePreference: z.string().nullable(), + colorScheme: z.string().nullable(), + aiMemoryEnabled: z.boolean(), + aiSummaryEnabled: z.boolean(), +}) + +const exportedHabitLogSchema = z.object({ + date: z.string(), + value: z.number(), + note: z.string().nullable(), + createdAtUtc: z.string(), +}) + +const exportedChecklistItemSchema = z.object({ + text: z.string(), + isChecked: z.boolean(), +}) + +const exportedHabitSchema = z.object({ + id: z.string(), + title: z.string(), + description: z.string().nullable(), + emoji: z.string().nullable(), + isBadHabit: z.boolean(), + isGeneral: z.boolean(), + dueDate: z.string(), + endDate: z.string().nullable(), + frequencyUnit: z.string().nullable(), + frequencyQuantity: z.number().nullable(), + days: z.array(z.string()), + checklistItems: z.array(exportedChecklistItemSchema), + createdAtUtc: z.string(), + logs: z.array(exportedHabitLogSchema), +}) + +const exportedGoalProgressLogSchema = z.object({ + value: z.number(), + previousValue: z.number(), + note: z.string().nullable(), + createdAtUtc: z.string(), +}) + +const exportedGoalSchema = z.object({ + id: z.string(), + title: z.string(), + description: z.string().nullable(), + targetValue: z.number(), + currentValue: z.number(), + unit: z.string(), + status: z.string(), + type: z.string(), + deadline: z.string().nullable(), + createdAtUtc: z.string(), + completedAtUtc: z.string().nullable(), + progressLogs: z.array(exportedGoalProgressLogSchema), +}) + +const exportedTagSchema = z.object({ + id: z.string(), + name: z.string(), + color: z.string(), + createdAtUtc: z.string(), +}) + +const exportedUserFactSchema = z.object({ + factText: z.string(), + category: z.string().nullable(), + extractedAtUtc: z.string(), +}) + +export const userDataExportSchema = z.object({ + exportedAtUtc: z.string(), + account: exportedAccountSchema, + settings: exportedSettingsSchema, + habits: z.array(exportedHabitSchema), + goals: z.array(exportedGoalSchema), + tags: z.array(exportedTagSchema), + facts: z.array(exportedUserFactSchema), +}) + +export type UserDataExport = z.infer diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index e1c03cdaf..3629db109 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -16,3 +16,4 @@ export * from './goal' export * from './chat' export * from './ai' export * from './tour' +export * from './data-export'