From 494ed17fe2433657cb0ea66fb375e74588458380 Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Sat, 17 Jan 2026 16:38:47 +0000 Subject: [PATCH] Add streaks, scheduled releases, and notification settings - Couple streaks with milestone notifications - Scheduled pack releases with admin UI - Notification preferences screen - Refactored profile settings screens - Partner activity notifications --- .../src/components/content/PackFormDialog.tsx | 57 +- apps/admin/src/pages/content/PacksPage.tsx | 41 +- apps/admin/src/pages/users/UserDetailPage.tsx | 75 ++- apps/mobile/app/(app)/_layout.tsx | 34 +- apps/mobile/app/(app)/settings/_layout.tsx | 12 + apps/mobile/app/(app)/settings/account.tsx | 3 + .../app/(app)/settings/notifications.tsx | 3 + apps/mobile/app/(app)/settings/partner.tsx | 3 + .../mobile/app/(app)/settings/preferences.tsx | 3 + apps/mobile/app/(app)/settings/profile.tsx | 3 + .../app/(app)/settings/subscription.tsx | 3 + apps/mobile/app/(app)/settings/support.tsx | 3 + .../stores/subscriptionStore.test.ts | 10 +- apps/mobile/src/components/StreakDisplay.tsx | 193 ++++++ .../mobile/src/components/paywall/Paywall.tsx | 76 ++- .../src/features/profile/ProfileScreen.tsx | 393 ++++++------ .../features/profile/components/MenuItem.tsx | 58 +- .../components/NotificationSettings.tsx | 125 +++- .../profile/components/PrivacySettings.tsx | 186 +++++- .../components/RelationshipDangerZone.tsx | 584 ++++++++++++++++++ .../profile/components/ScreenHeader.tsx | 80 +++ .../src/features/profile/components/index.ts | 3 + .../profile/screens/AccountScreen.tsx | 60 ++ .../profile/screens/NotificationsScreen.tsx | 44 ++ .../profile/screens/PartnerScreen.tsx | 66 ++ .../profile/screens/PreferencesScreen.tsx | 51 ++ .../profile/screens/ProfileSettingsScreen.tsx | 437 +++++++++++++ .../profile/screens/SubscriptionScreen.tsx | 70 +++ .../profile/screens/SupportScreen.tsx | 68 ++ .../src/features/profile/screens/index.ts | 8 + apps/mobile/src/lib/analytics.ts | 57 +- apps/mobile/src/lib/badge.ts | 57 ++ apps/mobile/src/store/authStore.ts | 44 +- apps/mobile/src/store/index.ts | 4 + .../src/store/notificationPreferencesStore.ts | 95 +++ apps/mobile/src/store/streakStore.ts | 60 ++ apps/mobile/src/store/subscriptionStore.ts | 87 +-- .../check-streak-milestones/index.ts | 206 ++++++ .../process-scheduled-releases/index.ts | 170 +++++ .../index.ts | 199 ++++++ .../20251230000000_v1_initial_schema.sql | 106 +--- ...000000_fix_chat_media_storage_policies.sql | 15 +- .../20260101000000_create_ai_config_table.sql | 8 - ...101000001_add_council_generators_array.sql | 3 - ...20260101000002_create_app_config_table.sql | 9 - ...dd_answer_gap_to_recommended_questions.sql | 1 - .../20260101100000_add_video_support.sql | 20 +- .../20260101110000_fix_admin_rls_policies.sql | 5 - ...0101113000_add_feedback_permission_rls.sql | 1 - ...00_add_redemption_codes_permission_rls.sql | 137 +++- ...1180200_create_feature_interests_table.sql | 48 +- ...000002_add_feature_interest_counts_rpc.sql | 1 - ...102100000_add_e2ee_columns_to_messages.sql | 5 - ...60102100001_add_public_key_to_profiles.sql | 2 - ...0260102100002_create_master_keys_table.sql | 6 - ...x_content_or_media_constraint_for_e2ee.sql | 5 +- .../20260102140000_add_content_moderation.sql | 4 - .../20260102150000_add_message_category.sql | 2 - .../20260103000000_add_message_deletion.sql | 30 +- .../20260107000000_add_message_reports.sql | 37 +- ...000001_add_message_reports_profiles_fk.sql | 2 +- .../20260108000000_add_intensity_levels.sql | 7 - ...000_filter_questions_by_user_intensity.sql | 1 - ...00000_add_admin_username_to_audit_logs.sql | 2 - .../20260109000001_create_dares_feature.sql | 36 -- .../20260109100000_add_match_archives.sql | 23 +- ...20260110000000_add_category_visibility.sql | 6 - ...date_recommended_questions_daily_limit.sql | 1 - ...0260110000003_add_daily_response_limit.sql | 3 - ...000000_add_required_props_to_questions.sql | 1 - ...0260111000001_randomize_question_order.sql | 5 +- ...20260111000002_fix_answer_gap_deadlock.sql | 11 +- ...0111100000_add_inverse_of_to_questions.sql | 31 +- ...60111120000_add_fk_indexes_drop_unused.sql | 71 ++- ...12100000_add_pack_change_notifications.sql | 26 +- ...418_add_get_profile_with_auth_info_rpc.sql | 41 ++ ...0116183023_add_gift_premium_rls_policy.sql | 7 + ...184058_add_admin_gift_premium_function.sql | 58 ++ ...117000000_add_notification_preferences.sql | 74 +++ ...001_add_partner_activity_notifications.sql | 110 ++++ ...0117000002_add_scheduled_pack_releases.sql | 25 + .../20260117000003_add_couple_streaks.sql | 197 ++++++ 82 files changed, 4189 insertions(+), 725 deletions(-) create mode 100644 apps/mobile/app/(app)/settings/_layout.tsx create mode 100644 apps/mobile/app/(app)/settings/account.tsx create mode 100644 apps/mobile/app/(app)/settings/notifications.tsx create mode 100644 apps/mobile/app/(app)/settings/partner.tsx create mode 100644 apps/mobile/app/(app)/settings/preferences.tsx create mode 100644 apps/mobile/app/(app)/settings/profile.tsx create mode 100644 apps/mobile/app/(app)/settings/subscription.tsx create mode 100644 apps/mobile/app/(app)/settings/support.tsx create mode 100644 apps/mobile/src/components/StreakDisplay.tsx create mode 100644 apps/mobile/src/features/profile/components/RelationshipDangerZone.tsx create mode 100644 apps/mobile/src/features/profile/components/ScreenHeader.tsx create mode 100644 apps/mobile/src/features/profile/screens/AccountScreen.tsx create mode 100644 apps/mobile/src/features/profile/screens/NotificationsScreen.tsx create mode 100644 apps/mobile/src/features/profile/screens/PartnerScreen.tsx create mode 100644 apps/mobile/src/features/profile/screens/PreferencesScreen.tsx create mode 100644 apps/mobile/src/features/profile/screens/ProfileSettingsScreen.tsx create mode 100644 apps/mobile/src/features/profile/screens/SubscriptionScreen.tsx create mode 100644 apps/mobile/src/features/profile/screens/SupportScreen.tsx create mode 100644 apps/mobile/src/features/profile/screens/index.ts create mode 100644 apps/mobile/src/lib/badge.ts create mode 100644 apps/mobile/src/store/notificationPreferencesStore.ts create mode 100644 apps/mobile/src/store/streakStore.ts create mode 100644 apps/supabase/functions/check-streak-milestones/index.ts create mode 100644 apps/supabase/functions/process-scheduled-releases/index.ts create mode 100644 apps/supabase/functions/send-partner-activity-notification/index.ts create mode 100644 apps/supabase/migrations/20260116145418_add_get_profile_with_auth_info_rpc.sql create mode 100644 apps/supabase/migrations/20260116183023_add_gift_premium_rls_policy.sql create mode 100644 apps/supabase/migrations/20260116184058_add_admin_gift_premium_function.sql create mode 100644 apps/supabase/migrations/20260117000000_add_notification_preferences.sql create mode 100644 apps/supabase/migrations/20260117000001_add_partner_activity_notifications.sql create mode 100644 apps/supabase/migrations/20260117000002_add_scheduled_pack_releases.sql create mode 100644 apps/supabase/migrations/20260117000003_add_couple_streaks.sql diff --git a/apps/admin/src/components/content/PackFormDialog.tsx b/apps/admin/src/components/content/PackFormDialog.tsx index a10aa2a..3ffa055 100644 --- a/apps/admin/src/components/content/PackFormDialog.tsx +++ b/apps/admin/src/components/content/PackFormDialog.tsx @@ -45,6 +45,8 @@ export interface PackFormData { is_public: boolean; is_explicit: boolean; category_id: string; + scheduled_release_at?: string | null; // Optional for backwards compatibility + skip_notification?: boolean; // If true, sets release_notified=true to skip notifications } export interface PackFormDialogProps { @@ -200,10 +202,63 @@ export function PackFormDialog({ setField('is_public', checked)} + onCheckedChange={(checked) => { + setField('is_public', checked); + // Clear scheduled release if making public immediately + if (checked) { + setField('scheduled_release_at', null); + } + }} /> + {/* Scheduled Release - only show when not public */} + {!formData.is_public && ( +
+
+ +

+ Automatically make this pack public at a specific time. Users will be notified when it becomes available. +

+ { + const value = e.target.value; + setField('scheduled_release_at', value ? new Date(value).toISOString() : null); + }} + className="w-full" + /> + {formData.scheduled_release_at && ( + + )} +
+ + {/* Skip Notification Toggle */} +
+
+ +

+ Release without notifying users +

+
+ setField('skip_notification', checked)} + /> +
+
+ )} + {/* Explicit Toggle */}
diff --git a/apps/admin/src/pages/content/PacksPage.tsx b/apps/admin/src/pages/content/PacksPage.tsx index 2594b7e..bf95280 100644 --- a/apps/admin/src/pages/content/PacksPage.tsx +++ b/apps/admin/src/pages/content/PacksPage.tsx @@ -8,7 +8,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; import { PaginationControls } from '@/components/ui/pagination'; -import { Plus, MessageCircle, Pencil, Trash2, Sparkles, Crown, Eye, EyeOff, Flame, Heart, Tags, ChevronUp, ChevronDown } from 'lucide-react'; +import { Plus, MessageCircle, Pencil, Trash2, Sparkles, Crown, Eye, EyeOff, Flame, Heart, Tags, ChevronUp, ChevronDown, Clock, BellOff } from 'lucide-react'; import { toast } from 'sonner'; import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog'; import { ExtractTopicsDialog } from '@/components/content/ExtractTopicsDialog'; @@ -41,6 +41,8 @@ interface QuestionPack { sort_order: number | null; category_id: string | null; created_at: string | null; + scheduled_release_at?: string | null; // Optional for backwards compatibility + release_notified?: boolean; // True if notifications have been sent or should be skipped question_count?: number; topics?: Topic[]; } @@ -76,6 +78,8 @@ export function PacksPage() { is_public: false, is_explicit: false, category_id: categoryId ?? '', + scheduled_release_at: null, + skip_notification: false, }, (pack) => ({ name: pack.name, @@ -85,6 +89,8 @@ export function PacksPage() { is_public: pack.is_public, is_explicit: pack.is_explicit, category_id: pack.category_id || '', + scheduled_release_at: pack.scheduled_release_at, + skip_notification: false, // Always start unchecked when editing }) ); @@ -223,6 +229,13 @@ export function PacksPage() { let packId: string; if (form.editingItem) { + // Determine release_notified value + // If skip_notification is checked, set release_notified=true to prevent notifications + // Otherwise, reset to false when scheduling a new release + const releaseNotifiedValue = form.formData.skip_notification + ? true + : (form.formData.scheduled_release_at ? false : undefined); + const { error } = await auditedSupabase.update('question_packs', form.editingItem.id, { name: form.formData.name, description: form.formData.description || null, @@ -231,6 +244,12 @@ export function PacksPage() { is_public: form.formData.is_public, is_explicit: form.formData.is_explicit, category_id: form.formData.category_id || null, + // Only include scheduled_release_at if the field exists in form data (backwards compatible) + ...('scheduled_release_at' in form.formData && { + scheduled_release_at: form.formData.scheduled_release_at, + // Set release_notified based on skip_notification toggle + ...(releaseNotifiedValue !== undefined && { release_notified: releaseNotifiedValue }), + }), }); if (error) throw error; packId = form.editingItem.id; @@ -246,6 +265,12 @@ export function PacksPage() { is_explicit: form.formData.is_explicit, category_id: form.formData.category_id || null, sort_order: totalCount, + // Only include scheduled_release_at if the field exists in form data (backwards compatible) + ...('scheduled_release_at' in form.formData && { + scheduled_release_at: form.formData.scheduled_release_at, + // If skip_notification is checked, set release_notified=true + ...(form.formData.skip_notification && { release_notified: true }), + }), }) .select('id') .single(); @@ -305,6 +330,8 @@ export function PacksPage() { is_public: false, is_explicit: false, category_id: categoryId ?? '', + scheduled_release_at: null, + skip_notification: false, }); setAiDialogOpen(false); setSelectedTopicIds(new Set()); @@ -497,6 +524,18 @@ export function PacksPage() { Published + ) : pack.scheduled_release_at ? ( + <> + + + Scheduled + + {pack.release_notified && ( + + + + )} + ) : ( diff --git a/apps/admin/src/pages/users/UserDetailPage.tsx b/apps/admin/src/pages/users/UserDetailPage.tsx index a6c21e5..9db04be 100644 --- a/apps/admin/src/pages/users/UserDetailPage.tsx +++ b/apps/admin/src/pages/users/UserDetailPage.tsx @@ -38,7 +38,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle, Package, Heart, Sparkles } from 'lucide-react'; +import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle, Package, Heart, Sparkles, Flame, Trophy, Calendar } from 'lucide-react'; import { format } from 'date-fns'; import { IconPreview } from '@/components/ui/icon-picker'; @@ -142,6 +142,17 @@ interface FeatureInterest { created_at: string; } +interface CoupleStreak { + id: string; + couple_id: string; + current_streak: number; + longest_streak: number; + last_active_date: string | null; + last_milestone_celebrated: number | null; + created_at: string; + updated_at: string; +} + const answerIcons = { yes: , no: , @@ -277,6 +288,7 @@ export function UserDetailPage() { const [mediaMessages, setMediaMessages] = useState([]); const [enabledPacks, setEnabledPacks] = useState([]); const [featureInterests, setFeatureInterests] = useState([]); + const [streak, setStreak] = useState(null); const [responsesPage, setResponsesPage] = useState(1); const [responsesPageSize, setResponsesPageSize] = useState(10); const [responsesTotal, setResponsesTotal] = useState(0); @@ -357,8 +369,17 @@ export function UserDetailPage() { if (profileData?.couple_id) { const { data: partnerData } = await supabase.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).single(); setPartner(partnerData); + + // Fetch streak data + const { data: streakData } = await supabase + .from('couple_streaks') + .select('*') + .eq('couple_id', profileData.couple_id) + .maybeSingle(); + setStreak(streakData); } else { setPartner(null); + setStreak(null); } const responsesFrom = (responsesPage - 1) * responsesPageSize; @@ -534,6 +555,58 @@ export function UserDetailPage() {
{profile.show_explicit_content ? : }

Explicit Content

{profile.show_explicit_content === null ? 'Not set' : profile.show_explicit_content ? 'Enabled' : 'Disabled'}

Onboarding

{profile.onboarding_completed ? 'Completed' : 'Not completed'}

+ {/* Streak Info */} + {profile.couple_id && streak && ( + + + + + Couple Streak + + Activity streak for this couple + + +
+
+
+ +
+
+

Current Streak

+

{streak.current_streak} day{streak.current_streak !== 1 ? 's' : ''}

+
+
+
+
+ +
+
+

Longest Streak

+

{streak.longest_streak} day{streak.longest_streak !== 1 ? 's' : ''}

+
+
+
+
+ +
+
+

Last Active

+

{streak.last_active_date ? format(new Date(streak.last_active_date), 'MMM d, yyyy') : 'Never'}

+
+
+
+
+ +
+
+

Last Milestone

+

{streak.last_milestone_celebrated ? `${streak.last_milestone_celebrated} days` : 'None yet'}

+
+
+
+
+
+ )} {/* Feature Opt-Ins */} {featureInterests.length > 0 && ( diff --git a/apps/mobile/app/(app)/_layout.tsx b/apps/mobile/app/(app)/_layout.tsx index 5cd416b..d02dc24 100755 --- a/apps/mobile/app/(app)/_layout.tsx +++ b/apps/mobile/app/(app)/_layout.tsx @@ -4,11 +4,12 @@ import { View, ActivityIndicator, StyleSheet, Platform, Text, Pressable, Animate import { BlurView } from "expo-blur"; import { LinearGradient } from "expo-linear-gradient"; import { useEffect, useState, useCallback, useRef } from "react"; -import { useAuthStore, useMatchStore, useMessageStore, useSubscriptionStore, usePacksStore } from "../../src/store"; +import { useAuthStore, useMatchStore, useMessageStore, useSubscriptionStore, usePacksStore, useStreakStore } from "../../src/store"; import { colors, gradients, blur, radius, spacing, typography, shadows } from "../../src/theme"; import { supabase } from "../../src/lib/supabase"; import { isBiometricEnabled } from "../../src/lib/biometricAuth"; import { checkAndRegisterPushToken } from "../../src/lib/notifications"; +import { syncBadgeCount } from "../../src/lib/badge"; import { BiometricLockScreen } from "../../src/components/BiometricLockScreen"; import { MatchNotificationModal, MatchNotificationData } from "../../src/components/MatchNotificationModal"; import { Events } from "../../src/lib/analytics"; @@ -139,11 +140,12 @@ function PlayTabButton({ accessibilityState }: PlayTabButtonProps) { export default function AppLayout() { const router = useRouter(); const segments = useSegments(); - const { isAuthenticated, isLoading, user, signOut } = useAuthStore(); + const { isAuthenticated, isLoading, user, signOut, updateLastActive } = useAuthStore(); const { matches, newMatchesCount, addMatch, updateMatchUnreadCount } = useMatchStore(); const { unreadCount, lastMessage, fetchUnreadCount, addMessage, clearLastMessage } = useMessageStore(); const { fetchEnabledPacks } = usePacksStore(); const { initializeRevenueCat } = useSubscriptionStore(); + const { fetchStreak } = useStreakStore(); const [matchNotification, setMatchNotification] = useState(null); const messageToastAnim = useRef(new Animated.Value(-100)).current; const messageTimeoutRef = useRef(null); @@ -208,7 +210,8 @@ export default function AppLayout() { const isOnOnboarding = segmentStrings.includes("onboarding"); const isOnPairing = segmentStrings.includes("pairing"); const isOnChat = segmentStrings.includes("chat"); - const shouldHideTabBar = isOnOnboarding || isOnPairing || isOnChat; + const isOnSettingsSubscreen = segmentStrings.includes("settings") && segmentStrings.length > 2; + const shouldHideTabBar = isOnOnboarding || isOnPairing || isOnChat || isOnSettingsSubscreen; // Redirect to login when not authenticated, or to onboarding if not completed useEffect(() => { @@ -232,17 +235,22 @@ export default function AppLayout() { // Check immediately checkSession(); + // Update last active timestamp on mount + updateLastActive(); + // Check when app comes to foreground const subscription = AppState.addEventListener('change', (nextAppState) => { if (nextAppState === 'active') { checkSession(); + // Update last active timestamp when app comes to foreground + updateLastActive(); } }); return () => { subscription.remove(); }; - }, [signOut]); + }, [signOut, updateLastActive]); // Handle biometric lock when app goes to background/foreground useEffect(() => { @@ -301,6 +309,18 @@ export default function AppLayout() { } }, [user?.id, fetchUnreadCount]); + // Fetch streak data when user is in a couple + useEffect(() => { + if (user?.couple_id) { + fetchStreak(); + } + }, [user?.couple_id, fetchStreak]); + + // Sync app icon badge count whenever matches or messages change + useEffect(() => { + syncBadgeCount(newMatchesCount, unreadCount); + }, [newMatchesCount, unreadCount]); + // Initialize RevenueCat for subscription management (iOS and Android only) useEffect(() => { if (user?.id && Platform.OS !== "web") { @@ -692,6 +712,12 @@ export default function AppLayout() { href: null, // Hide from tab bar }} /> + {/* Radial Menu Overlay */} diff --git a/apps/mobile/app/(app)/settings/_layout.tsx b/apps/mobile/app/(app)/settings/_layout.tsx new file mode 100644 index 0000000..d9d5745 --- /dev/null +++ b/apps/mobile/app/(app)/settings/_layout.tsx @@ -0,0 +1,12 @@ +import { Stack } from 'expo-router'; + +export default function SettingsLayout() { + return ( + + ); +} diff --git a/apps/mobile/app/(app)/settings/account.tsx b/apps/mobile/app/(app)/settings/account.tsx new file mode 100644 index 0000000..9d6d2eb --- /dev/null +++ b/apps/mobile/app/(app)/settings/account.tsx @@ -0,0 +1,3 @@ +import { AccountScreen } from "../../../src/features/profile/screens"; + +export default AccountScreen; diff --git a/apps/mobile/app/(app)/settings/notifications.tsx b/apps/mobile/app/(app)/settings/notifications.tsx new file mode 100644 index 0000000..72b4b61 --- /dev/null +++ b/apps/mobile/app/(app)/settings/notifications.tsx @@ -0,0 +1,3 @@ +import { NotificationsScreen } from "../../../src/features/profile/screens"; + +export default NotificationsScreen; diff --git a/apps/mobile/app/(app)/settings/partner.tsx b/apps/mobile/app/(app)/settings/partner.tsx new file mode 100644 index 0000000..163cb6a --- /dev/null +++ b/apps/mobile/app/(app)/settings/partner.tsx @@ -0,0 +1,3 @@ +import { PartnerScreen } from "../../../src/features/profile/screens"; + +export default PartnerScreen; diff --git a/apps/mobile/app/(app)/settings/preferences.tsx b/apps/mobile/app/(app)/settings/preferences.tsx new file mode 100644 index 0000000..0227f8c --- /dev/null +++ b/apps/mobile/app/(app)/settings/preferences.tsx @@ -0,0 +1,3 @@ +import { PreferencesScreen } from "../../../src/features/profile/screens"; + +export default PreferencesScreen; diff --git a/apps/mobile/app/(app)/settings/profile.tsx b/apps/mobile/app/(app)/settings/profile.tsx new file mode 100644 index 0000000..0706e3f --- /dev/null +++ b/apps/mobile/app/(app)/settings/profile.tsx @@ -0,0 +1,3 @@ +import { ProfileSettingsScreen } from "../../../src/features/profile/screens"; + +export default ProfileSettingsScreen; diff --git a/apps/mobile/app/(app)/settings/subscription.tsx b/apps/mobile/app/(app)/settings/subscription.tsx new file mode 100644 index 0000000..0634191 --- /dev/null +++ b/apps/mobile/app/(app)/settings/subscription.tsx @@ -0,0 +1,3 @@ +import { SubscriptionScreen } from "../../../src/features/profile/screens"; + +export default SubscriptionScreen; diff --git a/apps/mobile/app/(app)/settings/support.tsx b/apps/mobile/app/(app)/settings/support.tsx new file mode 100644 index 0000000..f4979ac --- /dev/null +++ b/apps/mobile/app/(app)/settings/support.tsx @@ -0,0 +1,3 @@ +import { SupportScreen } from "../../../src/features/profile/screens"; + +export default SupportScreen; diff --git a/apps/mobile/src/__tests__/stores/subscriptionStore.test.ts b/apps/mobile/src/__tests__/stores/subscriptionStore.test.ts index cd1460d..b543753 100644 --- a/apps/mobile/src/__tests__/stores/subscriptionStore.test.ts +++ b/apps/mobile/src/__tests__/stores/subscriptionStore.test.ts @@ -162,8 +162,8 @@ describe('subscriptionStore', () => { }); const result = await useSubscriptionStore.getState().purchasePackage(mockPackage as any); - - expect(result).toBe(true); + + expect(result.success).toBe(true); const state = useSubscriptionStore.getState(); expect(state.subscription.isProUser).toBe(true); expect(state.isPurchasing).toBe(false); @@ -178,7 +178,8 @@ describe('subscriptionStore', () => { const result = await useSubscriptionStore.getState().purchasePackage(mockPackage as any); - expect(result).toBe(false); + expect(result.success).toBe(false); + expect(result.cancelled).toBe(true); const state = useSubscriptionStore.getState(); expect(state.error).toBe('Purchase was cancelled'); expect(state.isPurchasing).toBe(false); @@ -192,7 +193,8 @@ describe('subscriptionStore', () => { const result = await useSubscriptionStore.getState().purchasePackage(mockPackage as any); - expect(result).toBe(false); + expect(result.success).toBe(false); + expect(result.cancelled).toBe(false); const state = useSubscriptionStore.getState(); expect(state.error).toBe('Purchase failed. Please try again.'); }); diff --git a/apps/mobile/src/components/StreakDisplay.tsx b/apps/mobile/src/components/StreakDisplay.tsx new file mode 100644 index 0000000..7d0bc44 --- /dev/null +++ b/apps/mobile/src/components/StreakDisplay.tsx @@ -0,0 +1,193 @@ +import React, { useEffect } from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import Animated, { FadeInDown, useSharedValue, useAnimatedStyle, withRepeat, withSequence, withTiming, Easing } from 'react-native-reanimated'; +import { LinearGradient } from 'expo-linear-gradient'; +import { useStreakStore } from '../store'; +import { colors, spacing, radius, typography } from '../theme'; + +interface StreakDisplayProps { + /** Whether to show the "longest streak" indicator */ + showLongest?: boolean; + /** Animation delay for entry animation */ + delay?: number; + /** Compact mode for smaller displays */ + compact?: boolean; +} + +/** + * Displays the couple's current streak with a flame icon. + * The flame pulses when there's an active streak. + */ +export function StreakDisplay({ showLongest = false, delay = 0, compact = false }: StreakDisplayProps) { + const { streak, isLoading, fetchStreak } = useStreakStore(); + + // Fetch streak on mount + useEffect(() => { + fetchStreak(); + }, [fetchStreak]); + + // Flame pulse animation + const scale = useSharedValue(1); + + useEffect(() => { + if (streak && streak.current_streak > 0) { + scale.value = withRepeat( + withSequence( + withTiming(1.1, { duration: 800, easing: Easing.inOut(Easing.ease) }), + withTiming(1, { duration: 800, easing: Easing.inOut(Easing.ease) }) + ), + -1, + true + ); + } else { + scale.value = 1; + } + }, [streak?.current_streak, scale]); + + const flameStyle = useAnimatedStyle(() => ({ + transform: [{ scale: scale.value }], + })); + + // Don't show if loading or no couple + if (isLoading || !streak) { + return null; + } + + const hasStreak = streak.current_streak > 0; + const currentStreak = streak.current_streak; + const longestStreak = streak.longest_streak; + + if (compact) { + return ( + + + + + + + {currentStreak} + + + + ); + } + + return ( + + + {/* Main streak display */} + + + {/* Flame icon */} + + + + + {/* Streak info */} + + + {currentStreak} + + + day{currentStreak !== 1 ? 's' : ''} together + + + + + {/* Longest streak */} + {showLongest && longestStreak > 0 && ( + + + + Best: {longestStreak} day{longestStreak !== 1 ? 's' : ''} + + + )} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + marginVertical: spacing.sm, + }, + card: { + borderRadius: radius.lg, + padding: spacing.md, + borderWidth: 1, + borderColor: colors.glass.border, + }, + content: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + }, + iconContainer: { + width: 48, + height: 48, + borderRadius: radius.full, + backgroundColor: 'rgba(243, 156, 18, 0.15)', + justifyContent: 'center', + alignItems: 'center', + }, + info: { + flex: 1, + }, + count: { + ...typography.title1, + color: colors.text, + }, + label: { + ...typography.subhead, + color: colors.textSecondary, + }, + inactiveText: { + color: colors.textTertiary, + }, + longest: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + marginTop: spacing.sm, + paddingTop: spacing.sm, + borderTopWidth: 1, + borderTopColor: colors.glass.border, + }, + longestText: { + ...typography.caption1, + color: colors.textTertiary, + }, + // Compact styles + compactContainer: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + backgroundColor: 'rgba(243, 156, 18, 0.15)', + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderRadius: radius.full, + }, + compactCount: { + ...typography.caption1, + fontWeight: '600', + color: colors.warning, + }, +}); + +export default StreakDisplay; diff --git a/apps/mobile/src/components/paywall/Paywall.tsx b/apps/mobile/src/components/paywall/Paywall.tsx index f32cabc..7aec17a 100644 --- a/apps/mobile/src/components/paywall/Paywall.tsx +++ b/apps/mobile/src/components/paywall/Paywall.tsx @@ -42,7 +42,11 @@ const FEATURES = [ }, ]; -export function Paywall({ visible, onClose, onSuccess }: PaywallProps) { +export function Paywall({ visible, onClose, onSuccess }: PaywallProps) { + const handleClose = (reason: "dismiss" | "success" | "restore" | "system") => { + Events.paywallClosed("paywall", reason); + onClose(); + }; const { offerings, isLoadingOfferings, @@ -76,26 +80,36 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) { } }, [offerings]); - const handlePurchase = async () => { - if (!selectedPackage) return; + const handlePurchase = async () => { + if (!selectedPackage) return; + + const packageType = selectedPackage.packageType || "unknown"; + Events.purchaseInitiated(packageType); + + const result = await purchasePackage(selectedPackage); + if (result.success) { + Events.purchaseCompleted(packageType); + onSuccess?.(); + handleClose("success"); + return; + } + + if (result.cancelled) { + Events.purchaseCancelled(packageType, result.errorCode); + return; + } + + Events.purchaseFailed(packageType, result.errorCode, result.errorMessage); + }; - Events.purchaseInitiated(selectedPackage.packageType || "unknown"); - const success = await purchasePackage(selectedPackage); - if (success) { - Events.purchaseCompleted(selectedPackage.packageType || "unknown"); - onSuccess?.(); - onClose(); - } - }; - - const handleRestore = async () => { - const restored = await restorePurchases(); - if (restored) { - Events.purchaseRestored(); - onSuccess?.(); - onClose(); - } - }; + const handleRestore = async () => { + const restored = await restorePurchases(); + if (restored) { + Events.purchaseRestored(); + onSuccess?.(); + handleClose("restore"); + } + }; const formatPrice = (pkg: PurchasesPackage) => { const price = pkg.product.priceString; @@ -129,12 +143,12 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) { return ( - + handleClose("system")} + > {/* Premium gradient background */} @@ -155,11 +169,11 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) { contentContainerStyle={styles.scrollContent} > {/* Close Button */} - + handleClose("dismiss")} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + > diff --git a/apps/mobile/src/features/profile/ProfileScreen.tsx b/apps/mobile/src/features/profile/ProfileScreen.tsx index 28bb3aa..671f2b6 100644 --- a/apps/mobile/src/features/profile/ProfileScreen.tsx +++ b/apps/mobile/src/features/profile/ProfileScreen.tsx @@ -1,62 +1,42 @@ -import React from 'react'; -import { View, Text, StyleSheet, ScrollView, TouchableOpacity, useWindowDimensions, Platform, Alert, Linking } from 'react-native'; +import React, { useState } from 'react'; +import { View, Text, StyleSheet, ScrollView, useWindowDimensions, Platform, Alert, TouchableOpacity } from 'react-native'; +import { useRouter } from 'expo-router'; import { Ionicons } from '@expo/vector-icons'; import Constants from 'expo-constants'; -import Animated, { - FadeInDown, - useSharedValue, - useAnimatedScrollHandler, - useAnimatedStyle, - interpolate, - Extrapolation -} from 'react-native-reanimated'; -import { BlurView } from 'expo-blur'; +import Animated, { FadeInDown } from 'react-native-reanimated'; +import { Image } from 'expo-image'; +import { LinearGradient } from 'expo-linear-gradient'; import { GradientBackground } from '../../components/ui'; -import { FeedbackModal } from '../../components/feedback'; import { Paywall } from '../../components/paywall'; import { colors, featureColors, spacing, typography, radius } from '../../theme'; import { useAuthStore, useSubscriptionStore } from '../../store'; import { resetSwipeTutorial } from '../../lib/swipeTutorialSeen'; import { resetMatchesTutorial } from '../../lib/matchesTutorialSeen'; -import { supabase } from '../../lib/supabase'; // Needed for debug reset - -// Hooks -import { useProfileSettings, useCoupleManagement } from './hooks'; +import { supabase } from '../../lib/supabase'; // Components -import { AppearanceSettings, CoupleStatus, NotificationSettings, PrivacySettings, DangerZone, SettingsSection, MenuItem, SubscriptionCard } from './components'; +import { SettingsSection, MenuItem, SubscriptionCard } from './components'; + +// Hooks +import { useProfileSettings } from './hooks'; const MAX_CONTENT_WIDTH = 500; -const NAV_BAR_HEIGHT = 44; -const STATUS_BAR_HEIGHT = 60; -const HEADER_SCROLL_DISTANCE = 100; - -const AnimatedScrollView = Animated.createAnimatedComponent(ScrollView); +const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string]; export function ProfileScreen() { + const router = useRouter(); const { width } = useWindowDimensions(); const isWideScreen = width > MAX_CONTENT_WIDTH; - const { user, partner, couple, fetchUser } = useAuthStore(); + const { user, partner, fetchUser } = useAuthStore(); const { subscription } = useSubscriptionStore(); - - // Hooks const settings = useProfileSettings(); - const { - handleUnpair, - handleDeleteRelationship, - handleResetProgress, - handleSignOut, - handleDeleteAccount, - navigateToPairing - } = useCoupleManagement(); - const [showFeedbackModal, setShowFeedbackModal] = React.useState(false); + // Paywall state + const [showPaywall, setShowPaywall] = useState(false); - // Derived formatting - // Use OR: if RevenueCat says they're subscribed OR database says premium, it's their own - // This handles the race condition after purchase (RevenueCat updates before webhook syncs DB) + // Derived state const isOwnSubscription = subscription.isProUser || user?.is_premium; const hasPremiumAccess = user?.is_premium || partner?.is_premium || subscription.isProUser; @@ -79,81 +59,20 @@ export function ProfileScreen() { }); const versionString = buildNumber ? `v${version} (${buildNumber})` : `v${version}`; - // Scroll animation - const scrollY = useSharedValue(0); - const scrollHandler = useAnimatedScrollHandler({ - onScroll: (event) => { - scrollY.value = event.contentOffset.y; - }, - }); - - const heroStyle = useAnimatedStyle(() => { - const opacity = interpolate( - scrollY.value, - [0, HEADER_SCROLL_DISTANCE * 0.7], - [1, 0], - Extrapolation.CLAMP - ); - const scale = interpolate( - scrollY.value, - [0, HEADER_SCROLL_DISTANCE], - [1, 0.95], - Extrapolation.CLAMP - ); - return { opacity, transform: [{ scale }] }; - }); - - const compactHeaderStyle = useAnimatedStyle(() => { - const opacity = interpolate( - scrollY.value, - [HEADER_SCROLL_DISTANCE * 0.5, HEADER_SCROLL_DISTANCE], - [0, 1], - Extrapolation.CLAMP - ); - return { opacity }; - }); - - const navBarBackgroundStyle = useAnimatedStyle(() => { - const opacity = interpolate( - scrollY.value, - [0, HEADER_SCROLL_DISTANCE * 0.8], - [0, 1], - Extrapolation.CLAMP - ); - return { opacity }; - }); - return ( - {/* Fixed Nav Bar */} - - - {Platform.OS === "ios" ? ( - - ) : ( - - )} - - - - Settings - - - - - {/* Header title */} + {/* Header with user info (compact) */} YOUR SAUCI Settings @@ -164,98 +83,111 @@ export function ProfileScreen() { - {/* Profile Header */} - settings.setIsEditingName(true)} - /> + {/* Compact Profile Preview */} + + router.push('/(app)/settings/profile')} + activeOpacity={0.7} + > + + {user?.avatar_url ? ( + + ) : ( + + + {user?.name?.[0]?.toUpperCase() || "U"} + + + )} + + + {user?.name || "User"} + {user?.email} + + + + + + - {/* Partner */} - - - {/* Subscription Section */} + {/* Subscription CTA - prominent upsell */} settings.setShowPaywall(true)} + onUpgradePress={() => setShowPaywall(true)} onManagePress={settings.handleManageSubscription} onRestorePress={settings.handleRestorePurchases} isRestoring={settings.isPurchasing} - delay={350} + delay={200} /> - {/* Notifications */} - - - {/* Preferences */} - - - {/* Account */} - + {/* Settings Navigation */} + router.push('/(app)/settings/profile')} + /> + + router.push('/(app)/settings/notifications')} + /> + + router.push('/(app)/settings/preferences')} /> - {/* Support */} - + {/* Relationship Section */} + setShowFeedbackModal(true)} - /> - - Linking.openURL('https://sauci.app/privacy')} - /> - - Linking.openURL('https://sauci.app/terms')} + icon="heart-outline" + label="Partner" + rightText={partner?.name || (user?.couple_id ? 'Waiting...' : undefined)} + onPress={() => router.push('/(app)/settings/partner')} /> - {/* Debug Section */} + {/* Other Section */} + + router.push('/(app)/settings/support')} + /> + + router.push('/(app)/settings/account')} + /> + + + {/* Debug Section - Dev only */} {__DEV__ && ( - + - + - + )} - {/* Danger Zone */} - - {/* Version */} @@ -318,16 +242,11 @@ export function ProfileScreen() { - + - {/* Modals */} - setShowFeedbackModal(false)} - /> settings.setShowPaywall(false)} + visible={showPaywall} + onClose={() => setShowPaywall(false)} /> ); @@ -337,33 +256,7 @@ const styles = StyleSheet.create({ container: { flex: 1, }, - // Fixed Nav Bar - navBar: { - position: "absolute", - top: 0, - left: 0, - right: 0, - height: STATUS_BAR_HEIGHT + NAV_BAR_HEIGHT, - flexDirection: "row", - alignItems: "center", - justifyContent: "center", - paddingTop: STATUS_BAR_HEIGHT - 10, - paddingHorizontal: spacing.md, - zIndex: 100, - }, - navBarBackground: { - ...StyleSheet.absoluteFillObject, - borderBottomWidth: 1, - borderBottomColor: 'rgba(155, 89, 182, 0.15)', // Purple tint for profile - overflow: "hidden", - }, - navBarTitle: { - ...typography.headline, - color: colors.text, - textAlign: "center", - }, contentContainer: { - // Remove top padding here as the header adds it paddingBottom: Platform.OS === 'ios' ? 100 : 80, }, contentContainerWide: { @@ -372,7 +265,7 @@ const styles = StyleSheet.create({ maxWidth: MAX_CONTENT_WIDTH, }, header: { - paddingTop: STATUS_BAR_HEIGHT + spacing.md, + paddingTop: Platform.OS === 'ios' ? 60 : 40, paddingHorizontal: spacing.lg, paddingBottom: spacing.md, alignItems: 'center', @@ -409,7 +302,65 @@ const styles = StyleSheet.create({ marginHorizontal: spacing.sm, opacity: 0.6, }, - preferencesDivider: { + // Profile Preview + profilePreview: { + marginHorizontal: spacing.lg, + marginBottom: spacing.lg, + backgroundColor: colors.glass.background, + borderRadius: radius.lg, + borderWidth: 1, + borderColor: colors.glass.border, + overflow: 'hidden', + }, + profilePreviewContent: { + flexDirection: 'row', + alignItems: 'center', + padding: spacing.md, + }, + avatarGradient: { + width: 56, + height: 56, + borderRadius: 28, + padding: 2, + justifyContent: 'center', + alignItems: 'center', + }, + avatar: { + width: '100%', + height: '100%', + borderRadius: 28, + backgroundColor: colors.background, + }, + avatarInner: { + width: '100%', + height: '100%', + borderRadius: 28, + backgroundColor: colors.background, + justifyContent: 'center', + alignItems: 'center', + }, + avatarText: { + ...typography.title2, + fontSize: 24, + color: colors.text, + }, + profileInfo: { + flex: 1, + marginLeft: spacing.md, + }, + userName: { + ...typography.headline, + color: colors.text, + }, + userEmail: { + ...typography.caption1, + color: colors.textSecondary, + marginTop: 2, + }, + profileChevron: { + padding: spacing.xs, + }, + divider: { height: 1, backgroundColor: colors.glass.border, marginVertical: spacing.md, diff --git a/apps/mobile/src/features/profile/components/MenuItem.tsx b/apps/mobile/src/features/profile/components/MenuItem.tsx index 747f6c1..d59cd21 100644 --- a/apps/mobile/src/features/profile/components/MenuItem.tsx +++ b/apps/mobile/src/features/profile/components/MenuItem.tsx @@ -15,6 +15,10 @@ export interface MenuItemProps { onPress: () => void; /** Optional element to show on the right */ rightElement?: React.ReactNode; + /** Text to display on the right side (e.g., partner name) */ + rightText?: string; + /** Badge label to display (e.g., "Premium") */ + badge?: string; /** Whether to show chevron arrow */ showChevron?: boolean; /** Visual variant */ @@ -34,6 +38,8 @@ export function MenuItem({ description, onPress, rightElement, + rightText, + badge, showChevron = true, variant = 'default', disabled = false, @@ -65,17 +71,29 @@ export function MenuItem({ )} - {label} + + {label} + {badge && ( + + {badge} + + )} + {description && ( {description} )} - {rightElement || (showChevron && ( - - - - ))} + + {rightText && ( + {rightText} + )} + {rightElement || (showChevron && ( + + + + ))} + ); } @@ -113,15 +131,43 @@ const styles = StyleSheet.create({ textContainer: { flex: 1, }, + labelRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + }, label: { ...typography.body, fontWeight: '500', }, + badge: { + backgroundColor: colors.premium.goldLight, + paddingHorizontal: spacing.sm, + paddingVertical: 2, + borderRadius: radius.full, + }, + badgeText: { + ...typography.caption2, + color: colors.premium.gold, + fontWeight: '600', + textTransform: 'uppercase', + letterSpacing: 0.5, + }, description: { ...typography.caption1, color: colors.textSecondary, marginTop: 2, }, + rightSection: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.sm, + }, + rightText: { + ...typography.subhead, + color: colors.textSecondary, + maxWidth: 120, + }, chevron: { padding: spacing.xs, }, diff --git a/apps/mobile/src/features/profile/components/NotificationSettings.tsx b/apps/mobile/src/features/profile/components/NotificationSettings.tsx index 5eab3fb..4cdfe09 100644 --- a/apps/mobile/src/features/profile/components/NotificationSettings.tsx +++ b/apps/mobile/src/features/profile/components/NotificationSettings.tsx @@ -1,5 +1,8 @@ -import React from 'react'; - +import React, { useEffect, useMemo } from 'react'; +import { View, StyleSheet, Text } from 'react-native'; +import { colors, spacing, typography } from '../../../theme'; +import { useNotificationPreferencesStore } from '../../../store'; +import { useAuthStore } from '../../../store'; import { SettingsSection } from './SettingsSection'; import { SwitchItem } from './SwitchItem'; @@ -16,16 +19,132 @@ export const NotificationSettings: React.FC = ({ onPushToggle, delay = 360, }) => { + const { user } = useAuthStore(); + const { preferences, isUpdating, fetchPreferences, updatePreference } = useNotificationPreferencesStore(); + + // Fetch notification preferences on mount when user is available + useEffect(() => { + if (user?.id && !preferences) { + fetchPreferences(); + } + }, [user?.id, preferences, fetchPreferences]); + + // Memoize handlers to prevent unnecessary re-renders + const handlers = useMemo(() => ({ + onMatchesToggle: (value: boolean) => updatePreference('matches_enabled', value), + onMessagesToggle: (value: boolean) => updatePreference('messages_enabled', value), + onPartnerActivityToggle: (value: boolean) => updatePreference('partner_activity_enabled', value), + onPackChangesToggle: (value: boolean) => updatePreference('pack_changes_enabled', value), + onNewPacksToggle: (value: boolean) => updatePreference('new_packs_enabled', value), + onStreakMilestonesToggle: (value: boolean) => updatePreference('streak_milestones_enabled', value), + }), [updatePreference]); + return ( + {/* Master push toggle */} + + {/* Granular settings - only show when push is enabled and preferences are loaded */} + {pushEnabled && preferences && ( + <> + + Notification Types + + + + + + + + + + + + + + + + + + + + + + + + + )} ); }; + +const styles = StyleSheet.create({ + sectionDivider: { + marginTop: spacing.lg, + marginBottom: spacing.md, + paddingTop: spacing.md, + borderTopWidth: 1, + borderTopColor: colors.glass.border, + }, + subsectionLabel: { + ...typography.caption1, + color: colors.textTertiary, + fontWeight: '600', + letterSpacing: 1, + textTransform: 'uppercase', + }, + itemDivider: { + height: 1, + backgroundColor: colors.glass.border, + marginVertical: spacing.sm, + }, +}); diff --git a/apps/mobile/src/features/profile/components/PrivacySettings.tsx b/apps/mobile/src/features/profile/components/PrivacySettings.tsx index bae53dd..0318168 100644 --- a/apps/mobile/src/features/profile/components/PrivacySettings.tsx +++ b/apps/mobile/src/features/profile/components/PrivacySettings.tsx @@ -1,10 +1,12 @@ -import React from 'react'; -import { View, Text, StyleSheet } from 'react-native'; +import React, { useState } from 'react'; +import { View, Text, StyleSheet, TouchableOpacity, Modal, Platform, ScrollView } from 'react-native'; import { LinearGradient } from 'expo-linear-gradient'; +import { BlurView } from 'expo-blur'; import { Ionicons } from '@expo/vector-icons'; import { SettingsSection } from './SettingsSection'; import { SwitchItem } from './SwitchItem'; import { IntensitySlider } from '../../../components/ui/IntensitySlider'; +import { GlassButton } from '../../../components/ui'; import { colors, spacing, radius, typography, featureColors } from '../../../theme'; import type { IntensityLevel } from '../../../types'; @@ -38,7 +40,10 @@ export const PrivacySettings: React.FC = ({ partnerName, partnerAvatar, }) => { + const [showHelpModal, setShowHelpModal] = useState(false); + return ( + <> {/* Comfort Zone Header */} @@ -51,7 +56,16 @@ export const PrivacySettings: React.FC = ({ - Comfort Zone + + Comfort Zone + setShowHelpModal(true)} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + activeOpacity={0.7} + > + + + Set the intimacy level you're comfortable with @@ -85,6 +99,100 @@ export const PrivacySettings: React.FC = ({ )} + + {/* Help Modal */} + setShowHelpModal(false)} + > + + + + + How Comfort Zone Works + setShowHelpModal(false)} + style={styles.closeButton} + hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} + > + + + + + + + + + + Intensity Levels + + Questions range from 1 (mild) to 5 (spicy). You'll only see questions at or below your comfort level. + + + + + + + + Hidden Packs + + Some question packs are designed for higher intensity levels. These packs won't appear until you raise your comfort zone. + + + + + + + + Partner Sync + + Questions only appear when both of you are comfortable. The lower setting between you and your partner determines what you both see. + + + + + + + + Visible to Partner + + Your partner can see the comfort zone level you choose so you can stay aligned. This doesn’t reveal your individual swipes. + + + + + + + + Change Anytime + + Adjust whenever you want. New questions and packs will appear or hide based on your new setting. + + + + + + + setShowHelpModal(false)} + fullWidth + > + Got it + + + + + + ); }; @@ -105,6 +213,11 @@ const styles = StyleSheet.create({ comfortText: { flex: 1, }, + comfortLabelRow: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.xs, + }, comfortLabel: { ...typography.headline, color: colors.text, @@ -122,4 +235,71 @@ const styles = StyleSheet.create({ backgroundColor: colors.glass.border, marginVertical: spacing.lg, }, + // Modal styles + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.7)', + justifyContent: 'center', + alignItems: 'center', + padding: spacing.lg, + }, + modalContent: { + width: '100%', + maxWidth: 400, + maxHeight: '80%', + backgroundColor: colors.backgroundLight, + borderRadius: radius.xl, + borderWidth: 1, + borderColor: colors.glass.border, + padding: spacing.lg, + }, + modalHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginBottom: spacing.md, + paddingBottom: spacing.md, + borderBottomWidth: 1, + borderBottomColor: colors.glass.border, + }, + modalTitle: { + ...typography.headline, + color: colors.text, + }, + closeButton: { + padding: spacing.xs, + }, + helpScrollView: { + flexGrow: 0, + marginBottom: spacing.md, + }, + helpSection: { + gap: spacing.sm, + }, + helpItem: { + flexDirection: 'row', + alignItems: 'flex-start', + paddingVertical: spacing.sm, + }, + helpItemLast: { + paddingBottom: 0, + }, + helpIcon: { + marginRight: spacing.sm, + marginTop: 2, + }, + helpTextContainer: { + flex: 1, + }, + helpItemTitle: { + ...typography.subhead, + fontWeight: '600', + color: colors.text, + marginBottom: 2, + }, + helpItemDescription: { + ...typography.caption1, + color: colors.textSecondary, + lineHeight: 18, + }, }); diff --git a/apps/mobile/src/features/profile/components/RelationshipDangerZone.tsx b/apps/mobile/src/features/profile/components/RelationshipDangerZone.tsx new file mode 100644 index 0000000..99d46ed --- /dev/null +++ b/apps/mobile/src/features/profile/components/RelationshipDangerZone.tsx @@ -0,0 +1,584 @@ +import React, { useState } from 'react'; +import { View, Text, StyleSheet, Modal, Platform, KeyboardAvoidingView, TouchableOpacity, ActivityIndicator, TextInput, ViewStyle } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import Animated, { FadeInDown } from 'react-native-reanimated'; +import { BlurView } from 'expo-blur'; +import { GlassCard, GlassButton } from '../../../components/ui'; +import { colors, spacing, radius, typography } from '../../../theme'; + +interface RelationshipDangerZoneProps { + onResetProgress?: () => Promise; + onDeleteRelationship?: () => Promise; +} + +/** + * Danger zone component specifically for relationship-related actions. + * Does NOT include delete account - that's in AccountScreen. + */ +export const RelationshipDangerZone: React.FC = ({ + onResetProgress, + onDeleteRelationship, +}) => { + const [showResetModal, setShowResetModal] = useState(false); + const [showDeleteRelationshipModal, setShowDeleteRelationshipModal] = useState(false); + const [confirmText, setConfirmText] = useState(""); + const [isResetting, setIsResetting] = useState(false); + const [isDeletingRelationship, setIsDeletingRelationship] = useState(false); + + const handleResetProgress = async () => { + if (confirmText !== "RESET" || !onResetProgress) return; + setIsResetting(true); + try { + await onResetProgress(); + setShowResetModal(false); + setConfirmText(""); + } catch (error) { + // Error handling relies on parent + } finally { + setIsResetting(false); + } + }; + + const closeResetModal = () => { + if (!isResetting) { + setShowResetModal(false); + setConfirmText(""); + } + }; + + const handleDeleteRelationship = async () => { + if (confirmText !== "DELETE" || !onDeleteRelationship) return; + setIsDeletingRelationship(true); + try { + await onDeleteRelationship(); + setShowDeleteRelationshipModal(false); + setConfirmText(""); + } catch (error) { + // Error handling relies on parent + } finally { + setIsDeletingRelationship(false); + } + }; + + const closeRelationshipModal = () => { + if (!isDeletingRelationship) { + setShowDeleteRelationshipModal(false); + setConfirmText(""); + } + }; + + return ( + <> + + Danger Zone + + {/* Reset Progress */} + {onResetProgress && ( + + + + + + + + Reset Progress + + Start fresh by clearing all swipes, matches, and chats. Your partner connection stays. + + + + setShowResetModal(true)} + activeOpacity={0.7} + > + + Reset + + + + )} + + {/* Delete Relationship */} + {onDeleteRelationship && ( + + + + + + + + Delete Relationship + + End your connection and delete all shared data. Your account will remain. + + + + setShowDeleteRelationshipModal(true)} + icon={} + style={styles.dangerButton} + > + Delete + + + + )} + + + {/* Reset Progress Modal */} + + + + + + + + + Reset Progress? + + + + This will permanently delete: + + + + + + All your swipes + + + + All matches + + + + All chat messages + + + + + + + Your partner connection stays + + + + + Type RESET to confirm: + + + + + + + Cancel + + + + {isResetting ? ( + + ) : ( + <> + + Reset + + )} + + + + + + + + {/* Delete Relationship Modal */} + + + + + + + + + Delete Relationship Data? + + + + This action cannot be undone. This will permanently delete: + + + + + + Your couple connection + + + + All matches and responses + + + + All chat messages + + + + All shared photos + + + + + Type DELETE to confirm: + + + + + + + Cancel + + + + {isDeletingRelationship ? ( + + ) : ( + <> + + Delete + + )} + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + section: { + paddingHorizontal: spacing.lg, + marginBottom: spacing.lg, + }, + dangerSectionTitle: { + ...typography.caption1, + color: colors.error, + textTransform: "uppercase", + letterSpacing: 1.5, + marginBottom: spacing.sm, + marginLeft: spacing.xs, + }, + warningCard: { + borderColor: 'rgba(243, 156, 18, 0.3)', + borderWidth: 1, + }, + warningIconContainer: { + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: 'rgba(243, 156, 18, 0.15)', + justifyContent: "center", + alignItems: "center", + }, + warningButton: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + backgroundColor: 'rgba(243, 156, 18, 0.15)', + paddingVertical: spacing.sm, + paddingHorizontal: spacing.md, + borderRadius: radius.md, + borderWidth: 1, + borderColor: 'rgba(243, 156, 18, 0.3)', + gap: spacing.xs, + alignSelf: "flex-end", + }, + warningButtonText: { + ...typography.subhead, + color: colors.warning, + fontWeight: "600", + }, + dangerCard: { + borderColor: 'rgba(231, 76, 60, 0.3)', + borderWidth: 1, + }, + dangerCardSpacing: { + marginTop: spacing.md, + }, + dangerContent: { + gap: spacing.md, + }, + dangerInfo: { + flexDirection: "row", + alignItems: "flex-start", + }, + dangerIconContainer: { + width: 44, + height: 44, + borderRadius: 22, + backgroundColor: colors.errorLight, + justifyContent: "center", + alignItems: "center", + }, + dangerTextContainer: { + flex: 1, + marginLeft: spacing.md, + }, + dangerTitle: { + ...typography.headline, + color: colors.text, + marginBottom: spacing.xs, + }, + dangerDescription: { + ...typography.subhead, + color: colors.textSecondary, + lineHeight: 20, + }, + dangerButton: { + alignSelf: "flex-end", + }, + // Modal styles + modalOverlay: { + flex: 1, + backgroundColor: "rgba(0, 0, 0, 0.7)", + justifyContent: "center", + alignItems: "center", + padding: spacing.lg, + }, + modalContent: { + width: "100%", + maxWidth: 400, + padding: spacing.xl, + backgroundColor: colors.backgroundLight, + borderRadius: radius.xl, + borderWidth: 1, + borderColor: colors.glass.border, + }, + modalHeader: { + alignItems: "center", + marginBottom: spacing.lg, + }, + modalIconContainer: { + width: 64, + height: 64, + borderRadius: 32, + backgroundColor: colors.errorLight, + justifyContent: "center", + alignItems: "center", + marginBottom: spacing.md, + }, + modalTitle: { + ...typography.title2, + color: colors.text, + textAlign: "center", + }, + modalDescription: { + ...typography.body, + color: colors.textSecondary, + marginBottom: spacing.lg, + textAlign: "center", + }, + deleteList: { + backgroundColor: "rgba(231, 76, 60, 0.1)", + borderRadius: radius.lg, + padding: spacing.md, + gap: spacing.sm, + marginBottom: spacing.xl, + borderWidth: 1, + borderColor: 'rgba(231, 76, 60, 0.2)', + }, + warningList: { + backgroundColor: "rgba(243, 156, 18, 0.1)", + borderRadius: radius.lg, + padding: spacing.md, + gap: spacing.sm, + marginBottom: spacing.md, + borderWidth: 1, + borderColor: 'rgba(243, 156, 18, 0.2)', + }, + keepList: { + backgroundColor: "rgba(46, 204, 113, 0.1)", + borderRadius: radius.lg, + padding: spacing.md, + gap: spacing.sm, + marginBottom: spacing.xl, + borderWidth: 1, + borderColor: 'rgba(46, 204, 113, 0.2)', + }, + warningModalIconContainer: { + width: 64, + height: 64, + borderRadius: 32, + backgroundColor: 'rgba(243, 156, 18, 0.15)', + justifyContent: "center", + alignItems: "center", + marginBottom: spacing.md, + }, + warningKeyword: { + fontWeight: "bold", + color: colors.warning, + }, + warningInput: { + backgroundColor: colors.glass.background, + borderRadius: radius.md, + borderWidth: 1, + borderColor: colors.warning, + padding: spacing.md, + ...typography.body, + color: colors.text, + textAlign: 'center', + marginBottom: spacing.xl, + }, + warningActionButton: { + flex: 1, + backgroundColor: colors.warning, + paddingVertical: spacing.md, + borderRadius: radius.md, + alignItems: "center", + flexDirection: "row", + justifyContent: "center", + gap: spacing.xs, + }, + deleteListItem: { + flexDirection: "row", + alignItems: "center", + gap: spacing.sm, + }, + deleteListText: { + ...typography.subhead, + color: colors.text, + }, + confirmLabel: { + ...typography.subhead, + color: colors.textSecondary, + marginBottom: spacing.xs, + textAlign: "center", + }, + confirmKeyword: { + fontWeight: "bold", + color: colors.error, + }, + confirmInput: { + backgroundColor: colors.glass.background, + borderRadius: radius.md, + borderWidth: 1, + borderColor: colors.error, + padding: spacing.md, + ...typography.body, + color: colors.text, + textAlign: 'center', + marginBottom: spacing.xl, + }, + modalButtons: { + flexDirection: "row", + gap: spacing.md, + }, + cancelButton: { + flex: 1, + backgroundColor: colors.glass.backgroundLight, + paddingVertical: spacing.md, + borderRadius: radius.md, + alignItems: "center", + borderWidth: 1, + borderColor: colors.glass.borderLight, + }, + cancelButtonText: { + ...typography.body, + color: colors.textSecondary, + fontWeight: "600", + }, + deleteButton: { + flex: 1, + backgroundColor: colors.error, + paddingVertical: spacing.md, + borderRadius: radius.md, + alignItems: "center", + flexDirection: "row", + justifyContent: "center", + gap: spacing.xs, + }, + deleteButtonDisabled: { + opacity: 0.5, + }, + deleteButtonText: { + ...typography.body, + color: colors.text, + fontWeight: "600", + }, +}); + +export default RelationshipDangerZone; diff --git a/apps/mobile/src/features/profile/components/ScreenHeader.tsx b/apps/mobile/src/features/profile/components/ScreenHeader.tsx new file mode 100644 index 0000000..449a2d0 --- /dev/null +++ b/apps/mobile/src/features/profile/components/ScreenHeader.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { View, Text, TouchableOpacity, StyleSheet, Platform } from 'react-native'; +import { useRouter } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { colors, spacing, typography, featureColors } from '../../../theme'; + +export interface ScreenHeaderProps { + /** Screen title */ + title: string; + /** Optional element to render on the right side */ + rightElement?: React.ReactNode; +} + +/** + * Reusable header with back navigation for settings sub-screens. + */ +export function ScreenHeader({ title, rightElement }: ScreenHeaderProps) { + const router = useRouter(); + const insets = useSafeAreaInsets(); + + const handleBack = () => { + // Navigate back to the settings hub (profile tab) + router.navigate('/(app)/profile'); + }; + + return ( + + + + + + {title} + + + {rightElement || } + + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: spacing.md, + paddingBottom: spacing.md, + borderBottomWidth: 1, + borderBottomColor: `${featureColors.profile.accent}15`, + }, + backButton: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: colors.glass.background, + justifyContent: 'center', + alignItems: 'center', + }, + title: { + ...typography.headline, + color: colors.text, + flex: 1, + textAlign: 'center', + marginHorizontal: spacing.md, + }, + rightContainer: { + minWidth: 40, + alignItems: 'flex-end', + }, + placeholder: { + width: 40, + }, +}); + +export default ScreenHeader; diff --git a/apps/mobile/src/features/profile/components/index.ts b/apps/mobile/src/features/profile/components/index.ts index 32d78f4..6d93ae2 100644 --- a/apps/mobile/src/features/profile/components/index.ts +++ b/apps/mobile/src/features/profile/components/index.ts @@ -8,6 +8,7 @@ export type { NotificationSettingsProps } from './NotificationSettings'; export { CoupleStatus } from './CoupleStatus'; export { PrivacySettings } from './PrivacySettings'; export { DangerZone } from './DangerZone'; +export { RelationshipDangerZone } from './RelationshipDangerZone'; export { ResetProgress } from './ResetProgress'; export { SettingsSection } from './SettingsSection'; @@ -18,3 +19,5 @@ export { MenuItem } from './MenuItem'; export type { MenuItemProps } from './MenuItem'; export { SubscriptionCard } from './SubscriptionCard'; export type { SubscriptionCardProps } from './SubscriptionCard'; +export { ScreenHeader } from './ScreenHeader'; +export type { ScreenHeaderProps } from './ScreenHeader'; diff --git a/apps/mobile/src/features/profile/screens/AccountScreen.tsx b/apps/mobile/src/features/profile/screens/AccountScreen.tsx new file mode 100644 index 0000000..bc22d92 --- /dev/null +++ b/apps/mobile/src/features/profile/screens/AccountScreen.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { View, ScrollView, StyleSheet, Platform } from 'react-native'; + +import { GradientBackground } from '../../../components/ui'; +import { colors, spacing } from '../../../theme'; +import { useAuthStore } from '../../../store'; +import { useCoupleManagement } from '../hooks'; +import { ScreenHeader, SettingsSection, MenuItem, DangerZone } from '../components'; + +/** + * Account settings sub-screen (sign out, delete account). + */ +export function AccountScreen() { + const { couple } = useAuthStore(); + const { handleSignOut, handleDeleteAccount } = useCoupleManagement(); + + return ( + + + + + + + + + + {/* Account Danger Zone - delete account only */} + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + content: { + paddingTop: spacing.lg, + paddingBottom: Platform.OS === 'ios' ? 40 : 20, + }, + spacer: { + height: spacing.md, + }, +}); + +export default AccountScreen; diff --git a/apps/mobile/src/features/profile/screens/NotificationsScreen.tsx b/apps/mobile/src/features/profile/screens/NotificationsScreen.tsx new file mode 100644 index 0000000..1f03118 --- /dev/null +++ b/apps/mobile/src/features/profile/screens/NotificationsScreen.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { ScrollView, StyleSheet, Platform } from 'react-native'; + +import { GradientBackground } from '../../../components/ui'; +import { spacing } from '../../../theme'; +import { useProfileSettings } from '../hooks'; +import { ScreenHeader, NotificationSettings } from '../components'; + +/** + * Notifications settings sub-screen for push notification preferences. + */ +export function NotificationsScreen() { + const settings = useProfileSettings(); + + return ( + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + content: { + paddingTop: spacing.lg, + paddingBottom: Platform.OS === 'ios' ? 40 : 20, + }, +}); + +export default NotificationsScreen; diff --git a/apps/mobile/src/features/profile/screens/PartnerScreen.tsx b/apps/mobile/src/features/profile/screens/PartnerScreen.tsx new file mode 100644 index 0000000..26da8af --- /dev/null +++ b/apps/mobile/src/features/profile/screens/PartnerScreen.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { View, ScrollView, StyleSheet, Platform } from 'react-native'; + +import { GradientBackground } from '../../../components/ui'; +import { colors, spacing } from '../../../theme'; +import { useAuthStore } from '../../../store'; +import { useCoupleManagement } from '../hooks'; +import { ScreenHeader, CoupleStatus, RelationshipDangerZone } from '../components'; + +/** + * Partner/relationship management sub-screen. + */ +export function PartnerScreen() { + const { partner, couple } = useAuthStore(); + const { + handleUnpair, + handleDeleteRelationship, + handleResetProgress, + navigateToPairing, + } = useCoupleManagement(); + + return ( + + + + {/* Partner Info */} + + + {/* Relationship Danger Zone - only show if in a relationship */} + {couple && ( + <> + + + + )} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + content: { + paddingTop: spacing.lg, + paddingBottom: Platform.OS === 'ios' ? 40 : 20, + }, + spacer: { + height: spacing.lg, + }, +}); + +export default PartnerScreen; diff --git a/apps/mobile/src/features/profile/screens/PreferencesScreen.tsx b/apps/mobile/src/features/profile/screens/PreferencesScreen.tsx new file mode 100644 index 0000000..47696d5 --- /dev/null +++ b/apps/mobile/src/features/profile/screens/PreferencesScreen.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { ScrollView, StyleSheet, Platform } from 'react-native'; + +import { GradientBackground } from '../../../components/ui'; +import { spacing } from '../../../theme'; +import { useProfileSettings } from '../hooks'; +import { ScreenHeader, PrivacySettings } from '../components'; + +/** + * Preferences sub-screen for comfort zone and biometric settings. + */ +export function PreferencesScreen() { + const settings = useProfileSettings(); + + return ( + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + content: { + paddingTop: spacing.lg, + paddingBottom: Platform.OS === 'ios' ? 40 : 20, + }, +}); + +export default PreferencesScreen; diff --git a/apps/mobile/src/features/profile/screens/ProfileSettingsScreen.tsx b/apps/mobile/src/features/profile/screens/ProfileSettingsScreen.tsx new file mode 100644 index 0000000..dc8c83d --- /dev/null +++ b/apps/mobile/src/features/profile/screens/ProfileSettingsScreen.tsx @@ -0,0 +1,437 @@ +import React, { useState, useEffect } from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + Platform, + TextInput, + Pressable, + ActivityIndicator, + Alert, + TouchableOpacity, +} from 'react-native'; +import { Image } from 'expo-image'; +import { Ionicons } from '@expo/vector-icons'; +import { LinearGradient } from 'expo-linear-gradient'; +import Animated, { FadeInDown } from 'react-native-reanimated'; + +import { GradientBackground, GlassCard, GlassButton } from '../../../components/ui'; +import { colors, featureColors, spacing, typography, radius } from '../../../theme'; +import { useAuthStore } from '../../../store'; +import { supabase } from '../../../lib/supabase'; +import { Events } from '../../../lib/analytics'; +import { useProfileSettings } from '../hooks'; +import { ScreenHeader, SettingsSection } from '../components'; +import type { Gender } from '../../../types'; + +// Hook to get email from auth session +function useAuthEmail() { + const [email, setEmail] = useState(null); + + useEffect(() => { + const getEmail = async () => { + const { data: { user } } = await supabase.auth.getUser(); + setEmail(user?.email ?? null); + }; + getEmail(); + }, []); + + return email; +} + +const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string]; + +const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [ + { value: 'male', label: 'Male', icon: 'male' }, + { value: 'female', label: 'Female', icon: 'female' }, + { value: 'non-binary', label: 'Non-binary', icon: 'male-female' }, + { value: 'prefer-not-to-say', label: 'Prefer not to say', icon: 'remove-circle-outline' }, +]; + +/** + * Profile settings sub-screen for editing avatar, display name, and gender. + */ +export function ProfileSettingsScreen() { + const { user, fetchUser } = useAuthStore(); + const settings = useProfileSettings(); + const authEmail = useAuthEmail(); + + // Local form state + const [name, setName] = useState(user?.name || ''); + const [gender, setGender] = useState(user?.gender || null); + const [isSaving, setIsSaving] = useState(false); + + // Track if form has changes + const hasChanges = name.trim() !== (user?.name || '') || gender !== (user?.gender || null); + + // Reset form when user changes + useEffect(() => { + setName(user?.name || ''); + setGender(user?.gender || null); + }, [user?.name, user?.gender]); + + const handleSave = async () => { + if (!user?.id) return; + + const trimmedName = name.trim(); + if (!trimmedName) { + Alert.alert('Error', 'Name cannot be empty'); + return; + } + + setIsSaving(true); + + try { + const updates: Record = { + updated_at: new Date().toISOString(), + }; + + const changedFields: string[] = []; + + if (trimmedName !== user.name) { + updates.name = trimmedName; + changedFields.push('name'); + } + + if (gender !== user.gender) { + updates.gender = gender; + changedFields.push('gender'); + } + + if (changedFields.length === 0) { + setIsSaving(false); + return; + } + + const { error } = await supabase + .from('profiles') + .update(updates) + .eq('id', user.id); + + if (error) throw error; + + await fetchUser(); + Events.profileUpdated(changedFields); + Alert.alert('Success', 'Profile updated successfully'); + } catch (error) { + console.error('Failed to update profile:', error); + Alert.alert('Error', 'Failed to update profile. Please try again.'); + } finally { + setIsSaving(false); + } + }; + + return ( + + + {isSaving ? ( + + ) : ( + Save + )} + + ) : undefined + } + /> + + {/* Avatar Section */} + + + + {user?.avatar_url ? ( + + ) : ( + + + {user?.name?.[0]?.toUpperCase() || 'U'} + + + )} + + {settings.isUploadingAvatar ? ( + + + + ) : ( + + + + )} + + Tap to change photo + + + {/* Name Section */} + + + + + + + This is how your partner will see you + + + + {/* Gender Section */} + + + This helps us show you the right questions + + + {GENDER_OPTIONS.map((option) => ( + setGender(option.value)} + > + + + {option.label} + + + ))} + + + + {/* Email (read-only) */} + + + + {authEmail || 'Loading...'} + + + Email cannot be changed + + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + content: { + paddingTop: spacing.lg, + paddingBottom: Platform.OS === 'ios' ? 40 : 20, + }, + saveButton: { + ...typography.headline, + color: featureColors.profile.accent, + }, + // Avatar + avatarSection: { + alignItems: 'center', + marginBottom: spacing.xl, + }, + avatarTouchable: { + marginBottom: spacing.sm, + }, + avatarGradient: { + width: 120, + height: 120, + borderRadius: 60, + padding: 4, + justifyContent: 'center', + alignItems: 'center', + shadowColor: featureColors.profile.accent, + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 6, + }, + avatarImage: { + width: '100%', + height: '100%', + borderRadius: 60, + backgroundColor: colors.background, + }, + avatarInner: { + width: '100%', + height: '100%', + borderRadius: 60, + backgroundColor: colors.background, + justifyContent: 'center', + alignItems: 'center', + }, + avatarText: { + ...typography.largeTitle, + fontSize: 48, + color: colors.text, + }, + avatarOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + borderRadius: 60, + backgroundColor: 'rgba(0,0,0,0.5)', + justifyContent: 'center', + alignItems: 'center', + }, + avatarEditBadge: { + position: 'absolute', + bottom: 4, + right: 4, + backgroundColor: colors.surfaceSolid, + width: 36, + height: 36, + borderRadius: 18, + justifyContent: 'center', + alignItems: 'center', + borderWidth: 3, + borderColor: colors.background, + }, + avatarHint: { + ...typography.caption1, + color: colors.textTertiary, + }, + // Input + inputContainer: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.glass.background, + borderRadius: radius.md, + borderWidth: 1, + borderColor: colors.glass.border, + paddingHorizontal: spacing.md, + }, + inputIcon: { + marginRight: spacing.sm, + }, + input: { + flex: 1, + ...typography.body, + color: colors.text, + paddingVertical: spacing.md, + }, + inputHint: { + ...typography.caption1, + color: colors.textTertiary, + marginTop: spacing.sm, + marginLeft: spacing.xs, + }, + // Read-only field + readOnlyField: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.glass.background, + borderRadius: radius.md, + borderWidth: 1, + borderColor: colors.glass.border, + paddingHorizontal: spacing.md, + paddingVertical: spacing.md, + opacity: 0.7, + }, + readOnlyText: { + ...typography.body, + color: colors.textSecondary, + }, + // Gender + genderDescription: { + ...typography.caption1, + color: colors.textSecondary, + marginBottom: spacing.md, + }, + genderGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: spacing.sm, + }, + genderOption: { + width: '48%', + alignItems: 'center', + backgroundColor: colors.glass.background, + borderRadius: radius.lg, + borderWidth: 2, + borderColor: colors.glass.border, + paddingVertical: spacing.md, + paddingHorizontal: spacing.sm, + gap: spacing.xs, + }, + genderOptionSelected: { + borderColor: colors.primary, + backgroundColor: colors.primaryLight, + }, + genderLabel: { + ...typography.caption1, + color: colors.textSecondary, + textAlign: 'center', + }, + genderLabelSelected: { + color: colors.primary, + fontWeight: '600', + }, + bottomSpacer: { + height: spacing.xl, + }, +}); + +export default ProfileSettingsScreen; diff --git a/apps/mobile/src/features/profile/screens/SubscriptionScreen.tsx b/apps/mobile/src/features/profile/screens/SubscriptionScreen.tsx new file mode 100644 index 0000000..57b10bb --- /dev/null +++ b/apps/mobile/src/features/profile/screens/SubscriptionScreen.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { ScrollView, StyleSheet, Platform } from 'react-native'; + +import { GradientBackground } from '../../../components/ui'; +import { Paywall } from '../../../components/paywall'; +import { spacing } from '../../../theme'; +import { useAuthStore, useSubscriptionStore } from '../../../store'; +import { useProfileSettings } from '../hooks'; +import { ScreenHeader, SubscriptionCard } from '../components'; + +/** + * Subscription management sub-screen. + */ +export function SubscriptionScreen() { + const { user, partner } = useAuthStore(); + const { subscription } = useSubscriptionStore(); + const settings = useProfileSettings(); + + // Use OR: if RevenueCat says they're subscribed OR database says premium, it's their own + const isOwnSubscription = subscription.isProUser || user?.is_premium; + const hasPremiumAccess = user?.is_premium || partner?.is_premium || subscription.isProUser; + + const formatExpirationDate = (date: Date | null | string) => { + if (!date) return "Never"; + const d = new Date(date); + return d.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); + }; + + return ( + + + + settings.setShowPaywall(true)} + onManagePress={settings.handleManageSubscription} + onRestorePress={settings.handleRestorePurchases} + isRestoring={settings.isPurchasing} + delay={0} + /> + + settings.setShowPaywall(false)} + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + content: { + paddingTop: spacing.lg, + paddingBottom: Platform.OS === 'ios' ? 40 : 20, + }, +}); + +export default SubscriptionScreen; diff --git a/apps/mobile/src/features/profile/screens/SupportScreen.tsx b/apps/mobile/src/features/profile/screens/SupportScreen.tsx new file mode 100644 index 0000000..4d554d1 --- /dev/null +++ b/apps/mobile/src/features/profile/screens/SupportScreen.tsx @@ -0,0 +1,68 @@ +import React, { useState } from 'react'; +import { View, ScrollView, StyleSheet, Platform, Linking } from 'react-native'; + +import { GradientBackground } from '../../../components/ui'; +import { FeedbackModal } from '../../../components/feedback'; +import { colors, spacing } from '../../../theme'; +import { ScreenHeader, SettingsSection, MenuItem } from '../components'; + +/** + * Help & Support sub-screen. + */ +export function SupportScreen() { + const [showFeedbackModal, setShowFeedbackModal] = useState(false); + + return ( + + + + + setShowFeedbackModal(true)} + /> + + Linking.openURL('https://sauci.app/privacy')} + /> + + Linking.openURL('https://sauci.app/terms')} + /> + + + + setShowFeedbackModal(false)} + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + content: { + paddingTop: spacing.lg, + paddingBottom: Platform.OS === 'ios' ? 40 : 20, + }, + divider: { + height: 1, + backgroundColor: colors.glass.border, + marginVertical: spacing.md, + }, +}); + +export default SupportScreen; diff --git a/apps/mobile/src/features/profile/screens/index.ts b/apps/mobile/src/features/profile/screens/index.ts new file mode 100644 index 0000000..9e44c21 --- /dev/null +++ b/apps/mobile/src/features/profile/screens/index.ts @@ -0,0 +1,8 @@ +// Profile sub-screens +export { ProfileSettingsScreen } from './ProfileSettingsScreen'; +export { NotificationsScreen } from './NotificationsScreen'; +export { PreferencesScreen } from './PreferencesScreen'; +export { SubscriptionScreen } from './SubscriptionScreen'; +export { PartnerScreen } from './PartnerScreen'; +export { SupportScreen } from './SupportScreen'; +export { AccountScreen } from './AccountScreen'; diff --git a/apps/mobile/src/lib/analytics.ts b/apps/mobile/src/lib/analytics.ts index 0007a8e..79c7e52 100644 --- a/apps/mobile/src/lib/analytics.ts +++ b/apps/mobile/src/lib/analytics.ts @@ -172,15 +172,27 @@ export function logScreenView(screenName: string, screenClass?: string) { } } -// Flush events (useful before app backgrounding) -export function flush() { - // Firebase Analytics batches and sends events automatically - // No manual flush needed, but we keep the function for API compatibility - if (__DEV__) console.log("[Analytics] Flush called (no-op for Firebase)"); -} - -// Pre-defined event helpers for common actions -export const Events = { +// Flush events (useful before app backgrounding) +export function flush() { + // Firebase Analytics batches and sends events automatically + // No manual flush needed, but we keep the function for API compatibility + if (__DEV__) console.log("[Analytics] Flush called (no-op for Firebase)"); +} + +function truncateParam(value: string, maxLength = 100) { + if (value.length <= maxLength) return value; + return value.slice(0, maxLength - 1) + "…"; +} + +function safeParam(value: unknown, maxLength = 100): string { + if (typeof value === "string") return truncateParam(value, maxLength); + if (typeof value === "number" || typeof value === "boolean") return String(value); + if (value instanceof Error) return truncateParam(value.message, maxLength); + return value == null ? "" : truncateParam(String(value), maxLength); +} + +// Pre-defined event helpers for common actions +export const Events = { // Auth events signUp: (method: string) => logEvent("sign_up", { method }), signIn: (method: string) => logEvent("login", { method }), @@ -210,13 +222,26 @@ export const Events = { messageSent: () => logEvent("message_sent"), mediaUploaded: () => logEvent("chat_media_uploaded"), - // Subscription events - paywallShown: (source: string) => logEvent("paywall_shown", { source }), - purchaseInitiated: (packageType: string) => - logEvent("purchase_initiated", { package_type: packageType }), - purchaseCompleted: (packageType: string) => - logEvent("purchase", { package_type: packageType }), - purchaseRestored: () => logEvent("purchase_restored"), + // Subscription events + paywallShown: (source: string) => logEvent("paywall_shown", { source }), + paywallClosed: (source: string, reason: string) => + logEvent("paywall_closed", { source, reason }), + purchaseInitiated: (packageType: string) => + logEvent("purchase_initiated", { package_type: packageType }), + purchaseCancelled: (packageType: string, errorCode?: unknown) => + logEvent("purchase_cancelled", { + package_type: packageType, + error_code: safeParam(errorCode) || "unknown", + }), + purchaseFailed: (packageType: string, errorCode?: unknown, errorMessage?: unknown) => + logEvent("purchase_failed", { + package_type: packageType, + error_code: safeParam(errorCode) || "unknown", + error_message: safeParam(errorMessage), + }), + purchaseCompleted: (packageType: string) => + logEvent("purchase", { package_type: packageType }), + purchaseRestored: () => logEvent("purchase_restored"), // Pack events packEnabled: (packId: string) => logEvent("pack_enabled", { pack_id: packId }), diff --git a/apps/mobile/src/lib/badge.ts b/apps/mobile/src/lib/badge.ts new file mode 100644 index 0000000..f56a2e2 --- /dev/null +++ b/apps/mobile/src/lib/badge.ts @@ -0,0 +1,57 @@ +import * as Notifications from 'expo-notifications'; +import { Platform } from 'react-native'; + +/** + * App icon badge management utility. + * Sets the badge count on the app icon (iOS only, Android has no equivalent). + */ + +/** + * Set the app icon badge count. + * On iOS, this updates the red badge number on the app icon. + * On Android, this is a no-op as Android doesn't have app icon badges. + */ +export async function setBadgeCount(count: number): Promise { + // Badge count is iOS only + if (Platform.OS !== 'ios') { + return; + } + + try { + await Notifications.setBadgeCountAsync(Math.max(0, count)); + } catch (error) { + console.error('Failed to set badge count:', error); + } +} + +/** + * Clear the app icon badge. + */ +export async function clearBadge(): Promise { + await setBadgeCount(0); +} + +/** + * Get the current badge count. + */ +export async function getBadgeCount(): Promise { + if (Platform.OS !== 'ios') { + return 0; + } + + try { + return await Notifications.getBadgeCountAsync(); + } catch (error) { + console.error('Failed to get badge count:', error); + return 0; + } +} + +/** + * Sync badge count with the total of new matches and unread messages. + * This should be called whenever either count changes. + */ +export async function syncBadgeCount(newMatchesCount: number, unreadMessagesCount: number): Promise { + const total = newMatchesCount + unreadMessagesCount; + await setBadgeCount(total); +} diff --git a/apps/mobile/src/store/authStore.ts b/apps/mobile/src/store/authStore.ts index 1b1cfaa..3c580eb 100644 --- a/apps/mobile/src/store/authStore.ts +++ b/apps/mobile/src/store/authStore.ts @@ -17,6 +17,7 @@ interface AuthState { refreshPartner: () => Promise; signOut: () => Promise; setUser: (user: Profile | null) => void; + updateLastActive: () => Promise; } // Import other stores lazily to avoid circular dependency issues @@ -25,7 +26,9 @@ const getOtherStores = () => { const { usePacksStore } = require("./packsStore"); const { useMessageStore } = require("./messageStore"); const { useSubscriptionStore } = require("./subscriptionStore"); - return { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore }; + const { useNotificationPreferencesStore } = require("./notificationPreferencesStore"); + const { useStreakStore } = require("./streakStore"); + return { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore }; }; @@ -56,11 +59,13 @@ export const useAuthStore = create((set, get) => ({ // Session is invalid - clear everything set({ user: null, couple: null, partner: null, isAuthenticated: false, isLoading: false }); // Clear other stores - const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore } = getOtherStores(); + const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores(); useMatchStore.getState().clearMatches(); usePacksStore.getState().clearPacks(); useMessageStore.getState().clearMessages(); useSubscriptionStore.getState().clearSubscription(); + useNotificationPreferencesStore.getState().clearPreferences(); + useStreakStore.getState().clearStreak(); // Sign out from Supabase (clears storage) await supabase.auth.signOut(); return; @@ -145,11 +150,17 @@ export const useAuthStore = create((set, get) => ({ isLoading: false, }); // Clear other stores - const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore } = getOtherStores(); + const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores(); useMatchStore.getState().clearMatches(); usePacksStore.getState().clearPacks(); useMessageStore.getState().clearMessages(); useSubscriptionStore.getState().clearSubscription(); + useNotificationPreferencesStore.getState().clearPreferences(); + useStreakStore.getState().clearStreak(); + + // Clear badge on sign out + const { clearBadge } = require("../lib/badge"); + await clearBadge(); // Then try to sign out from Supabase (don't block on this) try { @@ -169,11 +180,36 @@ export const useAuthStore = create((set, get) => ({ }); // Clear other stores when user signs out if (user === null) { - const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore } = getOtherStores(); + const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores(); useMatchStore.getState().clearMatches(); usePacksStore.getState().clearPacks(); useMessageStore.getState().clearMessages(); useSubscriptionStore.getState().clearSubscription(); + useNotificationPreferencesStore.getState().clearPreferences(); + useStreakStore.getState().clearStreak(); + // Clear badge on sign out + const { clearBadge } = require("../lib/badge"); + clearBadge(); + } + }, + + /** + * Update the user's last_active_at timestamp. + * Called when the app comes to the foreground to track user activity. + * This helps prevent sending notifications to users who are already in the app. + */ + updateLastActive: async () => { + const userId = get().user?.id; + if (!userId) return; + + try { + await supabase + .from("profiles") + .update({ last_active_at: new Date().toISOString() }) + .eq("id", userId); + } catch (error) { + // Silently fail - this is not critical + console.error("Failed to update last_active_at:", error); } }, })); diff --git a/apps/mobile/src/store/index.ts b/apps/mobile/src/store/index.ts index 55ed38a..7795e2e 100755 --- a/apps/mobile/src/store/index.ts +++ b/apps/mobile/src/store/index.ts @@ -6,3 +6,7 @@ export { useMessageStore } from "./messageStore"; export { useSubscriptionStore } from "./subscriptionStore"; export { useResponsesStore, groupResponses } from "./responsesStore"; export type { ResponseWithQuestion, UpdateResponseResult, GroupByOption, DateSortOrder } from "./responsesStore"; +export { useNotificationPreferencesStore } from "./notificationPreferencesStore"; +export type { NotificationPreferences } from "./notificationPreferencesStore"; +export { useStreakStore } from "./streakStore"; +export type { CoupleStreak } from "./streakStore"; diff --git a/apps/mobile/src/store/notificationPreferencesStore.ts b/apps/mobile/src/store/notificationPreferencesStore.ts new file mode 100644 index 0000000..1c106cd --- /dev/null +++ b/apps/mobile/src/store/notificationPreferencesStore.ts @@ -0,0 +1,95 @@ +import { create } from "zustand"; +import { supabase } from "../lib/supabase"; +import { useAuthStore } from "./authStore"; + +export interface NotificationPreferences { + user_id: string; + matches_enabled: boolean; + messages_enabled: boolean; + partner_activity_enabled: boolean; + pack_changes_enabled: boolean; + new_packs_enabled: boolean; + streak_milestones_enabled: boolean; + created_at: string; + updated_at: string; +} + +interface NotificationPreferencesState { + preferences: NotificationPreferences | null; + isLoading: boolean; + isUpdating: boolean; + error: string | null; + + // Actions + fetchPreferences: () => Promise; + updatePreference: (key: keyof Omit, value: boolean) => Promise; + clearPreferences: () => void; +} + +export const useNotificationPreferencesStore = create((set, get) => ({ + preferences: null, + isLoading: false, + isUpdating: false, + error: null, + + fetchPreferences: async () => { + const userId = useAuthStore.getState().user?.id; + if (!userId) { + set({ preferences: null, isLoading: false }); + return; + } + + set({ isLoading: true, error: null }); + + try { + // Use the RPC function to get or create preferences + const { data, error } = await supabase + .rpc('get_or_create_notification_preferences', { p_user_id: userId }); + + if (error) throw error; + + set({ preferences: data, isLoading: false }); + } catch (error) { + console.error('Error fetching notification preferences:', error); + set({ error: 'Failed to load notification preferences', isLoading: false }); + } + }, + + updatePreference: async (key, value) => { + const userId = useAuthStore.getState().user?.id; + const currentPreferences = get().preferences; + + if (!userId) return; + + // Optimistic update + if (currentPreferences) { + set({ + preferences: { ...currentPreferences, [key]: value }, + isUpdating: true, + }); + } + + try { + const { error } = await supabase + .from('notification_preferences') + .update({ [key]: value }) + .eq('user_id', userId); + + if (error) throw error; + + set({ isUpdating: false }); + } catch (error) { + console.error('Error updating notification preference:', error); + // Revert optimistic update + set({ + preferences: currentPreferences, + isUpdating: false, + error: 'Failed to update preference', + }); + } + }, + + clearPreferences: () => { + set({ preferences: null, isLoading: false, isUpdating: false, error: null }); + }, +})); diff --git a/apps/mobile/src/store/streakStore.ts b/apps/mobile/src/store/streakStore.ts new file mode 100644 index 0000000..4abe587 --- /dev/null +++ b/apps/mobile/src/store/streakStore.ts @@ -0,0 +1,60 @@ +import { create } from "zustand"; +import { supabase } from "../lib/supabase"; +import { useAuthStore } from "./authStore"; + +export interface CoupleStreak { + couple_id: string; + current_streak: number; + longest_streak: number; + last_active_date: string | null; + user1_answered_today: boolean; + user2_answered_today: boolean; + streak_celebrated_at: number; + created_at: string; + updated_at: string; +} + +interface StreakState { + streak: CoupleStreak | null; + isLoading: boolean; + error: string | null; + + // Actions + fetchStreak: () => Promise; + clearStreak: () => void; +} + +export const useStreakStore = create((set) => ({ + streak: null, + isLoading: false, + error: null, + + fetchStreak: async () => { + const coupleId = useAuthStore.getState().user?.couple_id; + if (!coupleId) { + set({ streak: null, isLoading: false }); + return; + } + + set({ isLoading: true, error: null }); + + try { + const { data, error } = await supabase + .from("couple_streaks") + .select("*") + .eq("couple_id", coupleId) + .maybeSingle(); + + if (error) throw error; + + set({ streak: data, isLoading: false }); + } catch (error) { + console.error("Error fetching streak:", error); + set({ error: "Failed to load streak", isLoading: false }); + } + }, + + clearStreak: () => { + set({ streak: null, isLoading: false, error: null }); + }, +})); diff --git a/apps/mobile/src/store/subscriptionStore.ts b/apps/mobile/src/store/subscriptionStore.ts index 88899d2..0ddcbe0 100644 --- a/apps/mobile/src/store/subscriptionStore.ts +++ b/apps/mobile/src/store/subscriptionStore.ts @@ -7,21 +7,28 @@ import revenueCatService, { } from "../lib/revenuecat"; import { useAuthStore } from "./authStore"; -interface SubscriptionStoreState { - subscription: SubscriptionState; - offerings: PurchasesOffering | null; - isLoadingOfferings: boolean; - isPurchasing: boolean; - error: string | null; - - // Actions - initializeRevenueCat: (userId: string) => Promise; - fetchOfferings: () => Promise; - purchasePackage: (pkg: PurchasesPackage) => Promise; - restorePurchases: () => Promise; - refreshSubscriptionStatus: () => Promise; - clearSubscription: () => void; -} +export type PurchaseResult = { + success: boolean; + cancelled?: boolean; + errorCode?: unknown; + errorMessage?: unknown; +}; + +interface SubscriptionStoreState { + subscription: SubscriptionState; + offerings: PurchasesOffering | null; + isLoadingOfferings: boolean; + isPurchasing: boolean; + error: string | null; + + // Actions + initializeRevenueCat: (userId: string) => Promise; + fetchOfferings: () => Promise; + purchasePackage: (pkg: PurchasesPackage) => Promise; + restorePurchases: () => Promise; + refreshSubscriptionStatus: () => Promise; + clearSubscription: () => void; +} const defaultSubscription: SubscriptionState = { isProUser: false, @@ -121,26 +128,36 @@ export const useSubscriptionStore = create((set, get) => } }, - purchasePackage: async (pkg: PurchasesPackage) => { - set({ isPurchasing: true, error: null }); - try { - const customerInfo = await revenueCatService.purchasePackage(pkg); - const subscription = revenueCatService.parseCustomerInfo(customerInfo); - set({ subscription, isPurchasing: false }); - - // Refresh user profile to get updated is_premium - await useAuthStore.getState().fetchUser(); - - return true; - } catch (error: any) { - const message = - error.message === "Purchase cancelled" - ? "Purchase was cancelled" - : "Purchase failed. Please try again."; - set({ error: message, isPurchasing: false }); - return false; - } - }, + purchasePackage: async (pkg: PurchasesPackage) => { + set({ isPurchasing: true, error: null }); + try { + const customerInfo = await revenueCatService.purchasePackage(pkg); + const subscription = revenueCatService.parseCustomerInfo(customerInfo); + set({ subscription, isPurchasing: false }); + + // Refresh user profile to get updated is_premium + await useAuthStore.getState().fetchUser(); + + return { success: true }; + } catch (error: any) { + const isCancelled = + error?.userCancelled === true || + error?.message === "Purchase cancelled" || + error?.code === "PURCHASE_CANCELLED"; + + const message = isCancelled + ? "Purchase was cancelled" + : "Purchase failed. Please try again."; + + set({ error: message, isPurchasing: false }); + return { + success: false, + cancelled: isCancelled, + errorCode: error?.code, + errorMessage: error?.message, + }; + } + }, restorePurchases: async () => { set({ isPurchasing: true, error: null }); diff --git a/apps/supabase/functions/check-streak-milestones/index.ts b/apps/supabase/functions/check-streak-milestones/index.ts new file mode 100644 index 0000000..23a1cf3 --- /dev/null +++ b/apps/supabase/functions/check-streak-milestones/index.ts @@ -0,0 +1,206 @@ +import "jsr:@supabase/functions-js/edge-runtime.d.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; + +/** + * Check for streak milestones and send celebration notifications. + * Called by pg_cron at 00:05 UTC daily. + * + * Milestones: 7, 14, 30, 60, 100 days + * + * Key principle: Celebrate achievements, never guilt-trip about breaks. + */ + +const MILESTONE_DAYS = [7, 14, 30, 60, 100]; + +interface ExpoPushMessage { + to: string; + title: string; + body: string; + sound?: string; + data?: Record; +} + +async function sendExpoPushNotifications(messages: ExpoPushMessage[]) { + if (messages.length === 0) return; + + // Expo accepts max 100 messages per request + const chunks: ExpoPushMessage[][] = []; + for (let i = 0; i < messages.length; i += 100) { + chunks.push(messages.slice(i, i + 100)); + } + + for (const chunk of chunks) { + const response = await fetch("https://exp.host/--/api/v2/push/send", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(chunk), + }); + + if (!response.ok) { + const error = await response.text(); + console.error("Expo push error:", error); + } + } +} + +function getStreakMessage(days: number): { title: string; body: string } { + switch (days) { + case 7: + return { + title: "1 week streak!", + body: "You and your partner have been connecting for 7 days straight. Keep it up!", + }; + case 14: + return { + title: "2 week streak!", + body: "Two weeks of daily connection. You two are on fire!", + }; + case 30: + return { + title: "1 month streak!", + body: "A whole month of daily connection! You're building something special.", + }; + case 60: + return { + title: "2 month streak!", + body: "60 days of daily connection. Your commitment is inspiring!", + }; + case 100: + return { + title: "100 day streak!", + body: "100 days! You've made connection a habit. Incredible!", + }; + default: + return { + title: `${days} day streak!`, + body: `${days} days of connecting with your partner. Amazing!`, + }; + } +} + +Deno.serve(async (req) => { + // Only allow POST (from cron) or GET (for manual testing) + if (req.method !== "POST" && req.method !== "GET") { + return new Response("Method not allowed", { status: 405 }); + } + + const supabase = createClient( + Deno.env.get("SUPABASE_URL")!, + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")! + ); + + try { + // Get all streaks that hit a milestone and haven't been celebrated yet + const { data: streaks, error: fetchError } = await supabase + .from("couple_streaks") + .select("couple_id, current_streak, streak_celebrated_at") + .in("current_streak", MILESTONE_DAYS) + .filter("current_streak", "gt", supabase.rpc("coalesce_int", { val: "streak_celebrated_at", fallback: 0 })); + + // Actually, we need a different approach since we can't use coalesce in filters + // Let's fetch all and filter in code + const { data: allStreaks, error: allStreaksError } = await supabase + .from("couple_streaks") + .select("couple_id, current_streak, streak_celebrated_at"); + + if (allStreaksError) { + console.error("Error fetching streaks:", allStreaksError); + return new Response( + JSON.stringify({ error: allStreaksError.message }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } + + // Filter for uncelebrated milestones + const uncelebratedMilestones = (allStreaks || []).filter(streak => + MILESTONE_DAYS.includes(streak.current_streak) && + streak.current_streak > (streak.streak_celebrated_at || 0) + ); + + if (uncelebratedMilestones.length === 0) { + return new Response( + JSON.stringify({ celebrated: 0, message: "No milestones to celebrate" }), + { headers: { "Content-Type": "application/json" } } + ); + } + + const messages: ExpoPushMessage[] = []; + const celebratedCouples: string[] = []; + + for (const streak of uncelebratedMilestones) { + // Get both partners' profiles + const { data: profiles, error: profilesError } = await supabase + .from("profiles") + .select(` + id, + push_token, + notification_preferences:notification_preferences(streak_milestones_enabled) + `) + .eq("couple_id", streak.couple_id); + + if (profilesError) { + console.error(`Error fetching profiles for couple ${streak.couple_id}:`, profilesError); + continue; + } + + const { title, body } = getStreakMessage(streak.current_streak); + + for (const profile of profiles || []) { + // Check notification preferences + const prefs = Array.isArray(profile.notification_preferences) + ? profile.notification_preferences[0] + : profile.notification_preferences; + + // Skip if user has explicitly disabled streak milestone notifications + if (prefs && prefs.streak_milestones_enabled === false) { + continue; + } + + if (profile.push_token) { + messages.push({ + to: profile.push_token, + title, + body, + sound: "default", + data: { + type: "streak_milestone", + streak: streak.current_streak, + }, + }); + } + } + + celebratedCouples.push(streak.couple_id); + + // Mark milestone as celebrated + await supabase + .from("couple_streaks") + .update({ streak_celebrated_at: streak.current_streak }) + .eq("couple_id", streak.couple_id); + } + + // Send all notifications + if (messages.length > 0) { + await sendExpoPushNotifications(messages); + } + + console.log(`Celebrated ${celebratedCouples.length} milestones, sent ${messages.length} notifications`); + + return new Response( + JSON.stringify({ + celebrated: celebratedCouples.length, + notificationsSent: messages.length, + }), + { headers: { "Content-Type": "application/json" } } + ); + } catch (err) { + console.error("Unexpected error:", err); + return new Response( + JSON.stringify({ error: "Internal server error" }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } +}); diff --git a/apps/supabase/functions/process-scheduled-releases/index.ts b/apps/supabase/functions/process-scheduled-releases/index.ts new file mode 100644 index 0000000..0b93b04 --- /dev/null +++ b/apps/supabase/functions/process-scheduled-releases/index.ts @@ -0,0 +1,170 @@ +import "jsr:@supabase/functions-js/edge-runtime.d.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; + +/** + * Process scheduled pack releases. + * Called by pg_cron every 5 minutes to check for packs with scheduled_release_at in the past. + * + * When a pack's release time arrives: + * 1. Set is_public = true to make the pack available + * 2. Send notification to all users who have new_packs_enabled + * 3. Set release_notified = true to prevent duplicate notifications + */ + +interface ExpoPushMessage { + to: string; + title: string; + body: string; + sound?: string; + data?: Record; +} + +async function sendExpoPushNotifications(messages: ExpoPushMessage[]) { + if (messages.length === 0) return; + + // Expo accepts max 100 messages per request + const chunks: ExpoPushMessage[][] = []; + for (let i = 0; i < messages.length; i += 100) { + chunks.push(messages.slice(i, i + 100)); + } + + for (const chunk of chunks) { + const response = await fetch("https://exp.host/--/api/v2/push/send", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(chunk), + }); + + if (!response.ok) { + const error = await response.text(); + console.error("Expo push error:", error); + // Continue with other chunks even if one fails + } + } +} + +Deno.serve(async (req) => { + // Only allow POST (from cron) or GET (for manual testing) + if (req.method !== "POST" && req.method !== "GET") { + return new Response("Method not allowed", { status: 405 }); + } + + const supabase = createClient( + Deno.env.get("SUPABASE_URL")!, + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")! + ); + + try { + // Get all packs due for release + const { data: duePacks, error: fetchError } = await supabase + .from("question_packs") + .select("id, name, description") + .eq("is_public", false) + .eq("release_notified", false) + .not("scheduled_release_at", "is", null) + .lte("scheduled_release_at", new Date().toISOString()); + + if (fetchError) { + console.error("Error fetching due packs:", fetchError); + return new Response( + JSON.stringify({ error: fetchError.message }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } + + if (!duePacks || duePacks.length === 0) { + return new Response( + JSON.stringify({ released: 0, message: "No packs due for release" }), + { headers: { "Content-Type": "application/json" } } + ); + } + + const packIds = duePacks.map(p => p.id); + + // Make packs public + const { error: updateError } = await supabase + .from("question_packs") + .update({ + is_public: true, + release_notified: true, + }) + .in("id", packIds); + + if (updateError) { + console.error("Error updating packs:", updateError); + return new Response( + JSON.stringify({ error: updateError.message }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } + + // Get all users with push tokens who want new pack notifications + // We join with notification_preferences and filter for new_packs_enabled = true (or null, which defaults to true) + const { data: users, error: usersError } = await supabase + .from("profiles") + .select(` + id, + push_token, + notification_preferences:notification_preferences(new_packs_enabled) + `) + .not("push_token", "is", null); + + if (usersError) { + console.error("Error fetching users:", usersError); + // Packs are already released, just log error and continue + } + + const messages: ExpoPushMessage[] = []; + + if (users && users.length > 0) { + for (const user of users) { + // Check notification preferences + // notification_preferences is an array (due to how Supabase returns joined data) + const prefs = Array.isArray(user.notification_preferences) + ? user.notification_preferences[0] + : user.notification_preferences; + + // Skip if user has explicitly disabled new_packs notifications + if (prefs && prefs.new_packs_enabled === false) { + continue; + } + + // Send notification for each released pack + for (const pack of duePacks) { + messages.push({ + to: user.push_token!, + title: "New pack available", + body: `${pack.name} is now available to play!`, + sound: "default", + data: { type: "new_pack", packId: pack.id }, + }); + } + } + } + + // Send all notifications + if (messages.length > 0) { + await sendExpoPushNotifications(messages); + } + + console.log(`Released ${duePacks.length} packs, sent ${messages.length} notifications`); + + return new Response( + JSON.stringify({ + released: duePacks.length, + packNames: duePacks.map(p => p.name), + notificationsSent: messages.length, + }), + { headers: { "Content-Type": "application/json" } } + ); + } catch (err) { + console.error("Unexpected error:", err); + return new Response( + JSON.stringify({ error: "Internal server error" }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } +}); diff --git a/apps/supabase/functions/send-partner-activity-notification/index.ts b/apps/supabase/functions/send-partner-activity-notification/index.ts new file mode 100644 index 0000000..06317d8 --- /dev/null +++ b/apps/supabase/functions/send-partner-activity-notification/index.ts @@ -0,0 +1,199 @@ +import "jsr:@supabase/functions-js/edge-runtime.d.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; + +/** + * Send batched partner activity notifications. + * Called by pg_cron every 5 minutes to check for pending notifications + * where notify_at has passed. + * + * Notifications are sent when a user answers questions. The notification + * is delayed by 1 hour, with the timer resetting on each new response. + * This prevents spam when a user answers multiple questions in quick succession. + * + * Skips sending if the partner was active in the last 15 minutes (they're already in app). + */ + +interface ExpoPushMessage { + to: string; + title: string; + body: string; + sound?: string; + data?: Record; +} + +async function sendExpoPushNotifications(messages: ExpoPushMessage[]) { + if (messages.length === 0) return; + + const response = await fetch("https://exp.host/--/api/v2/push/send", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(messages), + }); + + if (!response.ok) { + const error = await response.text(); + console.error("Expo push error:", error); + throw new Error(`Failed to send push notification: ${error}`); + } + + return response.json(); +} + +Deno.serve(async (req) => { + // Only allow POST (from cron) or GET (for manual testing) + if (req.method !== "POST" && req.method !== "GET") { + return new Response("Method not allowed", { status: 405 }); + } + + const supabase = createClient( + Deno.env.get("SUPABASE_URL")!, + Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")! + ); + + try { + // Get all pending notifications where notify_at has passed + const { data: pendingNotifications, error: fetchError } = await supabase + .from("pending_activity_notifications") + .select("id, couple_id, active_user_id, response_count, notify_at") + .lte("notify_at", new Date().toISOString()); + + if (fetchError) { + console.error("Error fetching pending notifications:", fetchError); + return new Response( + JSON.stringify({ error: fetchError.message }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } + + if (!pendingNotifications || pendingNotifications.length === 0) { + return new Response( + JSON.stringify({ sent: 0, message: "No pending notifications" }), + { headers: { "Content-Type": "application/json" } } + ); + } + + const messages: ExpoPushMessage[] = []; + const processedIds: string[] = []; + const skippedPartnerActive: string[] = []; + const skippedNoToken: string[] = []; + const skippedPrefsDisabled: string[] = []; + + // Time threshold for "partner is active" check (15 minutes ago) + const activeThreshold = new Date(Date.now() - 15 * 60 * 1000).toISOString(); + + for (const notification of pendingNotifications) { + // Get the partner (not the user who made changes) + const { data: partner, error: partnerError } = await supabase + .from("profiles") + .select("id, push_token, name, last_active_at") + .eq("couple_id", notification.couple_id) + .neq("id", notification.active_user_id) + .maybeSingle(); + + if (partnerError) { + console.error( + `Error fetching partner for couple ${notification.couple_id}:`, + partnerError + ); + continue; + } + + // Skip if partner was active recently (they're already in the app) + if (partner?.last_active_at && partner.last_active_at > activeThreshold) { + console.log( + `Skipping notification for couple ${notification.couple_id}: partner was active recently` + ); + skippedPartnerActive.push(notification.id); + processedIds.push(notification.id); // Still delete the pending notification + continue; + } + + // Skip if no push token + if (!partner?.push_token) { + skippedNoToken.push(notification.id); + processedIds.push(notification.id); + continue; + } + + // Check notification preferences + const { data: prefs } = await supabase + .from("notification_preferences") + .select("partner_activity_enabled") + .eq("user_id", partner.id) + .maybeSingle(); + + // Skip if partner has disabled partner_activity notifications + if (prefs && prefs.partner_activity_enabled === false) { + skippedPrefsDisabled.push(notification.id); + processedIds.push(notification.id); + continue; + } + + // Get the active user's name for the notification + const { data: activeUser } = await supabase + .from("profiles") + .select("name") + .eq("id", notification.active_user_id) + .maybeSingle(); + + const userName = activeUser?.name || "Your partner"; + const responseCount = notification.response_count; + + // Craft the notification message + const body = responseCount === 1 + ? `${userName} answered a question` + : `${userName} answered ${responseCount} questions`; + + messages.push({ + to: partner.push_token, + title: "Partner activity", + body, + sound: "default", + data: { type: "partner_activity" }, + }); + + processedIds.push(notification.id); + } + + // Send all notifications + if (messages.length > 0) { + await sendExpoPushNotifications(messages); + } + + // Delete processed notifications + if (processedIds.length > 0) { + const { error: deleteError } = await supabase + .from("pending_activity_notifications") + .delete() + .in("id", processedIds); + + if (deleteError) { + console.error("Error deleting processed notifications:", deleteError); + } + } + + console.log(`Sent ${messages.length} partner activity notifications, skipped: partner active=${skippedPartnerActive.length}, no token=${skippedNoToken.length}, prefs disabled=${skippedPrefsDisabled.length}`); + + return new Response( + JSON.stringify({ + sent: messages.length, + processed: processedIds.length, + skipped: { + partnerActive: skippedPartnerActive.length, + noToken: skippedNoToken.length, + prefsDisabled: skippedPrefsDisabled.length, + }, + }), + { headers: { "Content-Type": "application/json" } } + ); + } catch (err) { + console.error("Unexpected error:", err); + return new Response( + JSON.stringify({ error: "Internal server error" }), + { status: 500, headers: { "Content-Type": "application/json" } } + ); + } +}); diff --git a/apps/supabase/migrations/20251230000000_v1_initial_schema.sql b/apps/supabase/migrations/20251230000000_v1_initial_schema.sql index 5c34fe0..c64e4d8 100644 --- a/apps/supabase/migrations/20251230000000_v1_initial_schema.sql +++ b/apps/supabase/migrations/20251230000000_v1_initial_schema.sql @@ -5,7 +5,6 @@ -- EXTENSIONS -- ============================================================================ CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA extensions; - -- ============================================================================ -- CUSTOM TYPES -- ============================================================================ @@ -16,7 +15,6 @@ CREATE TYPE feedback_status AS ENUM ('new', 'reviewed', 'in_progress', 'resolved CREATE TYPE subscription_status AS ENUM ('active', 'cancelled', 'expired', 'billing_issue', 'paused'); CREATE TYPE admin_role AS ENUM ('pack_creator', 'super_admin'); CREATE TYPE audit_action AS ENUM ('INSERT', 'UPDATE', 'DELETE'); - -- ============================================================================ -- TABLES -- ============================================================================ @@ -27,7 +25,6 @@ CREATE TABLE couples ( invite_code TEXT UNIQUE DEFAULT substr(md5(random()::text), 1, 8), created_at TIMESTAMPTZ DEFAULT now() ); - -- Profiles table (extends auth.users) CREATE TABLE profiles ( id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, @@ -40,11 +37,11 @@ CREATE TABLE profiles ( updated_at TIMESTAMPTZ DEFAULT now(), email TEXT, gender TEXT, - show_explicit_content BOOLEAN DEFAULT false, + show_explicit_content BOOLEAN DEFAULT true, onboarding_completed BOOLEAN DEFAULT false, + usage_reason TEXT, CONSTRAINT valid_gender CHECK (gender IS NULL OR gender = ANY(ARRAY['male', 'female', 'non-binary', 'prefer-not-to-say'])) ); - -- Categories table CREATE TABLE categories ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -54,7 +51,6 @@ CREATE TABLE categories ( sort_order INTEGER DEFAULT 0, created_at TIMESTAMPTZ DEFAULT now() ); - -- Question packs table CREATE TABLE question_packs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -68,7 +64,6 @@ CREATE TABLE question_packs ( category_id UUID REFERENCES categories(id), is_explicit BOOLEAN NOT NULL DEFAULT false ); - -- Questions table CREATE TABLE questions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -80,10 +75,8 @@ CREATE TABLE questions ( allowed_couple_genders TEXT[], target_user_genders TEXT[] ); - COMMENT ON COLUMN questions.allowed_couple_genders IS 'Array of allowed couple gender pairings (e.g., ["female+male", "female+female"]). Null means all couple types allowed.'; COMMENT ON COLUMN questions.target_user_genders IS 'Array of genders that can see this question first (as initiator). NULL = all genders. Partners still see it via partner_text after their partner answers.'; - -- Responses table CREATE TABLE responses ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -94,7 +87,6 @@ CREATE TABLE responses ( created_at TIMESTAMPTZ DEFAULT now(), UNIQUE(user_id, question_id) ); - -- Matches table CREATE TABLE matches ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -105,7 +97,6 @@ CREATE TABLE matches ( created_at TIMESTAMPTZ DEFAULT now(), UNIQUE(couple_id, question_id) ); - -- Messages table CREATE TABLE messages ( id UUID PRIMARY KEY DEFAULT extensions.uuid_generate_v4(), @@ -119,7 +110,6 @@ CREATE TABLE messages ( delivered_at TIMESTAMPTZ, CONSTRAINT content_or_media CHECK (content IS NOT NULL OR media_path IS NOT NULL) ); - -- Couple packs table (which packs a couple has enabled) CREATE TABLE couple_packs ( couple_id UUID NOT NULL REFERENCES couples(id) ON DELETE CASCADE, @@ -128,7 +118,6 @@ CREATE TABLE couple_packs ( created_at TIMESTAMPTZ DEFAULT now(), PRIMARY KEY (couple_id, pack_id) ); - -- Feedback table CREATE TABLE feedback ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -144,7 +133,6 @@ CREATE TABLE feedback ( updated_at TIMESTAMPTZ DEFAULT now(), question_id UUID REFERENCES questions(id) ON DELETE SET NULL ); - -- Subscriptions table CREATE TABLE subscriptions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -164,7 +152,6 @@ CREATE TABLE subscriptions ( updated_at TIMESTAMPTZ DEFAULT now(), UNIQUE(user_id, original_transaction_id) ); - -- RevenueCat webhook events table (for idempotency) CREATE TABLE revenuecat_webhook_events ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -174,7 +161,6 @@ CREATE TABLE revenuecat_webhook_events ( processed_at TIMESTAMPTZ DEFAULT now(), payload JSONB ); - -- Admin users table CREATE TABLE admin_users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -184,7 +170,6 @@ CREATE TABLE admin_users ( created_by UUID REFERENCES auth.users(id), updated_at TIMESTAMPTZ DEFAULT now() ); - -- Audit logs table CREATE TABLE audit_logs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -198,7 +183,6 @@ CREATE TABLE audit_logs ( admin_role admin_role NOT NULL, created_at TIMESTAMPTZ DEFAULT now() ); - -- ============================================================================ -- INDEXES -- ============================================================================ @@ -225,7 +209,6 @@ CREATE INDEX idx_audit_logs_record_id ON audit_logs(record_id); CREATE INDEX idx_audit_logs_admin_user_id ON audit_logs(admin_user_id); CREATE INDEX idx_audit_logs_action ON audit_logs(action); CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at DESC); - -- ============================================================================ -- FUNCTIONS -- ============================================================================ @@ -240,7 +223,6 @@ SET search_path = '' AS $$ SELECT couple_id FROM public.profiles WHERE id = auth.uid(); $$; - -- Check if user is any admin CREATE OR REPLACE FUNCTION is_admin(check_user_id UUID DEFAULT auth.uid()) RETURNS BOOLEAN @@ -255,7 +237,6 @@ BEGIN ); END; $$; - -- Check if user is super admin CREATE OR REPLACE FUNCTION is_super_admin(check_user_id UUID DEFAULT auth.uid()) RETURNS BOOLEAN @@ -271,7 +252,6 @@ BEGIN ); END; $$; - -- Get admin role for user CREATE OR REPLACE FUNCTION get_admin_role(check_user_id UUID DEFAULT auth.uid()) RETURNS admin_role @@ -287,7 +267,6 @@ BEGIN RETURN user_role; END; $$; - -- Check if user or partner has premium access CREATE OR REPLACE FUNCTION has_premium_access(check_user_id UUID) RETURNS BOOLEAN @@ -322,7 +301,6 @@ BEGIN RETURN COALESCE(partner_premium, FALSE); END; $$; - -- Get recommended questions for a user CREATE OR REPLACE FUNCTION get_recommended_questions(target_pack_id UUID DEFAULT NULL) RETURNS TABLE( @@ -447,7 +425,6 @@ BEGIN ); END; $$; - -- Get pack teaser questions (bypasses premium for preview) CREATE OR REPLACE FUNCTION get_pack_teaser_questions(target_pack_id UUID) RETURNS TABLE(id UUID, text TEXT, intensity INTEGER) @@ -469,7 +446,6 @@ BEGIN LIMIT 3; END; $$; - -- Handle new user signup (create profile) CREATE OR REPLACE FUNCTION handle_new_user() RETURNS TRIGGER @@ -488,7 +464,6 @@ BEGIN RETURN NEW; END; $$; - -- Update updated_at timestamp CREATE OR REPLACE FUNCTION update_updated_at() RETURNS TRIGGER @@ -500,7 +475,6 @@ BEGIN RETURN NEW; END; $$; - -- Check couple size (max 2 members) CREATE OR REPLACE FUNCTION check_couple_size() RETURNS TRIGGER @@ -516,7 +490,6 @@ BEGIN RETURN NEW; END; $$; - -- Sync premium status from subscriptions CREATE OR REPLACE FUNCTION sync_premium_status() RETURNS TRIGGER @@ -537,7 +510,6 @@ BEGIN RETURN NEW; END; $$; - -- Disable premium packs when subscription is lost CREATE OR REPLACE FUNCTION disable_premium_packs_on_subscription_loss() RETURNS TRIGGER @@ -566,7 +538,6 @@ BEGIN RETURN NEW; END; $$; - -- Log admin actions CREATE OR REPLACE FUNCTION log_admin_action( p_table_name TEXT, @@ -611,7 +582,6 @@ BEGIN RETURN log_id; END; $$; - -- Notify on new message (calls edge function) CREATE OR REPLACE FUNCTION notify_new_message() RETURNS TRIGGER @@ -634,7 +604,6 @@ BEGIN RETURN NEW; END; $$; - -- ============================================================================ -- TRIGGERS -- ============================================================================ @@ -643,41 +612,33 @@ $$; CREATE TRIGGER update_profiles_updated_at BEFORE UPDATE ON profiles FOR EACH ROW EXECUTE FUNCTION update_updated_at(); - CREATE TRIGGER enforce_couple_size BEFORE INSERT OR UPDATE OF couple_id ON profiles FOR EACH ROW EXECUTE FUNCTION check_couple_size(); - CREATE TRIGGER disable_premium_packs_on_sub_loss AFTER UPDATE OF is_premium ON profiles FOR EACH ROW WHEN (OLD.is_premium IS DISTINCT FROM NEW.is_premium) EXECUTE FUNCTION disable_premium_packs_on_subscription_loss(); - -- Subscription triggers CREATE TRIGGER update_subscriptions_updated_at BEFORE UPDATE ON subscriptions FOR EACH ROW EXECUTE FUNCTION update_updated_at(); - CREATE TRIGGER sync_premium_on_subscription_change AFTER INSERT OR UPDATE ON subscriptions FOR EACH ROW EXECUTE FUNCTION sync_premium_status(); - -- Admin users trigger CREATE TRIGGER update_admin_users_updated_at BEFORE UPDATE ON admin_users FOR EACH ROW EXECUTE FUNCTION update_updated_at(); - -- Message notification trigger CREATE TRIGGER on_new_message AFTER INSERT ON messages FOR EACH ROW EXECUTE FUNCTION notify_new_message(); - -- Auth trigger to create profile on signup CREATE TRIGGER on_auth_user_created AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION handle_new_user(); - -- ============================================================================ -- ENABLE ROW LEVEL SECURITY -- ============================================================================ @@ -695,102 +656,81 @@ ALTER TABLE subscriptions ENABLE ROW LEVEL SECURITY; ALTER TABLE revenuecat_webhook_events ENABLE ROW LEVEL SECURITY; ALTER TABLE admin_users ENABLE ROW LEVEL SECURITY; ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY; - -- ============================================================================ -- RLS POLICIES: COUPLES -- ============================================================================ CREATE POLICY "Authenticated users can create couples" ON couples FOR INSERT WITH CHECK (auth.role() = 'authenticated'); - CREATE POLICY "Users can view own couple" ON couples FOR SELECT USING (id = get_auth_user_couple_id()); - CREATE POLICY "Users can lookup couple by exact invite code" ON couples FOR SELECT USING (id = get_auth_user_couple_id()); - CREATE POLICY "Super admins can view all couples" ON couples FOR SELECT USING (is_super_admin()); - -- ============================================================================ -- RLS POLICIES: PROFILES -- ============================================================================ CREATE POLICY "Users can view own profile" ON profiles FOR SELECT USING (auth.uid() = id); - CREATE POLICY "Users can view partner profile" ON profiles FOR SELECT USING (couple_id IS NOT NULL AND couple_id = get_auth_user_couple_id()); - CREATE POLICY "Users can update own profile" ON profiles FOR UPDATE USING (auth.uid() = id); - CREATE POLICY "Super admins can view all profiles" ON profiles FOR SELECT USING (is_super_admin()); - CREATE POLICY "Super admins can update all profiles" ON profiles FOR UPDATE USING (is_super_admin()); - -- ============================================================================ -- RLS POLICIES: CATEGORIES -- ============================================================================ CREATE POLICY "Categories are viewable by everyone" ON categories FOR SELECT USING (true); - CREATE POLICY "Admins can view all categories" ON categories FOR SELECT USING (is_admin()); - CREATE POLICY "Admins can insert categories" ON categories FOR INSERT WITH CHECK (is_admin()); - CREATE POLICY "Admins can update categories" ON categories FOR UPDATE USING (is_admin()) WITH CHECK (is_admin()); - CREATE POLICY "Admins can delete categories" ON categories FOR DELETE USING (is_admin()); - -- ============================================================================ -- RLS POLICIES: QUESTION PACKS -- ============================================================================ CREATE POLICY "Anyone can view public packs" ON question_packs FOR SELECT USING (is_public = true); - CREATE POLICY "Premium users can view premium packs" ON question_packs FOR SELECT USING (is_premium = false OR EXISTS ( SELECT 1 FROM profiles WHERE profiles.id = auth.uid() AND profiles.is_premium = true )); - CREATE POLICY "Admins can view all question packs" ON question_packs FOR SELECT USING (is_admin()); - CREATE POLICY "Admins can insert question packs" ON question_packs FOR INSERT WITH CHECK (is_admin()); - CREATE POLICY "Admins can update question packs" ON question_packs FOR UPDATE USING (is_admin()); - CREATE POLICY "Admins can delete question packs" ON question_packs FOR DELETE USING (is_admin()); - -- ============================================================================ -- RLS POLICIES: QUESTIONS -- ============================================================================ @@ -802,23 +742,18 @@ CREATE POLICY "Anyone can view questions in visible packs" SELECT 1 FROM profiles WHERE profiles.id = auth.uid() AND profiles.is_premium = true )) )); - CREATE POLICY "Admins can view all questions" ON questions FOR SELECT USING (is_admin()); - CREATE POLICY "Admins can insert questions" ON questions FOR INSERT WITH CHECK (is_admin()); - CREATE POLICY "Admins can update questions" ON questions FOR UPDATE USING (is_admin()); - CREATE POLICY "Admins can delete questions" ON questions FOR DELETE USING (is_admin()); - -- ============================================================================ -- RLS POLICIES: RESPONSES -- ============================================================================ @@ -827,42 +762,33 @@ CREATE POLICY "Users can view couple responses" USING (user_id = auth.uid() OR couple_id IN ( SELECT couple_id FROM profiles WHERE id = auth.uid() )); - CREATE POLICY "Users can insert own responses" ON responses FOR INSERT WITH CHECK (user_id = auth.uid() AND couple_id = get_auth_user_couple_id()); - CREATE POLICY "Users can update own responses" ON responses FOR UPDATE USING (user_id = auth.uid()); - CREATE POLICY "Users can delete own responses" ON responses FOR DELETE USING (user_id = auth.uid()); - CREATE POLICY "Super admins can view all responses" ON responses FOR SELECT USING (is_super_admin()); - -- ============================================================================ -- RLS POLICIES: MATCHES -- ============================================================================ CREATE POLICY "Users can view couple matches" ON matches FOR SELECT USING (couple_id = get_auth_user_couple_id()); - CREATE POLICY "Users can update match seen status" ON matches FOR UPDATE USING (couple_id = get_auth_user_couple_id()); - CREATE POLICY "Users can delete own couple matches" ON matches FOR DELETE USING (couple_id = get_auth_user_couple_id()); - CREATE POLICY "Super admins can view all matches" ON matches FOR SELECT USING (is_super_admin()); - -- ============================================================================ -- RLS POLICIES: MESSAGES -- ============================================================================ @@ -873,7 +799,6 @@ CREATE POLICY "Users can view messages in their matches" WHERE m.id = messages.match_id AND m.couple_id IN (SELECT couple_id FROM profiles WHERE id = auth.uid()) )); - CREATE POLICY "Users can insert messages in their matches" ON messages FOR INSERT WITH CHECK (auth.uid() = user_id AND EXISTS ( @@ -881,7 +806,6 @@ CREATE POLICY "Users can insert messages in their matches" WHERE m.id = messages.match_id AND m.couple_id IN (SELECT couple_id FROM profiles WHERE id = auth.uid()) )); - CREATE POLICY "Users can mark messages as read" ON messages FOR UPDATE USING (auth.uid() <> user_id AND EXISTS ( @@ -890,15 +814,12 @@ CREATE POLICY "Users can mark messages as read" AND m.couple_id IN (SELECT couple_id FROM profiles WHERE id = auth.uid()) )) WITH CHECK (auth.uid() <> user_id); - CREATE POLICY "Users can delete own messages" ON messages FOR DELETE USING (user_id = auth.uid()); - CREATE POLICY "Super admins can view all messages" ON messages FOR SELECT USING (is_super_admin()); - -- ============================================================================ -- RLS POLICIES: COUPLE PACKS -- ============================================================================ @@ -908,67 +829,54 @@ CREATE POLICY "Couples can view their own pack settings" SELECT 1 FROM profiles WHERE profiles.id = auth.uid() AND profiles.couple_id = couple_packs.couple_id )); - CREATE POLICY "Couples can modify their own pack settings" ON couple_packs FOR ALL USING (EXISTS ( SELECT 1 FROM profiles WHERE profiles.id = auth.uid() AND profiles.couple_id = couple_packs.couple_id )); - CREATE POLICY "Users can delete own couple pack settings" ON couple_packs FOR DELETE USING (EXISTS ( SELECT 1 FROM profiles WHERE profiles.id = auth.uid() AND profiles.couple_id = couple_packs.couple_id )); - -- ============================================================================ -- RLS POLICIES: FEEDBACK -- ============================================================================ CREATE POLICY "Users can create feedback" ON feedback FOR INSERT WITH CHECK (auth.uid() = user_id); - CREATE POLICY "Users can view own feedback" ON feedback FOR SELECT USING (auth.uid() = user_id); - CREATE POLICY "Users can update own pending feedback" ON feedback FOR UPDATE USING (auth.uid() = user_id AND status = 'new'); - CREATE POLICY "Super admins can view all feedback" ON feedback FOR SELECT USING (is_super_admin()); - CREATE POLICY "Super admins can update all feedback" ON feedback FOR UPDATE USING (is_super_admin()); - -- ============================================================================ -- RLS POLICIES: SUBSCRIPTIONS -- ============================================================================ CREATE POLICY "Users can view own subscriptions" ON subscriptions FOR SELECT USING (auth.uid() = user_id); - CREATE POLICY "Super admins can view all subscriptions" ON subscriptions FOR SELECT USING (is_super_admin()); - CREATE POLICY "Super admins can insert subscriptions" ON subscriptions FOR INSERT WITH CHECK (is_super_admin()); - CREATE POLICY "Super admins can update subscriptions" ON subscriptions FOR UPDATE USING (is_super_admin()); - CREATE POLICY "Super admins can delete subscriptions" ON subscriptions FOR DELETE USING (is_super_admin()); - -- ============================================================================ -- RLS POLICIES: REVENUECAT WEBHOOK EVENTS -- ============================================================================ @@ -981,14 +889,12 @@ CREATE POLICY "Super admins can delete subscriptions" CREATE POLICY "Admin users can read own record" ON admin_users FOR SELECT USING (auth.uid() = user_id); - -- ============================================================================ -- RLS POLICIES: AUDIT LOGS -- ============================================================================ CREATE POLICY "Super admins can view audit logs" ON audit_logs FOR SELECT USING (is_super_admin()); - -- ============================================================================ -- REALTIME SUBSCRIPTIONS -- ============================================================================ @@ -996,18 +902,15 @@ ALTER PUBLICATION supabase_realtime ADD TABLE profiles; ALTER PUBLICATION supabase_realtime ADD TABLE matches; ALTER PUBLICATION supabase_realtime ADD TABLE messages; ALTER PUBLICATION supabase_realtime ADD TABLE couple_packs; - -- ============================================================================ -- STORAGE BUCKETS -- ============================================================================ INSERT INTO storage.buckets (id, name, public) VALUES ('feedback-screenshots', 'feedback-screenshots', false) ON CONFLICT (id) DO NOTHING; - INSERT INTO storage.buckets (id, name, public) VALUES ('chat-media', 'chat-media', false) ON CONFLICT (id) DO NOTHING; - -- ============================================================================ -- STORAGE POLICIES -- ============================================================================ @@ -1019,14 +922,12 @@ CREATE POLICY "Users can upload feedback screenshots" bucket_id = 'feedback-screenshots' AND (storage.foldername(name))[1] = auth.uid()::text ); - CREATE POLICY "Users can view own feedback screenshots" ON storage.objects FOR SELECT TO authenticated USING ( bucket_id = 'feedback-screenshots' AND (storage.foldername(name))[1] = auth.uid()::text ); - -- Chat media policies -- IMPORTANT: Use storage.objects.name (not just "name") to avoid ambiguity with profiles.name CREATE POLICY "Users can view chat media in their matches" @@ -1040,7 +941,6 @@ CREATE POLICY "Users can view chat media in their matches" AND p.id = auth.uid() ) ); - CREATE POLICY "Users can upload chat media to their matches" ON storage.objects FOR INSERT TO authenticated WITH CHECK ( @@ -1052,7 +952,6 @@ CREATE POLICY "Users can upload chat media to their matches" AND p.id = auth.uid() ) ); - CREATE POLICY "Users can delete their own chat media" ON storage.objects FOR DELETE TO authenticated USING ( @@ -1064,7 +963,6 @@ CREATE POLICY "Users can delete their own chat media" AND p.id = auth.uid() ) ); - CREATE POLICY "Admins can view all chat media" ON storage.objects FOR SELECT TO authenticated USING ( diff --git a/apps/supabase/migrations/20251231000000_fix_chat_media_storage_policies.sql b/apps/supabase/migrations/20251231000000_fix_chat_media_storage_policies.sql index 28e34ec..b296471 100644 --- a/apps/supabase/migrations/20251231000000_fix_chat_media_storage_policies.sql +++ b/apps/supabase/migrations/20251231000000_fix_chat_media_storage_policies.sql @@ -4,10 +4,9 @@ -- Fix: Explicitly qualify as storage.objects.name to reference the storage object's file path -- Drop existing broken policies -DROP POLICY IF EXISTS "Users can upload chat media to their matches" ON storage.objects; -DROP POLICY IF EXISTS "Users can view chat media in their matches" ON storage.objects; -DROP POLICY IF EXISTS "Users can delete their own chat media" ON storage.objects; - +DROP POLICY IF EXISTS "Users can upload chat media to their matches" ON storage.objects; +DROP POLICY IF EXISTS "Users can view chat media in their matches" ON storage.objects; +DROP POLICY IF EXISTS "Users can delete their own chat media" ON storage.objects; -- Recreate with explicit table qualification to avoid column name ambiguity CREATE POLICY "Users can upload chat media to their matches" ON storage.objects FOR INSERT TO authenticated @@ -19,8 +18,7 @@ CREATE POLICY "Users can upload chat media to their matches" WHERE (storage.foldername(storage.objects.name))[1] = m.id::text AND p.id = auth.uid() ) - ); - + ); CREATE POLICY "Users can view chat media in their matches" ON storage.objects FOR SELECT TO authenticated USING ( @@ -31,8 +29,7 @@ CREATE POLICY "Users can view chat media in their matches" WHERE (storage.foldername(storage.objects.name))[1] = m.id::text AND p.id = auth.uid() ) - ); - + ); CREATE POLICY "Users can delete their own chat media" ON storage.objects FOR DELETE TO authenticated USING ( @@ -43,4 +40,4 @@ CREATE POLICY "Users can delete their own chat media" WHERE (storage.foldername(storage.objects.name))[1] = m.id::text AND p.id = auth.uid() ) - ); + ); diff --git a/apps/supabase/migrations/20260101000000_create_ai_config_table.sql b/apps/supabase/migrations/20260101000000_create_ai_config_table.sql index f218f8d..a48fabf 100644 --- a/apps/supabase/migrations/20260101000000_create_ai_config_table.sql +++ b/apps/supabase/migrations/20260101000000_create_ai_config_table.sql @@ -22,17 +22,13 @@ CREATE TABLE IF NOT EXISTS ai_config ( updated_at timestamptz DEFAULT now(), updated_by uuid REFERENCES auth.users(id) ); - -- Only allow one row in this table (singleton pattern) CREATE UNIQUE INDEX IF NOT EXISTS ai_config_singleton ON ai_config ((true)); - -- Insert default config row INSERT INTO ai_config (id) VALUES (gen_random_uuid()) ON CONFLICT DO NOTHING; - -- Enable RLS ALTER TABLE ai_config ENABLE ROW LEVEL SECURITY; - -- Only super admins can read/write (checked via admin_users table) CREATE POLICY "Super admins can read ai_config" ON ai_config FOR SELECT @@ -43,7 +39,6 @@ CREATE POLICY "Super admins can read ai_config" AND admin_users.role = 'super_admin' ) ); - CREATE POLICY "Super admins can update ai_config" ON ai_config FOR UPDATE USING ( @@ -53,7 +48,6 @@ CREATE POLICY "Super admins can update ai_config" AND admin_users.role = 'super_admin' ) ); - -- Create function to update the updated_at timestamp CREATE OR REPLACE FUNCTION update_ai_config_timestamp() RETURNS TRIGGER AS $$ @@ -63,13 +57,11 @@ BEGIN RETURN NEW; END; $$ LANGUAGE plpgsql SECURITY DEFINER; - -- Create trigger to auto-update timestamp DROP TRIGGER IF EXISTS ai_config_updated_at ON ai_config; CREATE TRIGGER ai_config_updated_at BEFORE UPDATE ON ai_config FOR EACH ROW EXECUTE FUNCTION update_ai_config_timestamp(); - -- Add comment for documentation COMMENT ON TABLE ai_config IS 'Singleton table storing AI configuration for admin portal. Only super admins can access.'; diff --git a/apps/supabase/migrations/20260101000001_add_council_generators_array.sql b/apps/supabase/migrations/20260101000001_add_council_generators_array.sql index 22bfffd..d2eb78e 100644 --- a/apps/supabase/migrations/20260101000001_add_council_generators_array.sql +++ b/apps/supabase/migrations/20260101000001_add_council_generators_array.sql @@ -5,17 +5,14 @@ -- Add the new column (migrate existing single generator model to array format) ALTER TABLE ai_config ADD COLUMN IF NOT EXISTS council_generators jsonb; - -- Set default value based on existing council_generator_model or default UPDATE ai_config SET council_generators = jsonb_build_array( jsonb_build_object('model', COALESCE(council_generator_model, 'anthropic/claude-3.5-sonnet')) ) WHERE council_generators IS NULL; - -- Set the default for new rows ALTER TABLE ai_config ALTER COLUMN council_generators SET DEFAULT '[{"model": "anthropic/claude-3.5-sonnet"}]'::jsonb; - -- Add comment for documentation COMMENT ON COLUMN ai_config.council_generators IS 'Array of generator configurations. Each object has a "model" field. Default is one generator with Claude 3.5 Sonnet.'; diff --git a/apps/supabase/migrations/20260101000002_create_app_config_table.sql b/apps/supabase/migrations/20260101000002_create_app_config_table.sql index 9293b09..fdc70cb 100644 --- a/apps/supabase/migrations/20260101000002_create_app_config_table.sql +++ b/apps/supabase/migrations/20260101000002_create_app_config_table.sql @@ -12,37 +12,29 @@ CREATE TABLE IF NOT EXISTS app_config ( updated_at timestamptz DEFAULT now(), updated_by uuid REFERENCES auth.users(id) ); - -- Only allow one row in this table (singleton pattern) CREATE UNIQUE INDEX IF NOT EXISTS app_config_singleton ON app_config ((true)); - -- Insert default config row INSERT INTO app_config (id) VALUES (gen_random_uuid()) ON CONFLICT DO NOTHING; - -- Enable RLS ALTER TABLE app_config ENABLE ROW LEVEL SECURITY; - -- All authenticated users can read (needed for mobile app) CREATE POLICY "Authenticated users can read app_config" ON app_config FOR SELECT USING (auth.role() = 'authenticated'); - -- Only super admins can update CREATE POLICY "Super admins can update app_config" ON app_config FOR UPDATE USING (is_super_admin()); - -- Create trigger to auto-update timestamp (reuses existing function) CREATE TRIGGER app_config_updated_at BEFORE UPDATE ON app_config FOR EACH ROW EXECUTE FUNCTION update_updated_at(); - -- Add comment for documentation COMMENT ON TABLE app_config IS 'Singleton table storing mobile app configuration. Readable by all authenticated users, writable by super admins only.'; COMMENT ON COLUMN app_config.answer_gap_threshold IS 'Maximum number of questions a user can answer ahead of their partner. Set to 0 to disable answer spam prevention.'; - -- ============================================================================ -- RPC FUNCTION: get_answer_gap_status -- Returns the number of questions user has answered that partner hasn't, @@ -131,5 +123,4 @@ BEGIN RETURN QUERY SELECT v_gap_count, v_threshold, (v_gap_count >= v_threshold); END; $$; - COMMENT ON FUNCTION get_answer_gap_status() IS 'Returns answer gap status for the current user. Used by mobile app to prevent answer spam.'; diff --git a/apps/supabase/migrations/20260101000003_add_answer_gap_to_recommended_questions.sql b/apps/supabase/migrations/20260101000003_add_answer_gap_to_recommended_questions.sql index 4343245..e62b45e 100644 --- a/apps/supabase/migrations/20260101000003_add_answer_gap_to_recommended_questions.sql +++ b/apps/supabase/migrations/20260101000003_add_answer_gap_to_recommended_questions.sql @@ -163,5 +163,4 @@ BEGIN ); END; $$; - COMMENT ON FUNCTION get_recommended_questions(UUID) IS 'Returns recommended questions for user. When user is too far ahead of partner (answer gap threshold exceeded), returns empty. Old apps see "All caught up!", new apps can call get_answer_gap_status to determine blocking reason.'; diff --git a/apps/supabase/migrations/20260101100000_add_video_support.sql b/apps/supabase/migrations/20260101100000_add_video_support.sql index 4ef418d..77602ae 100644 --- a/apps/supabase/migrations/20260101100000_add_video_support.sql +++ b/apps/supabase/migrations/20260101100000_add_video_support.sql @@ -2,20 +2,15 @@ -- Videos expire 30 days after being viewed by the recipient -- Add media type to distinguish images from videos -ALTER TABLE messages ADD COLUMN media_type TEXT CHECK (media_type IN ('image', 'video')); - +ALTER TABLE messages ADD COLUMN media_type TEXT CHECK (media_type IN ('image', 'video')); -- Track when media expires (set when video is viewed: media_viewed_at + 30 days) -ALTER TABLE messages ADD COLUMN media_expires_at TIMESTAMPTZ; - +ALTER TABLE messages ADD COLUMN media_expires_at TIMESTAMPTZ; -- Track if media was deleted due to expiration -ALTER TABLE messages ADD COLUMN media_expired BOOLEAN DEFAULT false; - +ALTER TABLE messages ADD COLUMN media_expired BOOLEAN DEFAULT false; -- Backfill existing media as images -UPDATE messages SET media_type = 'image' WHERE media_path IS NOT NULL; - +UPDATE messages SET media_type = 'image' WHERE media_path IS NOT NULL; -- Enable pg_cron extension for scheduled cleanup -CREATE EXTENSION IF NOT EXISTS pg_cron WITH SCHEMA extensions; - +CREATE EXTENSION IF NOT EXISTS pg_cron WITH SCHEMA extensions; -- Create cleanup function that handles both DB updates and storage deletion tracking CREATE OR REPLACE FUNCTION cleanup_expired_videos() RETURNS TABLE(deleted_count int, deleted_paths text[]) AS $$ @@ -48,12 +43,11 @@ BEGIN RETURN QUERY SELECT count, COALESCE(expired_paths, ARRAY[]::text[]); END; -$$ LANGUAGE plpgsql SECURITY DEFINER; - +$$ LANGUAGE plpgsql SECURITY DEFINER; -- Schedule daily cleanup at 3 AM UTC -- The database function marks videos as expired, edge function handles storage deletion SELECT cron.schedule( 'cleanup-expired-videos', '0 3 * * *', 'SELECT * FROM cleanup_expired_videos()' -); +); diff --git a/apps/supabase/migrations/20260101110000_fix_admin_rls_policies.sql b/apps/supabase/migrations/20260101110000_fix_admin_rls_policies.sql index 9e9676a..ef1355e 100644 --- a/apps/supabase/migrations/20260101110000_fix_admin_rls_policies.sql +++ b/apps/supabase/migrations/20260101110000_fix_admin_rls_policies.sql @@ -30,29 +30,24 @@ BEGIN RETURN v_permissions @> jsonb_build_array(check_permission); END; $$; - -- Update RLS policies to use permissions -- Profiles CREATE POLICY "Admins with view_users permission can view all profiles" ON profiles FOR SELECT USING (has_permission('view_users')); - -- Messages CREATE POLICY "Admins with view_chats permission can view all messages" ON messages FOR SELECT USING (has_permission('view_chats')); - -- Responses CREATE POLICY "Admins with view_responses permission can view all responses" ON responses FOR SELECT USING (has_permission('view_responses')); - -- Matches CREATE POLICY "Admins with view_matches permission can view all matches" ON matches FOR SELECT USING (has_permission('view_matches')); - -- Audit Logs CREATE POLICY "Admins with view_audit_logs permission can view audit logs" ON audit_logs FOR SELECT diff --git a/apps/supabase/migrations/20260101113000_add_feedback_permission_rls.sql b/apps/supabase/migrations/20260101113000_add_feedback_permission_rls.sql index c1fc6d3..3b8e049 100644 --- a/apps/supabase/migrations/20260101113000_add_feedback_permission_rls.sql +++ b/apps/supabase/migrations/20260101113000_add_feedback_permission_rls.sql @@ -3,7 +3,6 @@ CREATE POLICY "Admins with manage_feedback permission can view all feedback" ON feedback FOR SELECT USING (has_permission('manage_feedback')); - CREATE POLICY "Admins with manage_feedback permission can update all feedback" ON feedback FOR UPDATE USING (has_permission('manage_feedback')); diff --git a/apps/supabase/migrations/20260101114500_add_redemption_codes_permission_rls.sql b/apps/supabase/migrations/20260101114500_add_redemption_codes_permission_rls.sql index dd1d50d..671d925 100644 --- a/apps/supabase/migrations/20260101114500_add_redemption_codes_permission_rls.sql +++ b/apps/supabase/migrations/20260101114500_add_redemption_codes_permission_rls.sql @@ -1,17 +1,138 @@ --- Add RLS policies for redemption codes +-- Redemption codes table +CREATE TABLE redemption_codes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code TEXT NOT NULL UNIQUE, + description TEXT, + max_uses INTEGER DEFAULT 1, + current_uses INTEGER DEFAULT 0, + expires_at TIMESTAMPTZ, + is_active BOOLEAN DEFAULT true, + created_by UUID REFERENCES auth.users(id), + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +-- Code redemptions table +CREATE TABLE code_redemptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code_id UUID NOT NULL REFERENCES redemption_codes(id), + user_id UUID NOT NULL REFERENCES auth.users(id), + redeemed_at TIMESTAMPTZ DEFAULT now(), + UNIQUE(code_id, user_id) +); + +ALTER TABLE redemption_codes ENABLE ROW LEVEL SECURITY; +ALTER TABLE code_redemptions ENABLE ROW LEVEL SECURITY; CREATE POLICY "Admins with manage_codes permission can view redemption codes" ON redemption_codes FOR SELECT - USING (has_permission('manage_codes')); - + USING (has_permission('manage_codes'::text)); CREATE POLICY "Admins with manage_codes permission can insert redemption codes" ON redemption_codes FOR INSERT - WITH CHECK (has_permission('manage_codes')); - + WITH CHECK (has_permission('manage_codes'::text)); CREATE POLICY "Admins with manage_codes permission can update redemption codes" ON redemption_codes FOR UPDATE - USING (has_permission('manage_codes')); - + USING (has_permission('manage_codes'::text)); CREATE POLICY "Admins with manage_codes permission can delete redemption codes" ON redemption_codes FOR DELETE - USING (has_permission('manage_codes')); + USING (has_permission('manage_codes'::text)); +CREATE POLICY "Super admins can manage redemption codes" + ON redemption_codes FOR ALL + USING ( + EXISTS ( + SELECT 1 + FROM admin_users + WHERE admin_users.user_id = auth.uid() + AND admin_users.role = 'super_admin'::admin_role + ) + ); + +CREATE POLICY "Users can view own redemptions" + ON code_redemptions FOR SELECT + USING (auth.uid() = user_id); +CREATE POLICY "Super admins can view all redemptions" + ON code_redemptions FOR SELECT + USING ( + EXISTS ( + SELECT 1 + FROM admin_users + WHERE admin_users.user_id = auth.uid() + AND admin_users.role = 'super_admin'::admin_role + ) + ); + +CREATE OR REPLACE FUNCTION public.redeem_code_by_email(p_email text, p_code text) + RETURNS jsonb + LANGUAGE plpgsql + SECURITY DEFINER +AS $function$ +DECLARE + v_code_record record; + v_user_record record; + v_existing_redemption record; +BEGIN + -- Find the user by email + SELECT id INTO v_user_record + FROM public.profiles + WHERE LOWER(email) = LOWER(p_email); + + IF v_user_record IS NULL THEN + RETURN jsonb_build_object('success', false, 'error', 'No account found with this email address'); + END IF; + + -- Find the code + SELECT * INTO v_code_record + FROM public.redemption_codes + WHERE UPPER(code) = UPPER(p_code); + + IF v_code_record IS NULL THEN + RETURN jsonb_build_object('success', false, 'error', 'Invalid redemption code'); + END IF; + + -- Check if code is active + IF NOT v_code_record.is_active THEN + RETURN jsonb_build_object('success', false, 'error', 'This code is no longer active'); + END IF; + + -- Check if code has expired + IF v_code_record.expires_at IS NOT NULL AND v_code_record.expires_at < now() THEN + RETURN jsonb_build_object('success', false, 'error', 'This code has expired'); + END IF; + + -- Check if code has uses remaining + IF v_code_record.max_uses IS NOT NULL AND v_code_record.current_uses >= v_code_record.max_uses THEN + RETURN jsonb_build_object('success', false, 'error', 'This code has reached its maximum number of uses'); + END IF; + + -- Check if user has already redeemed this code + SELECT * INTO v_existing_redemption + FROM public.code_redemptions + WHERE code_id = v_code_record.id AND user_id = v_user_record.id; + + IF v_existing_redemption IS NOT NULL THEN + RETURN jsonb_build_object('success', false, 'error', 'You have already redeemed this code'); + END IF; + + -- All checks passed - redeem the code + -- 1. Create redemption record + INSERT INTO public.code_redemptions (code_id, user_id) + VALUES (v_code_record.id, v_user_record.id); + + -- 2. Increment current_uses + UPDATE public.redemption_codes + SET current_uses = current_uses + 1, + updated_at = now() + WHERE id = v_code_record.id; + + -- 3. Set user as premium + UPDATE public.profiles + SET is_premium = true, + updated_at = now() + WHERE id = v_user_record.id; + + RETURN jsonb_build_object('success', true, 'message', 'Code redeemed successfully! You now have premium access.'); +END; +$function$; + +GRANT EXECUTE ON FUNCTION public.redeem_code_by_email(text, text) TO PUBLIC; +GRANT EXECUTE ON FUNCTION public.redeem_code_by_email(text, text) TO anon, authenticated, service_role; diff --git a/apps/supabase/migrations/20260101180200_create_feature_interests_table.sql b/apps/supabase/migrations/20260101180200_create_feature_interests_table.sql index 0b2bd42..c2a0a72 100644 --- a/apps/supabase/migrations/20260101180200_create_feature_interests_table.sql +++ b/apps/supabase/migrations/20260101180200_create_feature_interests_table.sql @@ -1,27 +1,43 @@ --- Track user opt-ins for upcoming/coming-soon features - +-- Table to track user interest in upcoming features CREATE TABLE feature_interests ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, - feature_name TEXT NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - UNIQUE (user_id, feature_name) + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + feature_name TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL, + + -- Ensure a user can only express interest in a feature once + UNIQUE(user_id, feature_name) ); -CREATE INDEX idx_feature_interests_user_id ON feature_interests(user_id); +-- Index for querying by feature CREATE INDEX idx_feature_interests_feature_name ON feature_interests(feature_name); +-- Index for querying by user +CREATE INDEX idx_feature_interests_user_id ON feature_interests(user_id); + +-- Enable RLS ALTER TABLE feature_interests ENABLE ROW LEVEL SECURITY; -CREATE POLICY "Users can view their own interests" - ON feature_interests FOR SELECT - USING (auth.uid() = user_id); - +-- Users can insert their own interest CREATE POLICY "Users can express interest in features" - ON feature_interests FOR INSERT - WITH CHECK (auth.uid() = user_id); +ON feature_interests +FOR INSERT +TO authenticated +WITH CHECK (auth.uid() = user_id); +-- Users can view their own interests +CREATE POLICY "Users can view their own interests" +ON feature_interests +FOR SELECT +TO authenticated +USING (auth.uid() = user_id); + +-- Users can delete their own interests CREATE POLICY "Users can remove their interest" - ON feature_interests FOR DELETE - USING (auth.uid() = user_id); +ON feature_interests +FOR DELETE +TO authenticated +USING (auth.uid() = user_id); +-- Grant service role full access for admin queries +GRANT ALL ON feature_interests TO service_role;; diff --git a/apps/supabase/migrations/20260102000002_add_feature_interest_counts_rpc.sql b/apps/supabase/migrations/20260102000002_add_feature_interest_counts_rpc.sql index c7fcba7..25e47de 100644 --- a/apps/supabase/migrations/20260102000002_add_feature_interest_counts_rpc.sql +++ b/apps/supabase/migrations/20260102000002_add_feature_interest_counts_rpc.sql @@ -25,5 +25,4 @@ BEGIN ORDER BY opt_in_count DESC, fi.feature_name ASC; END; $$; - GRANT EXECUTE ON FUNCTION public.get_feature_interest_counts() TO authenticated; diff --git a/apps/supabase/migrations/20260102100000_add_e2ee_columns_to_messages.sql b/apps/supabase/migrations/20260102100000_add_e2ee_columns_to_messages.sql index 7bcccc0..93486c1 100644 --- a/apps/supabase/migrations/20260102100000_add_e2ee_columns_to_messages.sql +++ b/apps/supabase/migrations/20260102100000_add_e2ee_columns_to_messages.sql @@ -3,20 +3,15 @@ -- Version indicator: 1 = plaintext (legacy), 2 = E2EE ALTER TABLE messages ADD COLUMN IF NOT EXISTS version INTEGER DEFAULT 1; - -- Encrypted message content (base64 encoded ciphertext) ALTER TABLE messages ADD COLUMN IF NOT EXISTS encrypted_content TEXT; - -- Initialization vector for AES-GCM (base64 encoded, 12 bytes) ALTER TABLE messages ADD COLUMN IF NOT EXISTS encryption_iv TEXT; - -- Triple-wrapped encryption keys and metadata ALTER TABLE messages ADD COLUMN IF NOT EXISTS keys_metadata JSONB; - -- Add comment for documentation COMMENT ON COLUMN messages.version IS 'Encryption version: 1 = plaintext (legacy), 2 = E2EE with triple-wrapped keys'; - -- Constraint: v2 messages must have encryption fields DO $$ BEGIN diff --git a/apps/supabase/migrations/20260102100001_add_public_key_to_profiles.sql b/apps/supabase/migrations/20260102100001_add_public_key_to_profiles.sql index 3311eef..ac2c76d 100644 --- a/apps/supabase/migrations/20260102100001_add_public_key_to_profiles.sql +++ b/apps/supabase/migrations/20260102100001_add_public_key_to_profiles.sql @@ -3,10 +3,8 @@ -- RSA public key in JWK format (JSON Web Key) ALTER TABLE profiles ADD COLUMN IF NOT EXISTS public_key_jwk JSONB; - -- Index for efficient lookups when encrypting for partner CREATE INDEX IF NOT EXISTS idx_profiles_couple_id_public_key ON profiles(couple_id) WHERE public_key_jwk IS NOT NULL; - COMMENT ON COLUMN profiles.public_key_jwk IS 'RSA-2048 public key in JWK format for E2EE. Private key stored on device only.'; diff --git a/apps/supabase/migrations/20260102100002_create_master_keys_table.sql b/apps/supabase/migrations/20260102100002_create_master_keys_table.sql index 48c623c..68b2605 100644 --- a/apps/supabase/migrations/20260102100002_create_master_keys_table.sql +++ b/apps/supabase/migrations/20260102100002_create_master_keys_table.sql @@ -9,14 +9,11 @@ CREATE TABLE IF NOT EXISTS master_keys ( rotated_at TIMESTAMPTZ, is_active BOOLEAN DEFAULT true ); - -- Add table comment COMMENT ON TABLE master_keys IS 'Stores admin RSA public keys for E2EE moderation access. Private key stored securely in environment variables.'; - -- Enable RLS ALTER TABLE master_keys ENABLE ROW LEVEL SECURITY; - -- Super admins can view master keys DO $$ BEGIN @@ -29,7 +26,6 @@ BEGIN USING (is_super_admin(auth.uid())); END IF; END $$; - -- Super admins can insert master keys DO $$ BEGIN @@ -42,7 +38,6 @@ BEGIN WITH CHECK (is_super_admin(auth.uid())); END IF; END $$; - -- Super admins can update master keys DO $$ BEGIN @@ -55,7 +50,6 @@ BEGIN USING (is_super_admin(auth.uid())); END IF; END $$; - -- Allow all authenticated users to read active master key public keys (needed for encryption) DO $$ BEGIN diff --git a/apps/supabase/migrations/20260102130000_fix_content_or_media_constraint_for_e2ee.sql b/apps/supabase/migrations/20260102130000_fix_content_or_media_constraint_for_e2ee.sql index 292521f..3098180 100644 --- a/apps/supabase/migrations/20260102130000_fix_content_or_media_constraint_for_e2ee.sql +++ b/apps/supabase/migrations/20260102130000_fix_content_or_media_constraint_for_e2ee.sql @@ -1,8 +1,7 @@ -- Update the content_or_media constraint to allow E2EE messages -- E2EE messages have encrypted_content instead of content -ALTER TABLE public.messages DROP CONSTRAINT IF EXISTS content_or_media; - +ALTER TABLE public.messages DROP CONSTRAINT IF EXISTS content_or_media; ALTER TABLE public.messages ADD CONSTRAINT content_or_media CHECK ( -- v1 (plaintext): must have content or media_path (version = 1 AND (content IS NOT NULL OR media_path IS NOT NULL)) @@ -12,4 +11,4 @@ ALTER TABLE public.messages ADD CONSTRAINT content_or_media CHECK ( OR -- Legacy messages without version: require content or media_path (version IS NULL AND (content IS NOT NULL OR media_path IS NOT NULL)) -); +); diff --git a/apps/supabase/migrations/20260102140000_add_content_moderation.sql b/apps/supabase/migrations/20260102140000_add_content_moderation.sql index 95acfd6..6b5630d 100644 --- a/apps/supabase/migrations/20260102140000_add_content_moderation.sql +++ b/apps/supabase/migrations/20260102140000_add_content_moderation.sql @@ -2,7 +2,6 @@ ALTER TABLE messages ADD COLUMN moderation_status text DEFAULT 'unmoderated' CHECK (moderation_status IN ('unmoderated', 'safe', 'flagged')), ADD COLUMN flag_reason text; - -- Add classifier configuration to ai_config table ALTER TABLE ai_config ADD COLUMN classifier_enabled boolean DEFAULT true, @@ -22,7 +21,6 @@ If the content is safe (including consensual adult sexual content), respond with If the content is illegal/dangerous, respond with {"status": "flagged", "reason": "Brief explanation"}. Response must be valid JSON.'; - -- Function to trigger classification CREATE OR REPLACE FUNCTION trigger_classify_message() RETURNS TRIGGER @@ -54,13 +52,11 @@ BEGIN RETURN NEW; END; $$; - -- Create trigger on messages table CREATE TRIGGER on_message_created_classify AFTER INSERT ON messages FOR EACH ROW EXECUTE FUNCTION trigger_classify_message(); - -- Add comments COMMENT ON COLUMN messages.moderation_status IS 'Content moderation status: unmoderated, safe, or flagged.'; COMMENT ON COLUMN messages.flag_reason IS 'Reason for flagging the content (if status is flagged).'; diff --git a/apps/supabase/migrations/20260102150000_add_message_category.sql b/apps/supabase/migrations/20260102150000_add_message_category.sql index 84328d8..0d83008 100644 --- a/apps/supabase/migrations/20260102150000_add_message_category.sql +++ b/apps/supabase/migrations/20260102150000_add_message_category.sql @@ -1,9 +1,7 @@ -- Add category column to messages table ALTER TABLE messages ADD COLUMN category text; - COMMENT ON COLUMN messages.category IS 'Content category: Neutral, Romantic, Playful, Explicit, etc.'; - -- Update the default classifier prompt in ai_config UPDATE ai_config SET classifier_prompt = 'You are a content moderator for a couples app. Your task is to analyze the following message (text and/or image) and determine if it contains ILLEGAL or DANGEROUS content, and also categorize the tone/nature of the message. diff --git a/apps/supabase/migrations/20260103000000_add_message_deletion.sql b/apps/supabase/migrations/20260103000000_add_message_deletion.sql index 5d4f0a8..a249fbb 100644 --- a/apps/supabase/migrations/20260103000000_add_message_deletion.sql +++ b/apps/supabase/migrations/20260103000000_add_message_deletion.sql @@ -1,6 +1,5 @@ -- Add deleted_at column to messages table for "delete for everyone" -ALTER TABLE messages ADD COLUMN deleted_at TIMESTAMPTZ; - +ALTER TABLE messages ADD COLUMN deleted_at TIMESTAMPTZ; -- Create message_deletions table for "delete for self" (per-user deletions) CREATE TABLE message_deletions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -8,22 +7,18 @@ CREATE TABLE message_deletions ( user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, deleted_at TIMESTAMPTZ DEFAULT now(), UNIQUE(message_id, user_id) -); - +); -- Index for efficient lookup when filtering messages -CREATE INDEX idx_message_deletions_user_message ON message_deletions(user_id, message_id); -CREATE INDEX idx_messages_deleted_at ON messages(deleted_at) WHERE deleted_at IS NOT NULL; - +CREATE INDEX idx_message_deletions_user_message ON message_deletions(user_id, message_id); +CREATE INDEX idx_messages_deleted_at ON messages(deleted_at) WHERE deleted_at IS NOT NULL; -- Enable RLS on message_deletions -ALTER TABLE message_deletions ENABLE ROW LEVEL SECURITY; - +ALTER TABLE message_deletions ENABLE ROW LEVEL SECURITY; -- RLS Policies for message_deletions -- Users can view their own deletions (needed for filtering) CREATE POLICY "Users can view own deletions" ON message_deletions FOR SELECT - USING (user_id = auth.uid()); - + USING (user_id = auth.uid()); -- Users can insert their own deletions (for messages in their matches) CREATE POLICY "Users can delete messages for self" ON message_deletions FOR INSERT @@ -36,17 +31,14 @@ CREATE POLICY "Users can delete messages for self" WHERE m.id = message_deletions.message_id AND p.id = auth.uid() ) - ); - + ); -- Update messages RLS policy to allow authors to set deleted_at CREATE POLICY "Authors can soft delete messages for everyone" ON messages FOR UPDATE USING (user_id = auth.uid()) - WITH CHECK (user_id = auth.uid()); - + WITH CHECK (user_id = auth.uid()); -- Add to realtime for live updates -ALTER PUBLICATION supabase_realtime ADD TABLE message_deletions; - +ALTER PUBLICATION supabase_realtime ADD TABLE message_deletions; -- Comments for documentation -COMMENT ON COLUMN messages.deleted_at IS 'Timestamp when message was deleted for everyone (author-only action). Shows "Message deleted" to all users.'; -COMMENT ON TABLE message_deletions IS 'Per-user message deletions for "delete for self" functionality. Message still visible to other user.'; +COMMENT ON COLUMN messages.deleted_at IS 'Timestamp when message was deleted for everyone (author-only action). Shows "Message deleted" to all users.'; +COMMENT ON TABLE message_deletions IS 'Per-user message deletions for "delete for self" functionality. Message still visible to other user.'; diff --git a/apps/supabase/migrations/20260107000000_add_message_reports.sql b/apps/supabase/migrations/20260107000000_add_message_reports.sql index 2035d94..cc7f64d 100644 --- a/apps/supabase/migrations/20260107000000_add_message_reports.sql +++ b/apps/supabase/migrations/20260107000000_add_message_reports.sql @@ -1,6 +1,5 @@ -- Create enum for report reasons -CREATE TYPE report_reason AS ENUM ('harassment', 'spam', 'inappropriate_content', 'other'); - +CREATE TYPE report_reason AS ENUM ('harassment', 'spam', 'inappropriate_content', 'other'); -- Create message_reports table CREATE TABLE message_reports ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -14,23 +13,19 @@ CREATE TABLE message_reports ( -- Prevent duplicate reports from same user UNIQUE(message_id, reporter_id) -); - +); -- Indexes for efficient queries -CREATE INDEX idx_message_reports_status ON message_reports(status); -CREATE INDEX idx_message_reports_created ON message_reports(created_at DESC); -CREATE INDEX idx_message_reports_message ON message_reports(message_id); - +CREATE INDEX idx_message_reports_status ON message_reports(status); +CREATE INDEX idx_message_reports_created ON message_reports(created_at DESC); +CREATE INDEX idx_message_reports_message ON message_reports(message_id); -- Enable RLS -ALTER TABLE message_reports ENABLE ROW LEVEL SECURITY; - +ALTER TABLE message_reports ENABLE ROW LEVEL SECURITY; -- RLS Policies -- Users can view their own reports CREATE POLICY "Users can view own reports" ON message_reports FOR SELECT - USING (reporter_id = auth.uid()); - + USING (reporter_id = auth.uid()); -- Users can create reports for messages in their couple's matches (but NOT their own messages) CREATE POLICY "Users can report partner messages" ON message_reports FOR INSERT @@ -44,22 +39,18 @@ CREATE POLICY "Users can report partner messages" AND p.id = auth.uid() AND m.user_id != auth.uid() -- Cannot report own messages ) - ); - + ); -- Admins can view all reports CREATE POLICY "Admins can view all reports" ON message_reports FOR SELECT - USING (is_admin()); - + USING (is_admin()); -- Admins can update reports (for review status) CREATE POLICY "Admins can update reports" ON message_reports FOR UPDATE - USING (is_admin()); - + USING (is_admin()); -- Add to realtime for live updates in admin -ALTER PUBLICATION supabase_realtime ADD TABLE message_reports; - +ALTER PUBLICATION supabase_realtime ADD TABLE message_reports; -- Comments -COMMENT ON TABLE message_reports IS 'User-submitted reports for messages. Separate from AI moderation (moderation_status on messages table).'; -COMMENT ON COLUMN message_reports.status IS 'Report status: pending (new), reviewed (action taken), dismissed (no action needed).'; -COMMENT ON COLUMN message_reports.reason IS 'User-selected reason for reporting: harassment, spam, inappropriate_content, or other.'; +COMMENT ON TABLE message_reports IS 'User-submitted reports for messages. Separate from AI moderation (moderation_status on messages table).'; +COMMENT ON COLUMN message_reports.status IS 'Report status: pending (new), reviewed (action taken), dismissed (no action needed).'; +COMMENT ON COLUMN message_reports.reason IS 'User-selected reason for reporting: harassment, spam, inappropriate_content, or other.'; diff --git a/apps/supabase/migrations/20260107000001_add_message_reports_profiles_fk.sql b/apps/supabase/migrations/20260107000001_add_message_reports_profiles_fk.sql index 8ded8c5..ffeed02 100644 --- a/apps/supabase/migrations/20260107000001_add_message_reports_profiles_fk.sql +++ b/apps/supabase/migrations/20260107000001_add_message_reports_profiles_fk.sql @@ -2,4 +2,4 @@ -- This allows Supabase to join with profiles table in admin queries ALTER TABLE message_reports ADD CONSTRAINT message_reports_reporter_profile_fk -FOREIGN KEY (reporter_id) REFERENCES profiles(id) ON DELETE CASCADE; +FOREIGN KEY (reporter_id) REFERENCES profiles(id) ON DELETE CASCADE; diff --git a/apps/supabase/migrations/20260108000000_add_intensity_levels.sql b/apps/supabase/migrations/20260108000000_add_intensity_levels.sql index 2d8c150..17aa1e0 100644 --- a/apps/supabase/migrations/20260108000000_add_intensity_levels.sql +++ b/apps/supabase/migrations/20260108000000_add_intensity_levels.sql @@ -3,19 +3,16 @@ ALTER TABLE profiles ADD COLUMN IF NOT EXISTS max_intensity INTEGER DEFAULT 2 CHECK (max_intensity >= 1 AND max_intensity <= 5); - UPDATE profiles SET max_intensity = CASE WHEN show_explicit_content IS TRUE THEN 5 ELSE 2 END WHERE max_intensity IS NULL; - ALTER TABLE question_packs ADD COLUMN IF NOT EXISTS min_intensity INTEGER, ADD COLUMN IF NOT EXISTS max_intensity INTEGER, ADD COLUMN IF NOT EXISTS avg_intensity NUMERIC(3,2); - CREATE OR REPLACE FUNCTION update_question_pack_intensity_stats(target_pack_id UUID) RETURNS void LANGUAGE plpgsql @@ -46,7 +43,6 @@ BEGIN END IF; END; $$; - CREATE OR REPLACE FUNCTION handle_question_intensity_change() RETURNS TRIGGER LANGUAGE plpgsql @@ -66,13 +62,10 @@ BEGIN RETURN NEW; END; $$; - DROP TRIGGER IF EXISTS question_pack_intensity_stats ON questions; - CREATE TRIGGER question_pack_intensity_stats AFTER INSERT OR UPDATE OR DELETE ON questions FOR EACH ROW EXECUTE FUNCTION handle_question_intensity_change(); - UPDATE question_packs qp SET min_intensity = stats.min_intensity, max_intensity = stats.max_intensity, diff --git a/apps/supabase/migrations/20260108100000_filter_questions_by_user_intensity.sql b/apps/supabase/migrations/20260108100000_filter_questions_by_user_intensity.sql index 96d3afd..165951d 100644 --- a/apps/supabase/migrations/20260108100000_filter_questions_by_user_intensity.sql +++ b/apps/supabase/migrations/20260108100000_filter_questions_by_user_intensity.sql @@ -169,5 +169,4 @@ BEGIN ); END; $$; - COMMENT ON FUNCTION get_recommended_questions(UUID) IS 'Returns recommended questions for user filtered by their intensity preference. When user is too far ahead of partner (answer gap threshold exceeded), returns empty.'; diff --git a/apps/supabase/migrations/20260109000000_add_admin_username_to_audit_logs.sql b/apps/supabase/migrations/20260109000000_add_admin_username_to_audit_logs.sql index f794b39..df6d853 100644 --- a/apps/supabase/migrations/20260109000000_add_admin_username_to_audit_logs.sql +++ b/apps/supabase/migrations/20260109000000_add_admin_username_to_audit_logs.sql @@ -1,14 +1,12 @@ -- Add admin_username column to audit_logs table ALTER TABLE audit_logs ADD COLUMN admin_username TEXT; - -- Backfill existing audit logs with admin names from profiles UPDATE audit_logs SET admin_username = COALESCE(p.name, p.email, 'Unknown') FROM profiles p WHERE audit_logs.admin_user_id = p.id AND audit_logs.admin_username IS NULL; - -- Update the log_admin_action function to populate admin_username from profiles CREATE OR REPLACE FUNCTION log_admin_action( p_table_name TEXT, diff --git a/apps/supabase/migrations/20260109000001_create_dares_feature.sql b/apps/supabase/migrations/20260109000001_create_dares_feature.sql index 16965b7..7b1b748 100644 --- a/apps/supabase/migrations/20260109000001_create_dares_feature.sql +++ b/apps/supabase/migrations/20260109000001_create_dares_feature.sql @@ -13,7 +13,6 @@ CREATE TYPE dare_status AS ENUM ( 'declined', -- Recipient declined 'cancelled' -- Sender cancelled before completion ); - -- ============================================================================ -- TABLES -- ============================================================================ @@ -34,17 +33,14 @@ CREATE TABLE dare_packs ( avg_intensity NUMERIC(3,2), created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); - COMMENT ON TABLE dare_packs IS 'Collections of dares organized by theme or intensity level'; COMMENT ON COLUMN dare_packs.min_intensity IS 'Auto-calculated minimum intensity from dares in this pack'; COMMENT ON COLUMN dare_packs.max_intensity IS 'Auto-calculated maximum intensity from dares in this pack'; COMMENT ON COLUMN dare_packs.avg_intensity IS 'Auto-calculated average intensity from dares in this pack'; - -- Create indexes for dare_packs CREATE INDEX idx_dare_packs_sort_order ON dare_packs(sort_order); CREATE INDEX idx_dare_packs_visibility ON dare_packs(is_public, is_premium); CREATE INDEX idx_dare_packs_category ON dare_packs(category_id); - -- Dares table (individual dare content) CREATE TABLE dares ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -54,15 +50,12 @@ CREATE TABLE dares ( suggested_duration_hours INTEGER, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); - COMMENT ON TABLE dares IS 'Individual dares within dare packs'; COMMENT ON COLUMN dares.intensity IS 'Difficulty/spiciness level from 1 (mild) to 5 (intense)'; COMMENT ON COLUMN dares.suggested_duration_hours IS 'Suggested time to complete the dare in hours (null = no suggestion)'; - -- Create indexes for dares CREATE INDEX idx_dares_pack_id ON dares(pack_id); CREATE INDEX idx_dares_intensity ON dares(intensity); - -- Sent dares table (tracks dare instances between partners) CREATE TABLE sent_dares ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -81,21 +74,18 @@ CREATE TABLE sent_dares ( created_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT dare_or_custom CHECK (dare_id IS NOT NULL OR custom_dare_text IS NOT NULL) ); - COMMENT ON TABLE sent_dares IS 'Dare instances sent between partners in a couple'; COMMENT ON COLUMN sent_dares.dare_id IS 'Reference to the dare from a pack (null for custom dares)'; COMMENT ON COLUMN sent_dares.custom_dare_text IS 'Text for custom dares created by premium users'; COMMENT ON COLUMN sent_dares.custom_dare_intensity IS 'Intensity for custom dares (1-5)'; COMMENT ON COLUMN sent_dares.expires_at IS 'Deadline for completing the dare (null = no time limit)'; COMMENT ON COLUMN sent_dares.sender_notes IS 'Optional message from sender when sending the dare'; - -- Create indexes for sent_dares CREATE INDEX idx_sent_dares_couple_id ON sent_dares(couple_id); CREATE INDEX idx_sent_dares_sender_id ON sent_dares(sender_id); CREATE INDEX idx_sent_dares_recipient_id ON sent_dares(recipient_id); CREATE INDEX idx_sent_dares_status ON sent_dares(status); CREATE INDEX idx_sent_dares_expires_at ON sent_dares(expires_at) WHERE expires_at IS NOT NULL AND status = 'active'; - -- Dare messages table (chat for each dare) CREATE TABLE dare_messages ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), @@ -105,13 +95,10 @@ CREATE TABLE dare_messages ( read_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); - COMMENT ON TABLE dare_messages IS 'Chat messages associated with each sent dare'; - -- Create indexes for dare_messages CREATE INDEX idx_dare_messages_sent_dare_id ON dare_messages(sent_dare_id); CREATE INDEX idx_dare_messages_created_at ON dare_messages(sent_dare_id, created_at); - -- ============================================================================ -- TRIGGERS -- ============================================================================ @@ -136,12 +123,10 @@ BEGIN RETURN COALESCE(NEW, OLD); END; $$ LANGUAGE plpgsql; - -- Create trigger on dares table CREATE TRIGGER dare_intensity_stats_trigger AFTER INSERT OR UPDATE OR DELETE ON dares FOR EACH ROW EXECUTE FUNCTION update_dare_pack_intensity_stats(); - -- ============================================================================ -- STATS FUNCTIONS -- ============================================================================ @@ -163,7 +148,6 @@ BEGIN (SELECT COUNT(*) FROM sent_dares WHERE recipient_id = p_user_id AND status = 'completed')::BIGINT; END; $$ LANGUAGE plpgsql SECURITY DEFINER; - -- Function to get dare stats for a couple CREATE OR REPLACE FUNCTION get_couple_dare_stats(p_couple_id UUID) RETURNS TABLE ( @@ -179,7 +163,6 @@ BEGIN (SELECT COUNT(*) FROM sent_dares WHERE couple_id = p_couple_id AND status = 'active')::BIGINT; END; $$ LANGUAGE plpgsql SECURITY DEFINER; - -- ============================================================================ -- ROW LEVEL SECURITY -- ============================================================================ @@ -189,7 +172,6 @@ ALTER TABLE dare_packs ENABLE ROW LEVEL SECURITY; ALTER TABLE dares ENABLE ROW LEVEL SECURITY; ALTER TABLE sent_dares ENABLE ROW LEVEL SECURITY; ALTER TABLE dare_messages ENABLE ROW LEVEL SECURITY; - -- ------------------------- -- dare_packs policies -- ------------------------- @@ -198,7 +180,6 @@ ALTER TABLE dare_messages ENABLE ROW LEVEL SECURITY; CREATE POLICY "Public dare packs are viewable by everyone" ON dare_packs FOR SELECT USING (is_public = true); - -- Admins can view all dare packs CREATE POLICY "Admins can view all dare packs" ON dare_packs FOR SELECT @@ -207,7 +188,6 @@ USING ( SELECT 1 FROM admin_users WHERE user_id = auth.uid() ) ); - -- Admins can insert dare packs CREATE POLICY "Admins can insert dare packs" ON dare_packs FOR INSERT @@ -216,7 +196,6 @@ WITH CHECK ( SELECT 1 FROM admin_users WHERE user_id = auth.uid() ) ); - -- Admins can update dare packs CREATE POLICY "Admins can update dare packs" ON dare_packs FOR UPDATE @@ -225,7 +204,6 @@ USING ( SELECT 1 FROM admin_users WHERE user_id = auth.uid() ) ); - -- Admins can delete dare packs CREATE POLICY "Admins can delete dare packs" ON dare_packs FOR DELETE @@ -234,7 +212,6 @@ USING ( SELECT 1 FROM admin_users WHERE user_id = auth.uid() ) ); - -- ------------------------- -- dares policies -- ------------------------- @@ -253,7 +230,6 @@ USING ( ) ) ); - -- Admins can view all dares CREATE POLICY "Admins can view all dares" ON dares FOR SELECT @@ -262,7 +238,6 @@ USING ( SELECT 1 FROM admin_users WHERE user_id = auth.uid() ) ); - -- Admins can insert dares CREATE POLICY "Admins can insert dares" ON dares FOR INSERT @@ -271,7 +246,6 @@ WITH CHECK ( SELECT 1 FROM admin_users WHERE user_id = auth.uid() ) ); - -- Admins can update dares CREATE POLICY "Admins can update dares" ON dares FOR UPDATE @@ -280,7 +254,6 @@ USING ( SELECT 1 FROM admin_users WHERE user_id = auth.uid() ) ); - -- Admins can delete dares CREATE POLICY "Admins can delete dares" ON dares FOR DELETE @@ -289,7 +262,6 @@ USING ( SELECT 1 FROM admin_users WHERE user_id = auth.uid() ) ); - -- ------------------------- -- sent_dares policies -- ------------------------- @@ -298,7 +270,6 @@ USING ( CREATE POLICY "Users can view their couple's sent dares" ON sent_dares FOR SELECT USING (couple_id = get_auth_user_couple_id()); - -- Users can insert sent dares (sender must be the authenticated user) CREATE POLICY "Users can send dares to their partner" ON sent_dares FOR INSERT @@ -306,12 +277,10 @@ WITH CHECK ( sender_id = auth.uid() AND couple_id = get_auth_user_couple_id() ); - -- Users can update sent dares in their couple CREATE POLICY "Users can update their couple's sent dares" ON sent_dares FOR UPDATE USING (couple_id = get_auth_user_couple_id()); - -- Admins can view all sent dares CREATE POLICY "Admins can view all sent dares" ON sent_dares FOR SELECT @@ -320,7 +289,6 @@ USING ( SELECT 1 FROM admin_users WHERE user_id = auth.uid() ) ); - -- ------------------------- -- dare_messages policies -- ------------------------- @@ -335,7 +303,6 @@ USING ( AND sd.couple_id = get_auth_user_couple_id() ) ); - -- Users can insert messages for their couple's dares CREATE POLICY "Users can send messages for their dares" ON dare_messages FOR INSERT @@ -347,7 +314,6 @@ WITH CHECK ( AND sd.couple_id = get_auth_user_couple_id() ) ); - -- Users can update messages in their dares (for read_at) CREATE POLICY "Users can update message read status" ON dare_messages FOR UPDATE @@ -358,7 +324,6 @@ USING ( AND sd.couple_id = get_auth_user_couple_id() ) ); - -- Admins can view all dare messages CREATE POLICY "Admins can view all dare messages" ON dare_messages FOR SELECT @@ -367,7 +332,6 @@ USING ( SELECT 1 FROM admin_users WHERE user_id = auth.uid() ) ); - -- ============================================================================ -- REALTIME -- ============================================================================ diff --git a/apps/supabase/migrations/20260109100000_add_match_archives.sql b/apps/supabase/migrations/20260109100000_add_match_archives.sql index 3ee7ff9..8b74330 100644 --- a/apps/supabase/migrations/20260109100000_add_match_archives.sql +++ b/apps/supabase/migrations/20260109100000_add_match_archives.sql @@ -6,21 +6,17 @@ CREATE TABLE match_archives ( user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, archived_at TIMESTAMPTZ DEFAULT now(), UNIQUE(match_id, user_id) -); - +); -- Index for efficient lookup when filtering matches -CREATE INDEX idx_match_archives_user_match ON match_archives(user_id, match_id); - +CREATE INDEX idx_match_archives_user_match ON match_archives(user_id, match_id); -- Enable RLS on match_archives -ALTER TABLE match_archives ENABLE ROW LEVEL SECURITY; - +ALTER TABLE match_archives ENABLE ROW LEVEL SECURITY; -- RLS Policies for match_archives -- Users can view their own archives (needed for filtering) CREATE POLICY "Users can view own archives" ON match_archives FOR SELECT - USING (user_id = auth.uid()); - + USING (user_id = auth.uid()); -- Users can insert their own archives (for matches in their couples) CREATE POLICY "Users can archive matches" ON match_archives FOR INSERT @@ -32,15 +28,12 @@ CREATE POLICY "Users can archive matches" WHERE m.id = match_archives.match_id AND p.id = auth.uid() ) - ); - + ); -- Users can delete their own archives (for unarchiving) CREATE POLICY "Users can unarchive matches" ON match_archives FOR DELETE - USING (user_id = auth.uid()); - + USING (user_id = auth.uid()); -- Add to realtime for live updates -ALTER PUBLICATION supabase_realtime ADD TABLE match_archives; - +ALTER PUBLICATION supabase_realtime ADD TABLE match_archives; -- Documentation -COMMENT ON TABLE match_archives IS 'Per-user match archives. Partner still sees the match. Delete record to unarchive.'; +COMMENT ON TABLE match_archives IS 'Per-user match archives. Partner still sees the match. Delete record to unarchive.'; diff --git a/apps/supabase/migrations/20260110000000_add_category_visibility.sql b/apps/supabase/migrations/20260110000000_add_category_visibility.sql index 4c575ff..84a54b0 100644 --- a/apps/supabase/migrations/20260110000000_add_category_visibility.sql +++ b/apps/supabase/migrations/20260110000000_add_category_visibility.sql @@ -1,13 +1,11 @@ -- Add visibility toggle for categories ALTER TABLE public.categories ADD COLUMN IF NOT EXISTS is_public BOOLEAN NOT NULL DEFAULT true; - -- Categories: hide non-public categories from non-admins DROP POLICY IF EXISTS "Categories are viewable by everyone" ON public.categories; CREATE POLICY "Categories are viewable by everyone" ON public.categories FOR SELECT USING (is_public = true); - -- Question packs: require public categories for visibility DROP POLICY IF EXISTS "Anyone can view public packs" ON public.question_packs; CREATE POLICY "Anyone can view public packs" @@ -24,7 +22,6 @@ CREATE POLICY "Anyone can view public packs" ) ) ); - DROP POLICY IF EXISTS "Premium users can view premium packs" ON public.question_packs; CREATE POLICY "Premium users can view premium packs" ON public.question_packs FOR SELECT @@ -44,7 +41,6 @@ CREATE POLICY "Premium users can view premium packs" ) ) ); - -- Questions: require visible packs and public categories DROP POLICY IF EXISTS "Anyone can view questions in visible packs" ON public.questions; CREATE POLICY "Anyone can view questions in visible packs" @@ -71,7 +67,6 @@ CREATE POLICY "Anyone can view questions in visible packs" ) ) ); - -- Ensure recommendation queries honor category visibility CREATE OR REPLACE FUNCTION get_recommended_questions(target_pack_id UUID DEFAULT NULL) RETURNS TABLE( @@ -223,7 +218,6 @@ BEGIN ); END; $$; - CREATE OR REPLACE FUNCTION get_pack_teaser_questions(target_pack_id UUID) RETURNS TABLE(id UUID, text TEXT, intensity INTEGER) LANGUAGE plpgsql diff --git a/apps/supabase/migrations/20260110000001_update_recommended_questions_daily_limit.sql b/apps/supabase/migrations/20260110000001_update_recommended_questions_daily_limit.sql index 23b240d..d7200a5 100644 --- a/apps/supabase/migrations/20260110000001_update_recommended_questions_daily_limit.sql +++ b/apps/supabase/migrations/20260110000001_update_recommended_questions_daily_limit.sql @@ -201,5 +201,4 @@ BEGIN ); END; $$; - COMMENT ON FUNCTION get_recommended_questions(UUID) IS 'Returns recommended questions for user filtered by their intensity preference. When daily limit or answer gap threshold is exceeded for non-premium users, returns empty.'; diff --git a/apps/supabase/migrations/20260110000003_add_daily_response_limit.sql b/apps/supabase/migrations/20260110000003_add_daily_response_limit.sql index 1dc3d8f..514d2f9 100644 --- a/apps/supabase/migrations/20260110000003_add_daily_response_limit.sql +++ b/apps/supabase/migrations/20260110000003_add_daily_response_limit.sql @@ -4,9 +4,7 @@ -- Add daily_response_limit column to app_config ALTER TABLE app_config ADD COLUMN daily_response_limit INTEGER DEFAULT 0; - COMMENT ON COLUMN app_config.daily_response_limit IS 'Maximum number of questions a non-premium user can answer per UTC day. Set to 0 to disable daily limits.'; - -- ============================================================================ -- RPC FUNCTION: get_daily_response_limit_status -- Returns the current daily response limit status for the authenticated user @@ -73,5 +71,4 @@ BEGIN (v_count >= v_limit); END; $$; - COMMENT ON FUNCTION get_daily_response_limit_status() IS 'Returns daily response limit status for the current user. Premium users and disabled limits return is_blocked=false.'; diff --git a/apps/supabase/migrations/20260111000000_add_required_props_to_questions.sql b/apps/supabase/migrations/20260111000000_add_required_props_to_questions.sql index ecf59fb..48ffe81 100644 --- a/apps/supabase/migrations/20260111000000_add_required_props_to_questions.sql +++ b/apps/supabase/migrations/20260111000000_add_required_props_to_questions.sql @@ -1,5 +1,4 @@ ALTER TABLE public.questions ADD COLUMN required_props TEXT[]; - COMMENT ON COLUMN public.questions.required_props IS 'Array of props/accessories required to perform the activity.'; diff --git a/apps/supabase/migrations/20260111000001_randomize_question_order.sql b/apps/supabase/migrations/20260111000001_randomize_question_order.sql index cc50ba0..431bfe6 100644 --- a/apps/supabase/migrations/20260111000001_randomize_question_order.sql +++ b/apps/supabase/migrations/20260111000001_randomize_question_order.sql @@ -223,6 +223,5 @@ BEGIN ) DESC, RANDOM(); END; -$$; - -COMMENT ON FUNCTION get_recommended_questions(UUID) IS 'Returns recommended questions with partner-answered first, then randomized for variety. Uses NET gap calculation for blocking.'; +$$; +COMMENT ON FUNCTION get_recommended_questions(UUID) IS 'Returns recommended questions with partner-answered first, then randomized for variety. Uses NET gap calculation for blocking.'; diff --git a/apps/supabase/migrations/20260111000002_fix_answer_gap_deadlock.sql b/apps/supabase/migrations/20260111000002_fix_answer_gap_deadlock.sql index 2d4e29c..5c43473 100644 --- a/apps/supabase/migrations/20260111000002_fix_answer_gap_deadlock.sql +++ b/apps/supabase/migrations/20260111000002_fix_answer_gap_deadlock.sql @@ -103,10 +103,8 @@ BEGIN -- Return net gap (displayed as "unanswered_by_partner" for backwards compatibility) RETURN QUERY SELECT v_net_gap, v_threshold, (v_net_gap >= v_threshold); END; -$$; - -COMMENT ON FUNCTION get_answer_gap_status() IS 'Returns answer gap status using NET gap calculation. Prevents deadlocks when both partners answer different questions.'; - +$$; +COMMENT ON FUNCTION get_answer_gap_status() IS 'Returns answer gap status using NET gap calculation. Prevents deadlocks when both partners answer different questions.'; -- ============================================================================ -- Part 2: Update get_recommended_questions() to prioritize partner-answered questions -- ============================================================================ @@ -336,6 +334,5 @@ BEGIN ) DESC, q.intensity ASC; END; -$$; - -COMMENT ON FUNCTION get_recommended_questions(UUID) IS 'Returns recommended questions prioritizing partner-answered questions first to prevent deadlocks. Uses NET gap calculation for blocking.'; +$$; +COMMENT ON FUNCTION get_recommended_questions(UUID) IS 'Returns recommended questions prioritizing partner-answered questions first to prevent deadlocks. Uses NET gap calculation for blocking.'; diff --git a/apps/supabase/migrations/20260111100000_add_inverse_of_to_questions.sql b/apps/supabase/migrations/20260111100000_add_inverse_of_to_questions.sql index 1d80b9a..8f9c68e 100644 --- a/apps/supabase/migrations/20260111100000_add_inverse_of_to_questions.sql +++ b/apps/supabase/migrations/20260111100000_add_inverse_of_to_questions.sql @@ -4,16 +4,16 @@ -- The "primary" question has inverse_of = NULL -- The "secondary" (inverse) question points to its primary --- Add the column -ALTER TABLE questions -ADD COLUMN inverse_of UUID REFERENCES questions(id) ON DELETE SET NULL; - --- Add a comment to document the column -COMMENT ON COLUMN questions.inverse_of IS 'References the primary question that this question is an inverse of. Used to track question pairs (e.g., give/receive). Primary questions have inverse_of = NULL, secondary questions point to their primary.'; - --- Create an index for efficient lookups -CREATE INDEX idx_questions_inverse_of ON questions(inverse_of) WHERE inverse_of IS NOT NULL; - +-- Add the columns +ALTER TABLE questions +ADD COLUMN inverse_of UUID REFERENCES questions(id) ON DELETE SET NULL; +ALTER TABLE questions +ADD COLUMN deleted_at TIMESTAMPTZ; +-- Add comments to document the columns +COMMENT ON COLUMN questions.inverse_of IS 'References the primary question that this question is an inverse of. Used to track question pairs (e.g., give/receive). Primary questions have inverse_of = NULL, secondary questions point to their primary.'; +COMMENT ON COLUMN questions.deleted_at IS 'Soft delete timestamp. NULL means the question is active.'; +-- Create an index for efficient lookups +CREATE INDEX idx_questions_inverse_of ON questions(inverse_of) WHERE inverse_of IS NOT NULL; -- Create a function to get unique question count for a pack -- Unique count = questions that are NOT inverses of another question (i.e., inverse_of IS NULL) CREATE OR REPLACE FUNCTION get_pack_unique_question_count(pack_uuid UUID) @@ -26,11 +26,9 @@ AS $$ WHERE pack_id = pack_uuid AND inverse_of IS NULL AND deleted_at IS NULL; -$$; - +$$; -- Grant execute permission to authenticated users -GRANT EXECUTE ON FUNCTION get_pack_unique_question_count(UUID) TO authenticated; - +GRANT EXECUTE ON FUNCTION get_pack_unique_question_count(UUID) TO authenticated; -- Create a view to get pack stats including both total and unique counts CREATE OR REPLACE VIEW pack_question_stats AS SELECT @@ -39,7 +37,6 @@ SELECT COUNT(*) FILTER (WHERE inverse_of IS NULL AND deleted_at IS NULL) AS unique_questions, COUNT(*) FILTER (WHERE inverse_of IS NOT NULL AND deleted_at IS NULL) AS inverse_questions FROM questions -GROUP BY pack_id; - +GROUP BY pack_id; -- Grant select on the view -GRANT SELECT ON pack_question_stats TO authenticated; +GRANT SELECT ON pack_question_stats TO authenticated; diff --git a/apps/supabase/migrations/20260111120000_add_fk_indexes_drop_unused.sql b/apps/supabase/migrations/20260111120000_add_fk_indexes_drop_unused.sql index 78c4a22..e82845e 100644 --- a/apps/supabase/migrations/20260111120000_add_fk_indexes_drop_unused.sql +++ b/apps/supabase/migrations/20260111120000_add_fk_indexes_drop_unused.sql @@ -1,42 +1,41 @@ -- Add indexes for unindexed foreign keys -- These improve JOIN and DELETE performance -CREATE INDEX IF NOT EXISTS idx_admin_users_created_by ON public.admin_users(created_by); -CREATE INDEX IF NOT EXISTS idx_ai_config_updated_by ON public.ai_config(updated_by); -CREATE INDEX IF NOT EXISTS idx_app_config_updated_by ON public.app_config(updated_by); -CREATE INDEX IF NOT EXISTS idx_code_redemptions_user_id ON public.code_redemptions(user_id); -CREATE INDEX IF NOT EXISTS idx_couple_packs_pack_id ON public.couple_packs(pack_id); -CREATE INDEX IF NOT EXISTS idx_dare_messages_sender_id ON public.dare_messages(sender_id); -CREATE INDEX IF NOT EXISTS idx_matches_question_id ON public.matches(question_id); -CREATE INDEX IF NOT EXISTS idx_message_reports_reporter_id ON public.message_reports(reporter_id); -CREATE INDEX IF NOT EXISTS idx_message_reports_reviewed_by ON public.message_reports(reviewed_by); -CREATE INDEX IF NOT EXISTS idx_messages_match_id ON public.messages(match_id); -CREATE INDEX IF NOT EXISTS idx_messages_user_id ON public.messages(user_id); -CREATE INDEX IF NOT EXISTS idx_redemption_codes_created_by ON public.redemption_codes(created_by); -CREATE INDEX IF NOT EXISTS idx_responses_question_id ON public.responses(question_id); -CREATE INDEX IF NOT EXISTS idx_sent_dares_dare_id ON public.sent_dares(dare_id); - +CREATE INDEX IF NOT EXISTS idx_admin_users_created_by ON public.admin_users(created_by); +CREATE INDEX IF NOT EXISTS idx_ai_config_updated_by ON public.ai_config(updated_by); +CREATE INDEX IF NOT EXISTS idx_app_config_updated_by ON public.app_config(updated_by); +CREATE INDEX IF NOT EXISTS idx_code_redemptions_user_id ON public.code_redemptions(user_id); +CREATE INDEX IF NOT EXISTS idx_couple_packs_pack_id ON public.couple_packs(pack_id); +CREATE INDEX IF NOT EXISTS idx_dare_messages_sender_id ON public.dare_messages(sender_id); +CREATE INDEX IF NOT EXISTS idx_matches_question_id ON public.matches(question_id); +CREATE INDEX IF NOT EXISTS idx_message_reports_reporter_id ON public.message_reports(reporter_id); +CREATE INDEX IF NOT EXISTS idx_message_reports_reviewed_by ON public.message_reports(reviewed_by); +CREATE INDEX IF NOT EXISTS idx_messages_match_id ON public.messages(match_id); +CREATE INDEX IF NOT EXISTS idx_messages_user_id ON public.messages(user_id); +CREATE INDEX IF NOT EXISTS idx_redemption_codes_created_by ON public.redemption_codes(created_by); +CREATE INDEX IF NOT EXISTS idx_responses_question_id ON public.responses(question_id); +CREATE INDEX IF NOT EXISTS idx_sent_dares_dare_id ON public.sent_dares(dare_id); -- Drop unused indexes (identified by Supabase advisor as never used) -- These are just consuming storage with no benefit -DROP INDEX IF EXISTS public.idx_feedback_type; -DROP INDEX IF EXISTS public.idx_feedback_status; -DROP INDEX IF EXISTS public.idx_subscriptions_expires_at; -DROP INDEX IF EXISTS public.idx_webhook_events_event_id; -DROP INDEX IF EXISTS public.idx_audit_logs_record_id; -DROP INDEX IF EXISTS public.idx_audit_logs_action; -DROP INDEX IF EXISTS public.idx_pack_topics_topic_id; -DROP INDEX IF EXISTS public.idx_feature_interests_feature_name; -DROP INDEX IF EXISTS public.idx_profiles_couple_id_public_key; -DROP INDEX IF EXISTS public.idx_messages_deleted_at; -DROP INDEX IF EXISTS public.idx_message_reports_created; -DROP INDEX IF EXISTS public.idx_dare_packs_visibility; -DROP INDEX IF EXISTS public.idx_dares_pack_id; -DROP INDEX IF EXISTS public.idx_dares_intensity; -DROP INDEX IF EXISTS public.idx_sent_dares_sender_id; -DROP INDEX IF EXISTS public.idx_sent_dares_recipient_id; -DROP INDEX IF EXISTS public.idx_sent_dares_status; -DROP INDEX IF EXISTS public.idx_sent_dares_expires_at; -DROP INDEX IF EXISTS public.idx_dare_messages_sent_dare_id; -DROP INDEX IF EXISTS public.idx_dare_messages_created_at; -DROP INDEX IF EXISTS public.idx_questions_deleted_at; +DROP INDEX IF EXISTS public.idx_feedback_type; +DROP INDEX IF EXISTS public.idx_feedback_status; +DROP INDEX IF EXISTS public.idx_subscriptions_expires_at; +DROP INDEX IF EXISTS public.idx_webhook_events_event_id; +DROP INDEX IF EXISTS public.idx_audit_logs_record_id; +DROP INDEX IF EXISTS public.idx_audit_logs_action; +DROP INDEX IF EXISTS public.idx_pack_topics_topic_id; +DROP INDEX IF EXISTS public.idx_feature_interests_feature_name; +DROP INDEX IF EXISTS public.idx_profiles_couple_id_public_key; +DROP INDEX IF EXISTS public.idx_messages_deleted_at; +DROP INDEX IF EXISTS public.idx_message_reports_created; +DROP INDEX IF EXISTS public.idx_dare_packs_visibility; +DROP INDEX IF EXISTS public.idx_dares_pack_id; +DROP INDEX IF EXISTS public.idx_dares_intensity; +DROP INDEX IF EXISTS public.idx_sent_dares_sender_id; +DROP INDEX IF EXISTS public.idx_sent_dares_recipient_id; +DROP INDEX IF EXISTS public.idx_sent_dares_status; +DROP INDEX IF EXISTS public.idx_sent_dares_expires_at; +DROP INDEX IF EXISTS public.idx_dare_messages_sent_dare_id; +DROP INDEX IF EXISTS public.idx_dare_messages_created_at; +DROP INDEX IF EXISTS public.idx_questions_deleted_at; diff --git a/apps/supabase/migrations/20260112100000_add_pack_change_notifications.sql b/apps/supabase/migrations/20260112100000_add_pack_change_notifications.sql index c62b038..047f0bf 100644 --- a/apps/supabase/migrations/20260112100000_add_pack_change_notifications.sql +++ b/apps/supabase/migrations/20260112100000_add_pack_change_notifications.sql @@ -10,29 +10,23 @@ CREATE TABLE pending_pack_notifications ( created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now(), UNIQUE(couple_id) -- Only one pending notification per couple (enables UPSERT for timer reset) -); - +); -- Index for efficient cron queries CREATE INDEX idx_pending_pack_notifications_notify_at - ON pending_pack_notifications(notify_at); - + ON pending_pack_notifications(notify_at); -- Enable RLS -ALTER TABLE pending_pack_notifications ENABLE ROW LEVEL SECURITY; - +ALTER TABLE pending_pack_notifications ENABLE ROW LEVEL SECURITY; -- RLS policies CREATE POLICY "Users can view their couple's pending notifications" ON pending_pack_notifications FOR SELECT - USING (couple_id = get_auth_user_couple_id()); - + USING (couple_id = get_auth_user_couple_id()); CREATE POLICY "Super admins can view all pending notifications" ON pending_pack_notifications FOR SELECT - USING (is_super_admin()); - + USING (is_super_admin()); -- Updated_at trigger CREATE TRIGGER update_pending_pack_notifications_updated_at BEFORE UPDATE ON pending_pack_notifications - FOR EACH ROW EXECUTE FUNCTION update_updated_at(); - + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); -- Function to queue pack change notifications CREATE OR REPLACE FUNCTION queue_pack_change_notification() RETURNS TRIGGER @@ -79,14 +73,12 @@ BEGIN RETURN NEW; END; -$$; - +$$; -- Create trigger on couple_packs CREATE TRIGGER on_couple_pack_enabled AFTER INSERT OR UPDATE OF enabled ON couple_packs FOR EACH ROW - EXECUTE FUNCTION queue_pack_change_notification(); - + EXECUTE FUNCTION queue_pack_change_notification(); -- Schedule cron job (runs every 5 minutes) SELECT cron.schedule( 'send-pack-change-notifications', @@ -98,4 +90,4 @@ SELECT cron.schedule( body := '{}'::jsonb ) $$ -); +); diff --git a/apps/supabase/migrations/20260116145418_add_get_profile_with_auth_info_rpc.sql b/apps/supabase/migrations/20260116145418_add_get_profile_with_auth_info_rpc.sql new file mode 100644 index 0000000..599d8a7 --- /dev/null +++ b/apps/supabase/migrations/20260116145418_add_get_profile_with_auth_info_rpc.sql @@ -0,0 +1,41 @@ +-- Create RPC function to get a single profile with auth info +CREATE OR REPLACE FUNCTION get_profile_with_auth_info(user_id uuid) +RETURNS TABLE ( + id uuid, + name text, + email text, + avatar_url text, + is_premium boolean, + couple_id uuid, + created_at timestamptz, + gender text, + usage_reason text, + show_explicit_content boolean, + onboarding_completed boolean, + max_intensity smallint, + last_sign_in_at timestamptz, + email_confirmed_at timestamptz +) +LANGUAGE sql +SECURITY DEFINER +SET search_path = public +AS $$ + SELECT + p.id, + COALESCE(p.name, au.raw_user_meta_data->>'name')::text as name, + COALESCE(p.email, au.email)::text as email, + p.avatar_url::text, + p.is_premium, + p.couple_id, + p.created_at, + p.gender::text, + p.usage_reason::text, + p.show_explicit_content, + p.onboarding_completed, + p.max_intensity, + au.last_sign_in_at, + au.email_confirmed_at + FROM profiles p + LEFT JOIN auth.users au ON au.id = p.id + WHERE p.id = user_id; +$$;; diff --git a/apps/supabase/migrations/20260116183023_add_gift_premium_rls_policy.sql b/apps/supabase/migrations/20260116183023_add_gift_premium_rls_policy.sql new file mode 100644 index 0000000..37ca296 --- /dev/null +++ b/apps/supabase/migrations/20260116183023_add_gift_premium_rls_policy.sql @@ -0,0 +1,7 @@ + +-- Add RLS policy for admins with gift_premium permission to insert subscriptions +CREATE POLICY "Admins with gift_premium can insert subscriptions" +ON public.subscriptions +FOR INSERT +WITH CHECK (has_permission('gift_premium')); +; diff --git a/apps/supabase/migrations/20260116184058_add_admin_gift_premium_function.sql b/apps/supabase/migrations/20260116184058_add_admin_gift_premium_function.sql new file mode 100644 index 0000000..80bf233 --- /dev/null +++ b/apps/supabase/migrations/20260116184058_add_admin_gift_premium_function.sql @@ -0,0 +1,58 @@ + +-- Create a function that admins with gift_premium permission can use to gift premium +-- This bypasses RLS issues by using SECURITY DEFINER and checking permissions internally +CREATE OR REPLACE FUNCTION admin_gift_premium( + target_user_id UUID, + expires_at_param TIMESTAMPTZ DEFAULT NULL +) +RETURNS UUID +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + v_subscription_id UUID; + v_caller_role admin_role; + v_caller_permissions JSONB; +BEGIN + -- Check if caller has permission + SELECT role, permissions INTO v_caller_role, v_caller_permissions + FROM admin_users + WHERE user_id = auth.uid(); + + -- Must be super_admin OR have gift_premium permission + IF v_caller_role IS NULL THEN + RAISE EXCEPTION 'Access denied: not an admin user'; + END IF; + + IF v_caller_role != 'super_admin' AND NOT (v_caller_permissions @> '["gift_premium"]'::jsonb) THEN + RAISE EXCEPTION 'Access denied: missing gift_premium permission'; + END IF; + + -- Insert the subscription + INSERT INTO subscriptions ( + user_id, + revenuecat_app_user_id, + product_id, + status, + store, + purchased_at, + expires_at + ) VALUES ( + target_user_id, + 'admin_grant', + 'admin_premium', + 'active', + 'manual', + NOW(), + expires_at_param + ) + RETURNING id INTO v_subscription_id; + + RETURN v_subscription_id; +END; +$$; + +-- Grant execute permission to authenticated users (the function checks permissions internally) +GRANT EXECUTE ON FUNCTION admin_gift_premium(UUID, TIMESTAMPTZ) TO authenticated; +; diff --git a/apps/supabase/migrations/20260117000000_add_notification_preferences.sql b/apps/supabase/migrations/20260117000000_add_notification_preferences.sql new file mode 100644 index 0000000..b3d874f --- /dev/null +++ b/apps/supabase/migrations/20260117000000_add_notification_preferences.sql @@ -0,0 +1,74 @@ +-- Add notification preferences table +-- Allows users to opt out of specific notification types (opt-out model, all enabled by default) + +CREATE TABLE notification_preferences ( + user_id UUID PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE, + -- Core notifications (matching + messaging) + matches_enabled BOOLEAN DEFAULT true, + messages_enabled BOOLEAN DEFAULT true, + -- Re-engagement notifications + partner_activity_enabled BOOLEAN DEFAULT true, + pack_changes_enabled BOOLEAN DEFAULT true, + new_packs_enabled BOOLEAN DEFAULT true, + streak_milestones_enabled BOOLEAN DEFAULT true, + -- Timestamps + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +-- Enable RLS +ALTER TABLE notification_preferences ENABLE ROW LEVEL SECURITY; + +-- RLS policies: users can only manage their own preferences +CREATE POLICY "Users can view their own notification preferences" + ON notification_preferences FOR SELECT + USING (user_id = auth.uid()); + +CREATE POLICY "Users can insert their own notification preferences" + ON notification_preferences FOR INSERT + WITH CHECK (user_id = auth.uid()); + +CREATE POLICY "Users can update their own notification preferences" + ON notification_preferences FOR UPDATE + USING (user_id = auth.uid()) + WITH CHECK (user_id = auth.uid()); + +-- Super admins can view all preferences +CREATE POLICY "Super admins can view all notification preferences" + ON notification_preferences FOR SELECT + USING (is_super_admin()); + +-- Updated_at trigger +CREATE TRIGGER update_notification_preferences_updated_at + BEFORE UPDATE ON notification_preferences + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +-- Function to get notification preferences with defaults +-- Returns preferences if they exist, or creates a default row +CREATE OR REPLACE FUNCTION get_or_create_notification_preferences(p_user_id UUID) +RETURNS notification_preferences +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = 'public' +AS $$ +DECLARE + v_prefs notification_preferences; +BEGIN + -- Try to get existing preferences + SELECT * INTO v_prefs + FROM notification_preferences + WHERE user_id = p_user_id; + + -- If not found, create default preferences + IF NOT FOUND THEN + INSERT INTO notification_preferences (user_id) + VALUES (p_user_id) + RETURNING * INTO v_prefs; + END IF; + + RETURN v_prefs; +END; +$$; + +-- Grant execute permission to authenticated users +GRANT EXECUTE ON FUNCTION get_or_create_notification_preferences(UUID) TO authenticated; diff --git a/apps/supabase/migrations/20260117000001_add_partner_activity_notifications.sql b/apps/supabase/migrations/20260117000001_add_partner_activity_notifications.sql new file mode 100644 index 0000000..25e6afe --- /dev/null +++ b/apps/supabase/migrations/20260117000001_add_partner_activity_notifications.sql @@ -0,0 +1,110 @@ +-- Add partner activity notification system +-- Notifies partner when a user answers questions (batched with 1-hour debounce) + +-- Add last_active_at to profiles for "is user in app" check +ALTER TABLE profiles ADD COLUMN IF NOT EXISTS last_active_at TIMESTAMPTZ; + +-- Create pending activity notifications table +CREATE TABLE pending_activity_notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + couple_id UUID NOT NULL REFERENCES couples(id) ON DELETE CASCADE, + active_user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + response_count INTEGER DEFAULT 1, + notify_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now(), + UNIQUE(couple_id) -- Only one pending notification per couple (enables UPSERT for timer reset) +); + +-- Index for efficient cron queries +CREATE INDEX idx_pending_activity_notifications_notify_at + ON pending_activity_notifications(notify_at); + +-- Enable RLS +ALTER TABLE pending_activity_notifications ENABLE ROW LEVEL SECURITY; + +-- RLS policies +CREATE POLICY "Users can view their couple's pending activity notifications" + ON pending_activity_notifications FOR SELECT + USING (couple_id = get_auth_user_couple_id()); + +CREATE POLICY "Super admins can view all pending activity notifications" + ON pending_activity_notifications FOR SELECT + USING (is_super_admin()); + +-- Updated_at trigger +CREATE TRIGGER update_pending_activity_notifications_updated_at + BEFORE UPDATE ON pending_activity_notifications + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +-- Function to queue partner activity notifications +-- Called via trigger when a response is inserted or updated +CREATE OR REPLACE FUNCTION queue_partner_activity_notification() +RETURNS TRIGGER +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = 'public' +AS $$ +DECLARE + v_user_id UUID; + v_couple_id UUID; +BEGIN + -- Get the user making the response + v_user_id := auth.uid(); + + -- Skip if no auth context (service role operations) + IF v_user_id IS NULL THEN + RETURN NEW; + END IF; + + -- Get user's couple_id + SELECT couple_id INTO v_couple_id + FROM profiles + WHERE id = v_user_id; + + -- Skip if user is not in a couple + IF v_couple_id IS NULL THEN + RETURN NEW; + END IF; + + -- UPSERT: Insert new notification or reset timer and increment count + INSERT INTO pending_activity_notifications ( + couple_id, + active_user_id, + response_count, + notify_at + ) + VALUES ( + v_couple_id, + v_user_id, + 1, + now() + interval '1 hour' + ) + ON CONFLICT (couple_id) DO UPDATE SET + active_user_id = EXCLUDED.active_user_id, + response_count = pending_activity_notifications.response_count + 1, + notify_at = now() + interval '1 hour', + updated_at = now(); + + RETURN NEW; +END; +$$; + +-- Create trigger on responses table +CREATE TRIGGER on_response_submitted + AFTER INSERT OR UPDATE OF answer ON responses + FOR EACH ROW + EXECUTE FUNCTION queue_partner_activity_notification(); + +-- Schedule cron job (runs every 5 minutes) +SELECT cron.schedule( + 'send-partner-activity-notifications', + '*/5 * * * *', + $$ + SELECT net.http_post( + url := 'https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/send-partner-activity-notification', + headers := jsonb_build_object('Content-Type', 'application/json'), + body := '{}'::jsonb + ) + $$ +); diff --git a/apps/supabase/migrations/20260117000002_add_scheduled_pack_releases.sql b/apps/supabase/migrations/20260117000002_add_scheduled_pack_releases.sql new file mode 100644 index 0000000..69a95d5 --- /dev/null +++ b/apps/supabase/migrations/20260117000002_add_scheduled_pack_releases.sql @@ -0,0 +1,25 @@ +-- Add scheduled pack release system +-- Allows admins to schedule pack releases and automatically notifies users + +-- Add scheduling columns to question_packs +ALTER TABLE question_packs + ADD COLUMN IF NOT EXISTS scheduled_release_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS release_notified BOOLEAN DEFAULT false; + +-- Index for efficient cron queries +CREATE INDEX idx_question_packs_scheduled_release + ON question_packs(scheduled_release_at) + WHERE scheduled_release_at IS NOT NULL AND is_public = false; + +-- Schedule cron job for processing scheduled releases (runs every 5 minutes) +SELECT cron.schedule( + 'process-scheduled-releases', + '*/5 * * * *', + $$ + SELECT net.http_post( + url := 'https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/process-scheduled-releases', + headers := jsonb_build_object('Content-Type', 'application/json'), + body := '{}'::jsonb + ) + $$ +); diff --git a/apps/supabase/migrations/20260117000003_add_couple_streaks.sql b/apps/supabase/migrations/20260117000003_add_couple_streaks.sql new file mode 100644 index 0000000..3a38072 --- /dev/null +++ b/apps/supabase/migrations/20260117000003_add_couple_streaks.sql @@ -0,0 +1,197 @@ +-- Add gentle streaks system +-- Tracks consecutive days both partners answered questions +-- Celebrates milestones without guilt-tripping on breaks + +-- Create couple_streaks table +CREATE TABLE couple_streaks ( + couple_id UUID PRIMARY KEY REFERENCES couples(id) ON DELETE CASCADE, + current_streak INTEGER DEFAULT 0, + longest_streak INTEGER DEFAULT 0, + last_active_date DATE, + user1_answered_today BOOLEAN DEFAULT false, + user2_answered_today BOOLEAN DEFAULT false, + streak_celebrated_at INTEGER DEFAULT 0, -- Last milestone that was celebrated + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +-- Enable RLS +ALTER TABLE couple_streaks ENABLE ROW LEVEL SECURITY; + +-- RLS policies +CREATE POLICY "Users can view their couple's streaks" + ON couple_streaks FOR SELECT + USING (couple_id = get_auth_user_couple_id()); + +CREATE POLICY "Super admins can view all streaks" + ON couple_streaks FOR SELECT + USING (is_super_admin()); + +-- Updated_at trigger +CREATE TRIGGER update_couple_streaks_updated_at + BEFORE UPDATE ON couple_streaks + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +-- Function to update streak when a response is submitted +CREATE OR REPLACE FUNCTION update_couple_streak() +RETURNS TRIGGER +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = 'public' +AS $$ +DECLARE + v_user_id UUID; + v_couple_id UUID; + v_partner_id UUID; + v_today DATE; + v_streak RECORD; + v_user_is_user1 BOOLEAN; +BEGIN + -- Get the user making the response + v_user_id := auth.uid(); + + -- Skip if no auth context + IF v_user_id IS NULL THEN + RETURN NEW; + END IF; + + -- Get user's couple_id + SELECT couple_id INTO v_couple_id + FROM profiles + WHERE id = v_user_id; + + -- Skip if user is not in a couple + IF v_couple_id IS NULL THEN + RETURN NEW; + END IF; + + v_today := CURRENT_DATE; + + -- Get or create streak record + INSERT INTO couple_streaks (couple_id, last_active_date) + VALUES (v_couple_id, v_today) + ON CONFLICT (couple_id) DO NOTHING; + + SELECT * INTO v_streak + FROM couple_streaks + WHERE couple_id = v_couple_id + FOR UPDATE; + + -- Determine if this user is "user1" or "user2" + -- We use a deterministic method: compare UUIDs + SELECT id INTO v_partner_id + FROM profiles + WHERE couple_id = v_couple_id AND id != v_user_id; + + -- User with smaller UUID is user1 + v_user_is_user1 := v_user_id < COALESCE(v_partner_id, v_user_id); + + -- Check if streak needs to be reset (missed a day) + IF v_streak.last_active_date IS NOT NULL AND v_streak.last_active_date < v_today - 1 THEN + -- Streak is broken - silently reset (no guilt-tripping!) + UPDATE couple_streaks + SET + current_streak = 0, + user1_answered_today = CASE WHEN v_user_is_user1 THEN true ELSE false END, + user2_answered_today = CASE WHEN v_user_is_user1 THEN false ELSE true END, + last_active_date = v_today + WHERE couple_id = v_couple_id; + RETURN NEW; + END IF; + + -- Check if this is a new day + IF v_streak.last_active_date IS NULL OR v_streak.last_active_date < v_today THEN + -- New day: reset daily flags, keep streak + UPDATE couple_streaks + SET + user1_answered_today = CASE WHEN v_user_is_user1 THEN true ELSE false END, + user2_answered_today = CASE WHEN v_user_is_user1 THEN false ELSE true END, + last_active_date = v_today + WHERE couple_id = v_couple_id; + ELSE + -- Same day: mark this user as having answered + IF v_user_is_user1 THEN + UPDATE couple_streaks + SET user1_answered_today = true + WHERE couple_id = v_couple_id; + ELSE + UPDATE couple_streaks + SET user2_answered_today = true + WHERE couple_id = v_couple_id; + END IF; + END IF; + + -- Check if BOTH partners have now answered today + SELECT * INTO v_streak + FROM couple_streaks + WHERE couple_id = v_couple_id; + + IF v_streak.user1_answered_today AND v_streak.user2_answered_today THEN + -- Both answered! Increment streak if not already counted for today + IF v_streak.last_active_date = v_today AND v_streak.current_streak = 0 THEN + -- First time both answered on a new streak + UPDATE couple_streaks + SET + current_streak = 1, + longest_streak = GREATEST(longest_streak, 1) + WHERE couple_id = v_couple_id; + ELSIF v_streak.last_active_date = v_today THEN + -- Already counted for today, nothing to do + NULL; + ELSE + -- Continuing streak from previous day + UPDATE couple_streaks + SET + current_streak = current_streak + 1, + longest_streak = GREATEST(longest_streak, current_streak + 1) + WHERE couple_id = v_couple_id; + END IF; + END IF; + + RETURN NEW; +END; +$$; + +-- Create trigger on responses table +CREATE TRIGGER on_response_update_streak + AFTER INSERT OR UPDATE OF answer ON responses + FOR EACH ROW + EXECUTE FUNCTION update_couple_streak(); + +-- Function to reset daily flags (called by cron at UTC midnight) +CREATE OR REPLACE FUNCTION reset_daily_streak_flags() +RETURNS void +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = 'public' +AS $$ +BEGIN + UPDATE couple_streaks + SET + user1_answered_today = false, + user2_answered_today = false + WHERE last_active_date < CURRENT_DATE; +END; +$$; + +-- Schedule cron job for daily flag reset (runs at 00:00 UTC) +SELECT cron.schedule( + 'reset-daily-streak-flags', + '0 0 * * *', + $$ + SELECT reset_daily_streak_flags(); + $$ +); + +-- Schedule cron job for checking streak milestones (runs at 00:05 UTC) +SELECT cron.schedule( + 'check-streak-milestones', + '5 0 * * *', + $$ + SELECT net.http_post( + url := 'https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/check-streak-milestones', + headers := jsonb_build_object('Content-Type', 'application/json'), + body := '{}'::jsonb + ) + $$ +);