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
This commit is contained in:
jjbrunton committed 2026-01-17 16:38:47 +00:00
1 parent 12f312421b
commit 494ed17fe2
82 files changed
+4189 -725

No files matched your search

@@ -45,6 +45,8 @@ export interface PackFormData {
is_public: boolean; is_public: boolean;
is_explicit: boolean; is_explicit: boolean;
category_id: string; 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 { export interface PackFormDialogProps {
@@ -200,10 +202,63 @@ export function PackFormDialog({
</div> </div>
<Switch <Switch
checked={formData.is_public} checked={formData.is_public}
onCheckedChange={(checked) => setField('is_public', checked)} onCheckedChange={(checked) => {
setField('is_public', checked);
// Clear scheduled release if making public immediately
if (checked) {
setField('scheduled_release_at', null);
}
}}
/> />
</div> </div>
{/* Scheduled Release - only show when not public */}
{!formData.is_public && (
<div className="space-y-4 p-3 rounded-md bg-muted/50 border border-dashed">
<div className="space-y-2">
<Label htmlFor="scheduled_release_at">Scheduled Release</Label>
<p className="text-sm text-muted-foreground">
Automatically make this pack public at a specific time. Users will be notified when it becomes available.
</p>
<Input
id="scheduled_release_at"
type="datetime-local"
value={formData.scheduled_release_at ? new Date(formData.scheduled_release_at).toISOString().slice(0, 16) : ''}
onChange={(e) => {
const value = e.target.value;
setField('scheduled_release_at', value ? new Date(value).toISOString() : null);
}}
className="w-full"
/>
{formData.scheduled_release_at && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setField('scheduled_release_at', null)}
className="mt-1"
>
Clear scheduled release
</Button>
)}
</div>
{/* Skip Notification Toggle */}
<div className="flex items-center justify-between pt-2 border-t">
<div className="space-y-0.5">
<Label>Skip release notification</Label>
<p className="text-sm text-muted-foreground">
Release without notifying users
</p>
</div>
<Switch
checked={formData.skip_notification ?? false}
onCheckedChange={(checked) => setField('skip_notification', checked)}
/>
</div>
</div>
)}
{/* Explicit Toggle */} {/* Explicit Toggle */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="space-y-0.5"> <div className="space-y-0.5">
+40 -1
View File
@@ -8,7 +8,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination'; 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 { toast } from 'sonner';
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog'; import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
import { ExtractTopicsDialog } from '@/components/content/ExtractTopicsDialog'; import { ExtractTopicsDialog } from '@/components/content/ExtractTopicsDialog';
@@ -41,6 +41,8 @@ interface QuestionPack {
sort_order: number | null; sort_order: number | null;
category_id: string | null; category_id: string | null;
created_at: 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; question_count?: number;
topics?: Topic[]; topics?: Topic[];
} }
@@ -76,6 +78,8 @@ export function PacksPage() {
is_public: false, is_public: false,
is_explicit: false, is_explicit: false,
category_id: categoryId ?? '', category_id: categoryId ?? '',
scheduled_release_at: null,
skip_notification: false,
}, },
(pack) => ({ (pack) => ({
name: pack.name, name: pack.name,
@@ -85,6 +89,8 @@ export function PacksPage() {
is_public: pack.is_public, is_public: pack.is_public,
is_explicit: pack.is_explicit, is_explicit: pack.is_explicit,
category_id: pack.category_id || '', 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; let packId: string;
if (form.editingItem) { 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, { const { error } = await auditedSupabase.update('question_packs', form.editingItem.id, {
name: form.formData.name, name: form.formData.name,
description: form.formData.description || null, description: form.formData.description || null,
@@ -231,6 +244,12 @@ export function PacksPage() {
is_public: form.formData.is_public, is_public: form.formData.is_public,
is_explicit: form.formData.is_explicit, is_explicit: form.formData.is_explicit,
category_id: form.formData.category_id || null, 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; if (error) throw error;
packId = form.editingItem.id; packId = form.editingItem.id;
@@ -246,6 +265,12 @@ export function PacksPage() {
is_explicit: form.formData.is_explicit, is_explicit: form.formData.is_explicit,
category_id: form.formData.category_id || null, category_id: form.formData.category_id || null,
sort_order: totalCount, 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') .select('id')
.single(); .single();
@@ -305,6 +330,8 @@ export function PacksPage() {
is_public: false, is_public: false,
is_explicit: false, is_explicit: false,
category_id: categoryId ?? '', category_id: categoryId ?? '',
scheduled_release_at: null,
skip_notification: false,
}); });
setAiDialogOpen(false); setAiDialogOpen(false);
setSelectedTopicIds(new Set()); setSelectedTopicIds(new Set());
@@ -497,6 +524,18 @@ export function PacksPage() {
<Eye className="h-3 w-3 mr-1" /> <Eye className="h-3 w-3 mr-1" />
Published Published
</Badge> </Badge>
) : pack.scheduled_release_at ? (
<>
<Badge variant="outline" className="border-blue-500 text-blue-400" title={`Releases: ${new Date(pack.scheduled_release_at).toLocaleString()}`}>
<Clock className="h-3 w-3 mr-1" />
Scheduled
</Badge>
{pack.release_notified && (
<Badge variant="outline" className="border-gray-500 text-gray-400" title="Notifications skipped">
<BellOff className="h-3 w-3" />
</Badge>
)}
</>
) : ( ) : (
<Badge variant="secondary"> <Badge variant="secondary">
<EyeOff className="h-3 w-3 mr-1" /> <EyeOff className="h-3 w-3 mr-1" />
+74 -1
View File
@@ -38,7 +38,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } 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 { format } from 'date-fns';
import { IconPreview } from '@/components/ui/icon-picker'; import { IconPreview } from '@/components/ui/icon-picker';
@@ -142,6 +142,17 @@ interface FeatureInterest {
created_at: string; 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 = { const answerIcons = {
yes: <ThumbsUp className="h-4 w-4 text-green-500" />, yes: <ThumbsUp className="h-4 w-4 text-green-500" />,
no: <ThumbsDown className="h-4 w-4 text-red-500" />, no: <ThumbsDown className="h-4 w-4 text-red-500" />,
@@ -277,6 +288,7 @@ export function UserDetailPage() {
const [mediaMessages, setMediaMessages] = useState<any[]>([]); const [mediaMessages, setMediaMessages] = useState<any[]>([]);
const [enabledPacks, setEnabledPacks] = useState<EnabledPack[]>([]); const [enabledPacks, setEnabledPacks] = useState<EnabledPack[]>([]);
const [featureInterests, setFeatureInterests] = useState<FeatureInterest[]>([]); const [featureInterests, setFeatureInterests] = useState<FeatureInterest[]>([]);
const [streak, setStreak] = useState<CoupleStreak | null>(null);
const [responsesPage, setResponsesPage] = useState(1); const [responsesPage, setResponsesPage] = useState(1);
const [responsesPageSize, setResponsesPageSize] = useState(10); const [responsesPageSize, setResponsesPageSize] = useState(10);
const [responsesTotal, setResponsesTotal] = useState(0); const [responsesTotal, setResponsesTotal] = useState(0);
@@ -357,8 +369,17 @@ export function UserDetailPage() {
if (profileData?.couple_id) { if (profileData?.couple_id) {
const { data: partnerData } = await supabase.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).single(); const { data: partnerData } = await supabase.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).single();
setPartner(partnerData); setPartner(partnerData);
// Fetch streak data
const { data: streakData } = await supabase
.from('couple_streaks')
.select('*')
.eq('couple_id', profileData.couple_id)
.maybeSingle();
setStreak(streakData);
} else { } else {
setPartner(null); setPartner(null);
setStreak(null);
} }
const responsesFrom = (responsesPage - 1) * responsesPageSize; const responsesFrom = (responsesPage - 1) * responsesPageSize;
@@ -534,6 +555,58 @@ export function UserDetailPage() {
<div className="flex items-start gap-3"><div className={`p-2 rounded-lg ${profile.show_explicit_content ? 'bg-amber-100 dark:bg-amber-900' : 'bg-gray-100 dark:bg-gray-800'}`}>{profile.show_explicit_content ? <Eye className="h-4 w-4 text-amber-600 dark:text-amber-400" /> : <EyeOff className="h-4 w-4 text-gray-500 dark:text-gray-400" />}</div><div><p className="text-sm font-medium text-muted-foreground">Explicit Content</p><p className="font-medium">{profile.show_explicit_content === null ? 'Not set' : profile.show_explicit_content ? 'Enabled' : 'Disabled'}</p></div></div> <div className="flex items-start gap-3"><div className={`p-2 rounded-lg ${profile.show_explicit_content ? 'bg-amber-100 dark:bg-amber-900' : 'bg-gray-100 dark:bg-gray-800'}`}>{profile.show_explicit_content ? <Eye className="h-4 w-4 text-amber-600 dark:text-amber-400" /> : <EyeOff className="h-4 w-4 text-gray-500 dark:text-gray-400" />}</div><div><p className="text-sm font-medium text-muted-foreground">Explicit Content</p><p className="font-medium">{profile.show_explicit_content === null ? 'Not set' : profile.show_explicit_content ? 'Enabled' : 'Disabled'}</p></div></div>
<div className="flex items-start gap-3"><div className={`p-2 rounded-lg ${profile.onboarding_completed ? 'bg-green-100 dark:bg-green-900' : 'bg-red-100 dark:bg-red-900'}`}><CheckCircle className={`h-4 w-4 ${profile.onboarding_completed ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'}`} /></div><div><p className="text-sm font-medium text-muted-foreground">Onboarding</p><p className="font-medium">{profile.onboarding_completed ? 'Completed' : 'Not completed'}</p></div></div> <div className="flex items-start gap-3"><div className={`p-2 rounded-lg ${profile.onboarding_completed ? 'bg-green-100 dark:bg-green-900' : 'bg-red-100 dark:bg-red-900'}`}><CheckCircle className={`h-4 w-4 ${profile.onboarding_completed ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'}`} /></div><div><p className="text-sm font-medium text-muted-foreground">Onboarding</p><p className="font-medium">{profile.onboarding_completed ? 'Completed' : 'Not completed'}</p></div></div>
</div></CardContent></Card> </div></CardContent></Card>
{/* Streak Info */}
{profile.couple_id && streak && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<Flame className="h-5 w-5 text-orange-500" />
Couple Streak
</CardTitle>
<CardDescription>Activity streak for this couple</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-orange-100 dark:bg-orange-900">
<Flame className="h-4 w-4 text-orange-600 dark:text-orange-400" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Current Streak</p>
<p className="font-medium text-xl">{streak.current_streak} day{streak.current_streak !== 1 ? 's' : ''}</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-yellow-100 dark:bg-yellow-900">
<Trophy className="h-4 w-4 text-yellow-600 dark:text-yellow-400" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Longest Streak</p>
<p className="font-medium text-xl">{streak.longest_streak} day{streak.longest_streak !== 1 ? 's' : ''}</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900">
<Calendar className="h-4 w-4 text-blue-600 dark:text-blue-400" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Last Active</p>
<p className="font-medium">{streak.last_active_date ? format(new Date(streak.last_active_date), 'MMM d, yyyy') : 'Never'}</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-900">
<Sparkles className="h-4 w-4 text-purple-600 dark:text-purple-400" />
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Last Milestone</p>
<p className="font-medium">{streak.last_milestone_celebrated ? `${streak.last_milestone_celebrated} days` : 'None yet'}</p>
</div>
</div>
</div>
</CardContent>
</Card>
)}
{/* Feature Opt-Ins */} {/* Feature Opt-Ins */}
{featureInterests.length > 0 && ( {featureInterests.length > 0 && (
<Card> <Card>
+30 -4
View File
@@ -4,11 +4,12 @@ import { View, ActivityIndicator, StyleSheet, Platform, Text, Pressable, Animate
import { BlurView } from "expo-blur"; import { BlurView } from "expo-blur";
import { LinearGradient } from "expo-linear-gradient"; import { LinearGradient } from "expo-linear-gradient";
import { useEffect, useState, useCallback, useRef } from "react"; 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 { colors, gradients, blur, radius, spacing, typography, shadows } from "../../src/theme";
import { supabase } from "../../src/lib/supabase"; import { supabase } from "../../src/lib/supabase";
import { isBiometricEnabled } from "../../src/lib/biometricAuth"; import { isBiometricEnabled } from "../../src/lib/biometricAuth";
import { checkAndRegisterPushToken } from "../../src/lib/notifications"; import { checkAndRegisterPushToken } from "../../src/lib/notifications";
import { syncBadgeCount } from "../../src/lib/badge";
import { BiometricLockScreen } from "../../src/components/BiometricLockScreen"; import { BiometricLockScreen } from "../../src/components/BiometricLockScreen";
import { MatchNotificationModal, MatchNotificationData } from "../../src/components/MatchNotificationModal"; import { MatchNotificationModal, MatchNotificationData } from "../../src/components/MatchNotificationModal";
import { Events } from "../../src/lib/analytics"; import { Events } from "../../src/lib/analytics";
@@ -139,11 +140,12 @@ function PlayTabButton({ accessibilityState }: PlayTabButtonProps) {
export default function AppLayout() { export default function AppLayout() {
const router = useRouter(); const router = useRouter();
const segments = useSegments(); const segments = useSegments();
const { isAuthenticated, isLoading, user, signOut } = useAuthStore(); const { isAuthenticated, isLoading, user, signOut, updateLastActive } = useAuthStore();
const { matches, newMatchesCount, addMatch, updateMatchUnreadCount } = useMatchStore(); const { matches, newMatchesCount, addMatch, updateMatchUnreadCount } = useMatchStore();
const { unreadCount, lastMessage, fetchUnreadCount, addMessage, clearLastMessage } = useMessageStore(); const { unreadCount, lastMessage, fetchUnreadCount, addMessage, clearLastMessage } = useMessageStore();
const { fetchEnabledPacks } = usePacksStore(); const { fetchEnabledPacks } = usePacksStore();
const { initializeRevenueCat } = useSubscriptionStore(); const { initializeRevenueCat } = useSubscriptionStore();
const { fetchStreak } = useStreakStore();
const [matchNotification, setMatchNotification] = useState<MatchNotificationData | null>(null); const [matchNotification, setMatchNotification] = useState<MatchNotificationData | null>(null);
const messageToastAnim = useRef(new Animated.Value(-100)).current; const messageToastAnim = useRef(new Animated.Value(-100)).current;
const messageTimeoutRef = useRef<NodeJS.Timeout | null>(null); const messageTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@@ -208,7 +210,8 @@ export default function AppLayout() {
const isOnOnboarding = segmentStrings.includes("onboarding"); const isOnOnboarding = segmentStrings.includes("onboarding");
const isOnPairing = segmentStrings.includes("pairing"); const isOnPairing = segmentStrings.includes("pairing");
const isOnChat = segmentStrings.includes("chat"); 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 // Redirect to login when not authenticated, or to onboarding if not completed
useEffect(() => { useEffect(() => {
@@ -232,17 +235,22 @@ export default function AppLayout() {
// Check immediately // Check immediately
checkSession(); checkSession();
// Update last active timestamp on mount
updateLastActive();
// Check when app comes to foreground // Check when app comes to foreground
const subscription = AppState.addEventListener('change', (nextAppState) => { const subscription = AppState.addEventListener('change', (nextAppState) => {
if (nextAppState === 'active') { if (nextAppState === 'active') {
checkSession(); checkSession();
// Update last active timestamp when app comes to foreground
updateLastActive();
} }
}); });
return () => { return () => {
subscription.remove(); subscription.remove();
}; };
}, [signOut]); }, [signOut, updateLastActive]);
// Handle biometric lock when app goes to background/foreground // Handle biometric lock when app goes to background/foreground
useEffect(() => { useEffect(() => {
@@ -301,6 +309,18 @@ export default function AppLayout() {
} }
}, [user?.id, fetchUnreadCount]); }, [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) // Initialize RevenueCat for subscription management (iOS and Android only)
useEffect(() => { useEffect(() => {
if (user?.id && Platform.OS !== "web") { if (user?.id && Platform.OS !== "web") {
@@ -692,6 +712,12 @@ export default function AppLayout() {
href: null, // Hide from tab bar href: null, // Hide from tab bar
}} }}
/> />
<Tabs.Screen
name="settings"
options={{
href: null, // Hide from tab bar
}}
/>
</Tabs> </Tabs>
{/* Radial Menu Overlay */} {/* Radial Menu Overlay */}
@@ -0,0 +1,12 @@
import { Stack } from 'expo-router';
export default function SettingsLayout() {
return (
<Stack
screenOptions={{
headerShown: false,
animation: 'slide_from_right',
}}
/>
);
}
@@ -0,0 +1,3 @@
import { AccountScreen } from "../../../src/features/profile/screens";
export default AccountScreen;
@@ -0,0 +1,3 @@
import { NotificationsScreen } from "../../../src/features/profile/screens";
export default NotificationsScreen;
@@ -0,0 +1,3 @@
import { PartnerScreen } from "../../../src/features/profile/screens";
export default PartnerScreen;
@@ -0,0 +1,3 @@
import { PreferencesScreen } from "../../../src/features/profile/screens";
export default PreferencesScreen;
@@ -0,0 +1,3 @@
import { ProfileSettingsScreen } from "../../../src/features/profile/screens";
export default ProfileSettingsScreen;
@@ -0,0 +1,3 @@
import { SubscriptionScreen } from "../../../src/features/profile/screens";
export default SubscriptionScreen;
@@ -0,0 +1,3 @@
import { SupportScreen } from "../../../src/features/profile/screens";
export default SupportScreen;
@@ -162,8 +162,8 @@ describe('subscriptionStore', () => {
}); });
const result = await useSubscriptionStore.getState().purchasePackage(mockPackage as any); const result = await useSubscriptionStore.getState().purchasePackage(mockPackage as any);
expect(result).toBe(true); expect(result.success).toBe(true);
const state = useSubscriptionStore.getState(); const state = useSubscriptionStore.getState();
expect(state.subscription.isProUser).toBe(true); expect(state.subscription.isProUser).toBe(true);
expect(state.isPurchasing).toBe(false); expect(state.isPurchasing).toBe(false);
@@ -178,7 +178,8 @@ describe('subscriptionStore', () => {
const result = await useSubscriptionStore.getState().purchasePackage(mockPackage as any); 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(); const state = useSubscriptionStore.getState();
expect(state.error).toBe('Purchase was cancelled'); expect(state.error).toBe('Purchase was cancelled');
expect(state.isPurchasing).toBe(false); expect(state.isPurchasing).toBe(false);
@@ -192,7 +193,8 @@ describe('subscriptionStore', () => {
const result = await useSubscriptionStore.getState().purchasePackage(mockPackage as any); 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(); const state = useSubscriptionStore.getState();
expect(state.error).toBe('Purchase failed. Please try again.'); expect(state.error).toBe('Purchase failed. Please try again.');
}); });
@@ -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 (
<Animated.View entering={FadeInDown.delay(delay).duration(400)}>
<View style={styles.compactContainer}>
<Animated.View style={flameStyle}>
<Ionicons
name="flame"
size={16}
color={hasStreak ? colors.warning : colors.textTertiary}
/>
</Animated.View>
<Text style={[styles.compactCount, !hasStreak && styles.inactiveText]}>
{currentStreak}
</Text>
</View>
</Animated.View>
);
}
return (
<Animated.View entering={FadeInDown.delay(delay).duration(500)}>
<View style={styles.container}>
{/* Main streak display */}
<LinearGradient
colors={hasStreak ? ['rgba(243, 156, 18, 0.2)', 'rgba(233, 69, 96, 0.1)'] : ['rgba(255, 255, 255, 0.05)', 'rgba(255, 255, 255, 0.02)']}
style={styles.card}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<View style={styles.content}>
{/* Flame icon */}
<Animated.View style={[styles.iconContainer, flameStyle]}>
<Ionicons
name="flame"
size={32}
color={hasStreak ? colors.warning : colors.textTertiary}
/>
</Animated.View>
{/* Streak info */}
<View style={styles.info}>
<Text style={[styles.count, !hasStreak && styles.inactiveText]}>
{currentStreak}
</Text>
<Text style={styles.label}>
day{currentStreak !== 1 ? 's' : ''} together
</Text>
</View>
</View>
{/* Longest streak */}
{showLongest && longestStreak > 0 && (
<View style={styles.longest}>
<Ionicons name="trophy-outline" size={12} color={colors.textTertiary} />
<Text style={styles.longestText}>
Best: {longestStreak} day{longestStreak !== 1 ? 's' : ''}
</Text>
</View>
)}
</LinearGradient>
</View>
</Animated.View>
);
}
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;
+45 -31
View File
@@ -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 { const {
offerings, offerings,
isLoadingOfferings, isLoadingOfferings,
@@ -76,26 +80,36 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
} }
}, [offerings]); }, [offerings]);
const handlePurchase = async () => { const handlePurchase = async () => {
if (!selectedPackage) return; 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 handleRestore = async () => {
const success = await purchasePackage(selectedPackage); const restored = await restorePurchases();
if (success) { if (restored) {
Events.purchaseCompleted(selectedPackage.packageType || "unknown"); Events.purchaseRestored();
onSuccess?.(); onSuccess?.();
onClose(); handleClose("restore");
} }
}; };
const handleRestore = async () => {
const restored = await restorePurchases();
if (restored) {
Events.purchaseRestored();
onSuccess?.();
onClose();
}
};
const formatPrice = (pkg: PurchasesPackage) => { const formatPrice = (pkg: PurchasesPackage) => {
const price = pkg.product.priceString; const price = pkg.product.priceString;
@@ -129,12 +143,12 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
return ( return (
<Modal <Modal
visible={visible} visible={visible}
transparent transparent
animationType="slide" animationType="slide"
onRequestClose={onClose} onRequestClose={() => handleClose("system")}
> >
<View style={styles.overlay}> <View style={styles.overlay}>
<View style={styles.content}> <View style={styles.content}>
{/* Premium gradient background */} {/* Premium gradient background */}
@@ -155,11 +169,11 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
contentContainerStyle={styles.scrollContent} contentContainerStyle={styles.scrollContent}
> >
{/* Close Button */} {/* Close Button */}
<TouchableOpacity <TouchableOpacity
style={styles.closeButton} style={styles.closeButton}
onPress={onClose} onPress={() => handleClose("dismiss")}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
> >
<Ionicons name="close" size={24} color={colors.textSecondary} /> <Ionicons name="close" size={24} color={colors.textSecondary} />
</TouchableOpacity> </TouchableOpacity>
+172 -221
View File
@@ -1,62 +1,42 @@
import React from 'react'; import React, { useState } from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, useWindowDimensions, Platform, Alert, Linking } from 'react-native'; import { View, Text, StyleSheet, ScrollView, useWindowDimensions, Platform, Alert, TouchableOpacity } from 'react-native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import Constants from 'expo-constants'; import Constants from 'expo-constants';
import Animated, { import Animated, { FadeInDown } from 'react-native-reanimated';
FadeInDown, import { Image } from 'expo-image';
useSharedValue, import { LinearGradient } from 'expo-linear-gradient';
useAnimatedScrollHandler,
useAnimatedStyle,
interpolate,
Extrapolation
} from 'react-native-reanimated';
import { BlurView } from 'expo-blur';
import { GradientBackground } from '../../components/ui'; import { GradientBackground } from '../../components/ui';
import { FeedbackModal } from '../../components/feedback';
import { Paywall } from '../../components/paywall'; import { Paywall } from '../../components/paywall';
import { colors, featureColors, spacing, typography, radius } from '../../theme'; import { colors, featureColors, spacing, typography, radius } from '../../theme';
import { useAuthStore, useSubscriptionStore } from '../../store'; import { useAuthStore, useSubscriptionStore } from '../../store';
import { resetSwipeTutorial } from '../../lib/swipeTutorialSeen'; import { resetSwipeTutorial } from '../../lib/swipeTutorialSeen';
import { resetMatchesTutorial } from '../../lib/matchesTutorialSeen'; import { resetMatchesTutorial } from '../../lib/matchesTutorialSeen';
import { supabase } from '../../lib/supabase'; // Needed for debug reset import { supabase } from '../../lib/supabase';
// Hooks
import { useProfileSettings, useCoupleManagement } from './hooks';
// Components // 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 MAX_CONTENT_WIDTH = 500;
const NAV_BAR_HEIGHT = 44; const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
const STATUS_BAR_HEIGHT = 60;
const HEADER_SCROLL_DISTANCE = 100;
const AnimatedScrollView = Animated.createAnimatedComponent(ScrollView);
export function ProfileScreen() { export function ProfileScreen() {
const router = useRouter();
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const isWideScreen = width > MAX_CONTENT_WIDTH; const isWideScreen = width > MAX_CONTENT_WIDTH;
const { user, partner, couple, fetchUser } = useAuthStore(); const { user, partner, fetchUser } = useAuthStore();
const { subscription } = useSubscriptionStore(); const { subscription } = useSubscriptionStore();
// Hooks
const settings = useProfileSettings(); 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 // Derived state
// 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)
const isOwnSubscription = subscription.isProUser || user?.is_premium; const isOwnSubscription = subscription.isProUser || user?.is_premium;
const hasPremiumAccess = user?.is_premium || partner?.is_premium || subscription.isProUser; 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}`; 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 ( return (
<GradientBackground> <GradientBackground>
{/* Fixed Nav Bar */} <ScrollView
<View style={styles.navBar}>
<Animated.View style={[styles.navBarBackground, navBarBackgroundStyle]}>
{Platform.OS === "ios" ? (
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
) : (
<View style={[StyleSheet.absoluteFill, { backgroundColor: "rgba(13, 13, 26, 0.95)" }]} />
)}
</Animated.View>
<Animated.Text style={[styles.navBarTitle, compactHeaderStyle]} numberOfLines={1}>
Settings
</Animated.Text>
</View>
<AnimatedScrollView
style={styles.container} style={styles.container}
contentContainerStyle={[ contentContainerStyle={[
styles.contentContainer, styles.contentContainer,
isWideScreen && styles.contentContainerWide, isWideScreen && styles.contentContainerWide,
]} ]}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
onScroll={scrollHandler}
scrollEventThrottle={16}
> >
{/* Header title */} {/* Header with user info (compact) */}
<Animated.View <Animated.View
entering={FadeInDown.delay(100).duration(500)} entering={FadeInDown.delay(100).duration(500)}
style={[styles.header, heroStyle]} style={styles.header}
> >
<Text style={styles.headerLabel}>YOUR SAUCI</Text> <Text style={styles.headerLabel}>YOUR SAUCI</Text>
<Text style={styles.title}>Settings</Text> <Text style={styles.title}>Settings</Text>
@@ -164,98 +83,111 @@ export function ProfileScreen() {
</View> </View>
</Animated.View> </Animated.View>
{/* Profile Header */} {/* Compact Profile Preview */}
<AppearanceSettings <Animated.View
user={user} entering={FadeInDown.delay(150).duration(500)}
isUploadingAvatar={settings.isUploadingAvatar} style={styles.profilePreview}
isEditingName={settings.isEditingName} >
newName={settings.newName} <TouchableOpacity
isUpdatingName={settings.isUpdatingName} style={styles.profilePreviewContent}
onAvatarPress={settings.handleAvatarPress} onPress={() => router.push('/(app)/settings/profile')}
onNewNameChange={settings.setNewName} activeOpacity={0.7}
onUpdateName={settings.handleUpdateName} >
onCancelEditName={settings.handleCancelEditName} <LinearGradient
onStartEditingName={() => settings.setIsEditingName(true)} colors={ACCENT_GRADIENT}
/> style={styles.avatarGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
{user?.avatar_url ? (
<Image
source={{ uri: user.avatar_url }}
style={styles.avatar}
cachePolicy="disk"
transition={200}
/>
) : (
<View style={styles.avatarInner}>
<Text style={styles.avatarText}>
{user?.name?.[0]?.toUpperCase() || "U"}
</Text>
</View>
)}
</LinearGradient>
<View style={styles.profileInfo}>
<Text style={styles.userName}>{user?.name || "User"}</Text>
<Text style={styles.userEmail}>{user?.email}</Text>
</View>
<View style={styles.profileChevron}>
<Ionicons name="chevron-forward" size={20} color={colors.textTertiary} />
</View>
</TouchableOpacity>
</Animated.View>
{/* Partner */} {/* Subscription CTA - prominent upsell */}
<CoupleStatus
partner={partner}
couple={couple}
onUnpair={handleUnpair}
onPairingPress={navigateToPairing}
/>
{/* Subscription Section */}
<SubscriptionCard <SubscriptionCard
hasPremiumAccess={hasPremiumAccess} hasPremiumAccess={hasPremiumAccess}
isOwnSubscription={!!isOwnSubscription} isOwnSubscription={!!isOwnSubscription}
expirationDate={formatExpirationDate(subscription.expirationDate)} expirationDate={formatExpirationDate(subscription.expirationDate)}
onUpgradePress={() => settings.setShowPaywall(true)} onUpgradePress={() => setShowPaywall(true)}
onManagePress={settings.handleManageSubscription} onManagePress={settings.handleManageSubscription}
onRestorePress={settings.handleRestorePurchases} onRestorePress={settings.handleRestorePurchases}
isRestoring={settings.isPurchasing} isRestoring={settings.isPurchasing}
delay={350} delay={200}
/> />
{/* Notifications */} {/* Settings Navigation */}
<NotificationSettings <SettingsSection title="Settings" delay={250}>
pushEnabled={settings.pushEnabled}
isUpdatingPush={settings.isUpdatingPush}
onPushToggle={settings.handlePushToggle}
/>
{/* Preferences */}
<PrivacySettings
maxIntensity={settings.maxIntensity}
isUpdatingIntensity={settings.isUpdatingIntensity}
onIntensityChange={settings.handleIntensityChange}
biometricAvailable={settings.biometricAvailable}
biometricEnabled={settings.biometricEnabled}
biometricType={settings.biometricType}
isUpdatingBiometric={settings.isUpdatingBiometric}
onBiometricToggle={settings.handleBiometricToggle}
partnerIntensity={settings.partnerIntensity}
partnerName={settings.partnerName}
partnerAvatar={settings.partnerAvatar}
/>
{/* Account */}
<SettingsSection title="Account" delay={425}>
<MenuItem <MenuItem
icon="log-out-outline" icon="person-circle-outline"
label="Sign Out" label="Profile"
onPress={handleSignOut} description="Avatar and display name"
variant="danger" onPress={() => router.push('/(app)/settings/profile')}
showChevron={true} />
<View style={styles.divider} />
<MenuItem
icon="notifications-outline"
label="Notifications"
description="Push notification preferences"
onPress={() => router.push('/(app)/settings/notifications')}
/>
<View style={styles.divider} />
<MenuItem
icon="options-outline"
label="Preferences"
description="Comfort zone and security"
onPress={() => router.push('/(app)/settings/preferences')}
/> />
</SettingsSection> </SettingsSection>
{/* Support */} {/* Relationship Section */}
<SettingsSection title="Support" delay={475}> <SettingsSection title="Relationship" delay={300}>
<MenuItem <MenuItem
icon="chatbubble-ellipses-outline" icon="heart-outline"
label="Send Feedback" label="Partner"
description="Report bugs or request features" rightText={partner?.name || (user?.couple_id ? 'Waiting...' : undefined)}
onPress={() => setShowFeedbackModal(true)} onPress={() => router.push('/(app)/settings/partner')}
/>
<View style={styles.preferencesDivider} />
<MenuItem
icon="shield-checkmark-outline"
label="Privacy Policy"
onPress={() => Linking.openURL('https://sauci.app/privacy')}
/>
<View style={styles.preferencesDivider} />
<MenuItem
icon="document-text-outline"
label="Terms of Service"
onPress={() => Linking.openURL('https://sauci.app/terms')}
/> />
</SettingsSection> </SettingsSection>
{/* Debug Section */} {/* Other Section */}
<SettingsSection title="Other" delay={350}>
<MenuItem
icon="help-circle-outline"
label="Help & Support"
onPress={() => router.push('/(app)/settings/support')}
/>
<View style={styles.divider} />
<MenuItem
icon="settings-outline"
label="Account"
onPress={() => router.push('/(app)/settings/account')}
/>
</SettingsSection>
{/* Debug Section - Dev only */}
{__DEV__ && ( {__DEV__ && (
<SettingsSection title="Debug" delay={550}> <SettingsSection title="Debug" delay={400}>
<MenuItem <MenuItem
icon="refresh-outline" icon="refresh-outline"
label="Reset Swipe Tutorial" label="Reset Swipe Tutorial"
@@ -265,7 +197,7 @@ export function ProfileScreen() {
Alert.alert("Success", "Swipe tutorial has been reset."); Alert.alert("Success", "Swipe tutorial has been reset.");
}} }}
/> />
<View style={styles.preferencesDivider} /> <View style={styles.divider} />
<MenuItem <MenuItem
icon="heart-outline" icon="heart-outline"
label="Reset Matches Tutorial" label="Reset Matches Tutorial"
@@ -275,7 +207,7 @@ export function ProfileScreen() {
Alert.alert("Success", "Matches tutorial has been reset."); Alert.alert("Success", "Matches tutorial has been reset.");
}} }}
/> />
<View style={styles.preferencesDivider} /> <View style={styles.divider} />
<MenuItem <MenuItem
icon="school-outline" icon="school-outline"
label="Reset Onboarding" label="Reset Onboarding"
@@ -298,17 +230,9 @@ export function ProfileScreen() {
</SettingsSection> </SettingsSection>
)} )}
{/* Danger Zone */}
<DangerZone
onResetProgress={couple ? handleResetProgress : undefined}
onDeleteRelationship={couple ? handleDeleteRelationship : undefined}
onDeleteAccount={handleDeleteAccount}
hasRelationship={!!couple}
/>
{/* Version */} {/* Version */}
<Animated.View <Animated.View
entering={FadeInDown.delay(couple ? 650 : 525).duration(500)} entering={FadeInDown.delay(__DEV__ ? 450 : 400).duration(500)}
style={styles.versionContainer} style={styles.versionContainer}
> >
<View style={styles.versionBadge}> <View style={styles.versionBadge}>
@@ -318,16 +242,11 @@ export function ProfileScreen() {
</Animated.View> </Animated.View>
<View style={styles.bottomSpacer} /> <View style={styles.bottomSpacer} />
</AnimatedScrollView> </ScrollView>
{/* Modals */}
<FeedbackModal
visible={showFeedbackModal}
onClose={() => setShowFeedbackModal(false)}
/>
<Paywall <Paywall
visible={settings.showPaywall} visible={showPaywall}
onClose={() => settings.setShowPaywall(false)} onClose={() => setShowPaywall(false)}
/> />
</GradientBackground> </GradientBackground>
); );
@@ -337,33 +256,7 @@ const styles = StyleSheet.create({
container: { container: {
flex: 1, 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: { contentContainer: {
// Remove top padding here as the header adds it
paddingBottom: Platform.OS === 'ios' ? 100 : 80, paddingBottom: Platform.OS === 'ios' ? 100 : 80,
}, },
contentContainerWide: { contentContainerWide: {
@@ -372,7 +265,7 @@ const styles = StyleSheet.create({
maxWidth: MAX_CONTENT_WIDTH, maxWidth: MAX_CONTENT_WIDTH,
}, },
header: { header: {
paddingTop: STATUS_BAR_HEIGHT + spacing.md, paddingTop: Platform.OS === 'ios' ? 60 : 40,
paddingHorizontal: spacing.lg, paddingHorizontal: spacing.lg,
paddingBottom: spacing.md, paddingBottom: spacing.md,
alignItems: 'center', alignItems: 'center',
@@ -409,7 +302,65 @@ const styles = StyleSheet.create({
marginHorizontal: spacing.sm, marginHorizontal: spacing.sm,
opacity: 0.6, 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, height: 1,
backgroundColor: colors.glass.border, backgroundColor: colors.glass.border,
marginVertical: spacing.md, marginVertical: spacing.md,
@@ -15,6 +15,10 @@ export interface MenuItemProps {
onPress: () => void; onPress: () => void;
/** Optional element to show on the right */ /** Optional element to show on the right */
rightElement?: React.ReactNode; 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 */ /** Whether to show chevron arrow */
showChevron?: boolean; showChevron?: boolean;
/** Visual variant */ /** Visual variant */
@@ -34,6 +38,8 @@ export function MenuItem({
description, description,
onPress, onPress,
rightElement, rightElement,
rightText,
badge,
showChevron = true, showChevron = true,
variant = 'default', variant = 'default',
disabled = false, disabled = false,
@@ -65,17 +71,29 @@ export function MenuItem({
</LinearGradient> </LinearGradient>
)} )}
<View style={styles.textContainer}> <View style={styles.textContainer}>
<Text style={[styles.label, { color: labelColor }]}>{label}</Text> <View style={styles.labelRow}>
<Text style={[styles.label, { color: labelColor }]}>{label}</Text>
{badge && (
<View style={styles.badge}>
<Text style={styles.badgeText}>{badge}</Text>
</View>
)}
</View>
{description && ( {description && (
<Text style={styles.description}>{description}</Text> <Text style={styles.description}>{description}</Text>
)} )}
</View> </View>
</View> </View>
{rightElement || (showChevron && ( <View style={styles.rightSection}>
<View style={styles.chevron}> {rightText && (
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} /> <Text style={styles.rightText} numberOfLines={1}>{rightText}</Text>
</View> )}
))} {rightElement || (showChevron && (
<View style={styles.chevron}>
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
</View>
))}
</View>
</TouchableOpacity> </TouchableOpacity>
); );
} }
@@ -113,15 +131,43 @@ const styles = StyleSheet.create({
textContainer: { textContainer: {
flex: 1, flex: 1,
}, },
labelRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
label: { label: {
...typography.body, ...typography.body,
fontWeight: '500', 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: { description: {
...typography.caption1, ...typography.caption1,
color: colors.textSecondary, color: colors.textSecondary,
marginTop: 2, marginTop: 2,
}, },
rightSection: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
rightText: {
...typography.subhead,
color: colors.textSecondary,
maxWidth: 120,
},
chevron: { chevron: {
padding: spacing.xs, padding: spacing.xs,
}, },
@@ -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 { SettingsSection } from './SettingsSection';
import { SwitchItem } from './SwitchItem'; import { SwitchItem } from './SwitchItem';
@@ -16,16 +19,132 @@ export const NotificationSettings: React.FC<NotificationSettingsProps> = ({
onPushToggle, onPushToggle,
delay = 360, 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 ( return (
<SettingsSection title="Notifications" delay={delay}> <SettingsSection title="Notifications" delay={delay}>
{/* Master push toggle */}
<SwitchItem <SwitchItem
icon="notifications-outline" icon="notifications-outline"
label="Push Notifications" label="Push Notifications"
description="Get alerts for matches and messages" description="Enable notifications on this device"
value={pushEnabled} value={pushEnabled}
onValueChange={onPushToggle} onValueChange={onPushToggle}
disabled={isUpdatingPush} disabled={isUpdatingPush}
/> />
{/* Granular settings - only show when push is enabled and preferences are loaded */}
{pushEnabled && preferences && (
<>
<View style={styles.sectionDivider}>
<Text style={styles.subsectionLabel}>Notification Types</Text>
</View>
<SwitchItem
icon="heart-outline"
label="Matches"
description="When you and your partner match"
value={preferences.matches_enabled}
onValueChange={handlers.onMatchesToggle}
disabled={isUpdating}
/>
<View style={styles.itemDivider} />
<SwitchItem
icon="chatbubble-outline"
label="Messages"
description="When your partner sends you a message"
value={preferences.messages_enabled}
onValueChange={handlers.onMessagesToggle}
disabled={isUpdating}
/>
<View style={styles.itemDivider} />
<SwitchItem
icon="person-outline"
label="Partner Activity"
description="When your partner answers questions"
value={preferences.partner_activity_enabled}
onValueChange={handlers.onPartnerActivityToggle}
disabled={isUpdating}
/>
<View style={styles.itemDivider} />
<SwitchItem
icon="layers-outline"
label="Pack Updates"
description="When your partner enables new packs"
value={preferences.pack_changes_enabled}
onValueChange={handlers.onPackChangesToggle}
disabled={isUpdating}
/>
<View style={styles.itemDivider} />
<SwitchItem
icon="sparkles-outline"
label="New Packs"
description="When new question packs are available"
value={preferences.new_packs_enabled}
onValueChange={handlers.onNewPacksToggle}
disabled={isUpdating}
/>
<View style={styles.itemDivider} />
<SwitchItem
icon="flame-outline"
label="Streak Milestones"
description="Celebrate your streak achievements"
value={preferences.streak_milestones_enabled}
onValueChange={handlers.onStreakMilestonesToggle}
disabled={isUpdating}
/>
</>
)}
</SettingsSection> </SettingsSection>
); );
}; };
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,
},
});
@@ -1,10 +1,12 @@
import React from 'react'; import React, { useState } from 'react';
import { View, Text, StyleSheet } from 'react-native'; import { View, Text, StyleSheet, TouchableOpacity, Modal, Platform, ScrollView } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { BlurView } from 'expo-blur';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { SettingsSection } from './SettingsSection'; import { SettingsSection } from './SettingsSection';
import { SwitchItem } from './SwitchItem'; import { SwitchItem } from './SwitchItem';
import { IntensitySlider } from '../../../components/ui/IntensitySlider'; import { IntensitySlider } from '../../../components/ui/IntensitySlider';
import { GlassButton } from '../../../components/ui';
import { colors, spacing, radius, typography, featureColors } from '../../../theme'; import { colors, spacing, radius, typography, featureColors } from '../../../theme';
import type { IntensityLevel } from '../../../types'; import type { IntensityLevel } from '../../../types';
@@ -38,7 +40,10 @@ export const PrivacySettings: React.FC<PrivacySettingsProps> = ({
partnerName, partnerName,
partnerAvatar, partnerAvatar,
}) => { }) => {
const [showHelpModal, setShowHelpModal] = useState(false);
return ( return (
<>
<SettingsSection title="Preferences" delay={375}> <SettingsSection title="Preferences" delay={375}>
{/* Comfort Zone Header */} {/* Comfort Zone Header */}
<View style={styles.comfortHeader}> <View style={styles.comfortHeader}>
@@ -51,7 +56,16 @@ export const PrivacySettings: React.FC<PrivacySettingsProps> = ({
<Ionicons name="heart-circle" size={22} color={colors.text} /> <Ionicons name="heart-circle" size={22} color={colors.text} />
</LinearGradient> </LinearGradient>
<View style={styles.comfortText}> <View style={styles.comfortText}>
<Text style={styles.comfortLabel}>Comfort Zone</Text> <View style={styles.comfortLabelRow}>
<Text style={styles.comfortLabel}>Comfort Zone</Text>
<TouchableOpacity
onPress={() => setShowHelpModal(true)}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
activeOpacity={0.7}
>
<Ionicons name="help-circle-outline" size={20} color={colors.textSecondary} />
</TouchableOpacity>
</View>
<Text style={styles.comfortDescription}> <Text style={styles.comfortDescription}>
Set the intimacy level you're comfortable with Set the intimacy level you're comfortable with
</Text> </Text>
@@ -85,6 +99,100 @@ export const PrivacySettings: React.FC<PrivacySettingsProps> = ({
</> </>
)} )}
</SettingsSection> </SettingsSection>
{/* Help Modal */}
<Modal
visible={showHelpModal}
transparent
animationType="fade"
onRequestClose={() => setShowHelpModal(false)}
>
<BlurView
intensity={Platform.OS === 'ios' ? 20 : 0}
tint="dark"
style={StyleSheet.absoluteFill}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>How Comfort Zone Works</Text>
<TouchableOpacity
onPress={() => setShowHelpModal(false)}
style={styles.closeButton}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="close" size={24} color={colors.textSecondary} />
</TouchableOpacity>
</View>
<ScrollView
style={styles.helpScrollView}
showsVerticalScrollIndicator={false}
>
<View style={styles.helpSection}>
<View style={styles.helpItem}>
<Ionicons name="layers-outline" size={20} color={colors.primary} style={styles.helpIcon} />
<View style={styles.helpTextContainer}>
<Text style={styles.helpItemTitle}>Intensity Levels</Text>
<Text style={styles.helpItemDescription}>
Questions range from 1 (mild) to 5 (spicy). You'll only see questions at or below your comfort level.
</Text>
</View>
</View>
<View style={styles.helpItem}>
<Ionicons name="albums-outline" size={20} color={colors.primary} style={styles.helpIcon} />
<View style={styles.helpTextContainer}>
<Text style={styles.helpItemTitle}>Hidden Packs</Text>
<Text style={styles.helpItemDescription}>
Some question packs are designed for higher intensity levels. These packs won't appear until you raise your comfort zone.
</Text>
</View>
</View>
<View style={styles.helpItem}>
<Ionicons name="people-outline" size={20} color={colors.primary} style={styles.helpIcon} />
<View style={styles.helpTextContainer}>
<Text style={styles.helpItemTitle}>Partner Sync</Text>
<Text style={styles.helpItemDescription}>
Questions only appear when both of you are comfortable. The lower setting between you and your partner determines what you both see.
</Text>
</View>
</View>
<View style={styles.helpItem}>
<Ionicons name="eye-outline" size={20} color={colors.primary} style={styles.helpIcon} />
<View style={styles.helpTextContainer}>
<Text style={styles.helpItemTitle}>Visible to Partner</Text>
<Text style={styles.helpItemDescription}>
Your partner can see the comfort zone level you choose so you can stay aligned. This doesn’t reveal your individual swipes.
</Text>
</View>
</View>
<View style={[styles.helpItem, styles.helpItemLast]}>
<Ionicons name="refresh-outline" size={20} color={colors.primary} style={styles.helpIcon} />
<View style={styles.helpTextContainer}>
<Text style={styles.helpItemTitle}>Change Anytime</Text>
<Text style={styles.helpItemDescription}>
Adjust whenever you want. New questions and packs will appear or hide based on your new setting.
</Text>
</View>
</View>
</View>
</ScrollView>
<GlassButton
onPress={() => setShowHelpModal(false)}
fullWidth
>
Got it
</GlassButton>
</View>
</View>
</BlurView>
</Modal>
</>
); );
}; };
@@ -105,6 +213,11 @@ const styles = StyleSheet.create({
comfortText: { comfortText: {
flex: 1, flex: 1,
}, },
comfortLabelRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
comfortLabel: { comfortLabel: {
...typography.headline, ...typography.headline,
color: colors.text, color: colors.text,
@@ -122,4 +235,71 @@ const styles = StyleSheet.create({
backgroundColor: colors.glass.border, backgroundColor: colors.glass.border,
marginVertical: spacing.lg, 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,
},
}); });
@@ -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<void>;
onDeleteRelationship?: () => Promise<void>;
}
/**
* Danger zone component specifically for relationship-related actions.
* Does NOT include delete account - that's in AccountScreen.
*/
export const RelationshipDangerZone: React.FC<RelationshipDangerZoneProps> = ({
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 (
<>
<Animated.View
entering={FadeInDown.delay(100).duration(500)}
style={styles.section}
>
<Text style={styles.dangerSectionTitle}>Danger Zone</Text>
{/* Reset Progress */}
{onResetProgress && (
<GlassCard style={styles.warningCard}>
<View style={styles.dangerContent}>
<View style={styles.dangerInfo}>
<View style={styles.warningIconContainer}>
<Ionicons name="refresh" size={20} color={colors.warning} />
</View>
<View style={styles.dangerTextContainer}>
<Text style={styles.dangerTitle}>Reset Progress</Text>
<Text style={styles.dangerDescription}>
Start fresh by clearing all swipes, matches, and chats. Your partner connection stays.
</Text>
</View>
</View>
<TouchableOpacity
style={styles.warningButton}
onPress={() => setShowResetModal(true)}
activeOpacity={0.7}
>
<Ionicons name="refresh-outline" size={16} color={colors.warning} />
<Text style={styles.warningButtonText}>Reset</Text>
</TouchableOpacity>
</View>
</GlassCard>
)}
{/* Delete Relationship */}
{onDeleteRelationship && (
<GlassCard style={StyleSheet.flatten([styles.dangerCard, onResetProgress && styles.dangerCardSpacing]) as ViewStyle}>
<View style={styles.dangerContent}>
<View style={styles.dangerInfo}>
<View style={styles.dangerIconContainer}>
<Ionicons name="heart-dislike" size={20} color={colors.error} />
</View>
<View style={styles.dangerTextContainer}>
<Text style={styles.dangerTitle}>Delete Relationship</Text>
<Text style={styles.dangerDescription}>
End your connection and delete all shared data. Your account will remain.
</Text>
</View>
</View>
<GlassButton
variant="danger"
size="sm"
onPress={() => setShowDeleteRelationshipModal(true)}
icon={<Ionicons name="trash-outline" size={16} color={colors.text} />}
style={styles.dangerButton}
>
Delete
</GlassButton>
</View>
</GlassCard>
)}
</Animated.View>
{/* Reset Progress Modal */}
<Modal
visible={showResetModal}
transparent
animationType="fade"
onRequestClose={closeResetModal}
>
<BlurView
intensity={Platform.OS === 'ios' ? 20 : 0}
tint="dark"
style={StyleSheet.absoluteFill}
>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.modalOverlay}
>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<View style={styles.warningModalIconContainer}>
<Ionicons name="refresh" size={32} color={colors.warning} />
</View>
<Text style={styles.modalTitle}>Reset Progress?</Text>
</View>
<Text style={styles.modalDescription}>
This will permanently delete:
</Text>
<View style={styles.warningList}>
<View style={styles.deleteListItem}>
<Ionicons name="swap-horizontal" size={16} color={colors.warning} />
<Text style={styles.deleteListText}>All your swipes</Text>
</View>
<View style={styles.deleteListItem}>
<Ionicons name="heart" size={16} color={colors.warning} />
<Text style={styles.deleteListText}>All matches</Text>
</View>
<View style={styles.deleteListItem}>
<Ionicons name="chatbubbles" size={16} color={colors.warning} />
<Text style={styles.deleteListText}>All chat messages</Text>
</View>
</View>
<View style={styles.keepList}>
<View style={styles.deleteListItem}>
<Ionicons name="checkmark-circle" size={16} color={colors.success} />
<Text style={styles.deleteListText}>Your partner connection stays</Text>
</View>
</View>
<Text style={styles.confirmLabel}>
Type <Text style={styles.warningKeyword}>RESET</Text> to confirm:
</Text>
<TextInput
style={styles.warningInput}
value={confirmText}
onChangeText={setConfirmText}
placeholder="Type RESET"
placeholderTextColor={colors.textTertiary}
autoCapitalize="characters"
autoCorrect={false}
editable={!isResetting}
/>
<View style={styles.modalButtons}>
<TouchableOpacity
style={styles.cancelButton}
onPress={closeResetModal}
disabled={isResetting}
activeOpacity={0.7}
>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.warningActionButton,
(confirmText !== "RESET" || isResetting) && styles.deleteButtonDisabled,
]}
onPress={handleResetProgress}
disabled={confirmText !== "RESET" || isResetting}
activeOpacity={0.7}
>
{isResetting ? (
<ActivityIndicator size="small" color={colors.text} />
) : (
<>
<Ionicons name="refresh" size={16} color={colors.text} />
<Text style={styles.deleteButtonText}>Reset</Text>
</>
)}
</TouchableOpacity>
</View>
</View>
</KeyboardAvoidingView>
</BlurView>
</Modal>
{/* Delete Relationship Modal */}
<Modal
visible={showDeleteRelationshipModal}
transparent
animationType="fade"
onRequestClose={closeRelationshipModal}
>
<BlurView
intensity={Platform.OS === 'ios' ? 20 : 0}
tint="dark"
style={StyleSheet.absoluteFill}
>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.modalOverlay}
>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<View style={styles.modalIconContainer}>
<Ionicons name="heart-dislike" size={32} color={colors.error} />
</View>
<Text style={styles.modalTitle}>Delete Relationship Data?</Text>
</View>
<Text style={styles.modalDescription}>
This action cannot be undone. This will permanently delete:
</Text>
<View style={styles.deleteList}>
<View style={styles.deleteListItem}>
<Ionicons name="heart-dislike" size={16} color={colors.error} />
<Text style={styles.deleteListText}>Your couple connection</Text>
</View>
<View style={styles.deleteListItem}>
<Ionicons name="checkmark-circle" size={16} color={colors.error} />
<Text style={styles.deleteListText}>All matches and responses</Text>
</View>
<View style={styles.deleteListItem}>
<Ionicons name="chatbubbles" size={16} color={colors.error} />
<Text style={styles.deleteListText}>All chat messages</Text>
</View>
<View style={styles.deleteListItem}>
<Ionicons name="images" size={16} color={colors.error} />
<Text style={styles.deleteListText}>All shared photos</Text>
</View>
</View>
<Text style={styles.confirmLabel}>
Type <Text style={styles.confirmKeyword}>DELETE</Text> to confirm:
</Text>
<TextInput
style={styles.confirmInput}
value={confirmText}
onChangeText={setConfirmText}
placeholder="Type DELETE"
placeholderTextColor={colors.textTertiary}
autoCapitalize="characters"
autoCorrect={false}
editable={!isDeletingRelationship}
/>
<View style={styles.modalButtons}>
<TouchableOpacity
style={styles.cancelButton}
onPress={closeRelationshipModal}
disabled={isDeletingRelationship}
activeOpacity={0.7}
>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.deleteButton,
(confirmText !== "DELETE" || isDeletingRelationship) && styles.deleteButtonDisabled,
]}
onPress={handleDeleteRelationship}
disabled={confirmText !== "DELETE" || isDeletingRelationship}
activeOpacity={0.7}
>
{isDeletingRelationship ? (
<ActivityIndicator size="small" color={colors.text} />
) : (
<>
<Ionicons name="trash" size={16} color={colors.text} />
<Text style={styles.deleteButtonText}>Delete</Text>
</>
)}
</TouchableOpacity>
</View>
</View>
</KeyboardAvoidingView>
</BlurView>
</Modal>
</>
);
};
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;
@@ -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 (
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
<TouchableOpacity
onPress={handleBack}
style={styles.backButton}
activeOpacity={0.7}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="chevron-back" size={24} color={colors.text} />
</TouchableOpacity>
<Text style={styles.title} numberOfLines={1}>
{title}
</Text>
<View style={styles.rightContainer}>
{rightElement || <View style={styles.placeholder} />}
</View>
</View>
);
}
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;
@@ -8,6 +8,7 @@ export type { NotificationSettingsProps } from './NotificationSettings';
export { CoupleStatus } from './CoupleStatus'; export { CoupleStatus } from './CoupleStatus';
export { PrivacySettings } from './PrivacySettings'; export { PrivacySettings } from './PrivacySettings';
export { DangerZone } from './DangerZone'; export { DangerZone } from './DangerZone';
export { RelationshipDangerZone } from './RelationshipDangerZone';
export { ResetProgress } from './ResetProgress'; export { ResetProgress } from './ResetProgress';
export { SettingsSection } from './SettingsSection'; export { SettingsSection } from './SettingsSection';
@@ -18,3 +19,5 @@ export { MenuItem } from './MenuItem';
export type { MenuItemProps } from './MenuItem'; export type { MenuItemProps } from './MenuItem';
export { SubscriptionCard } from './SubscriptionCard'; export { SubscriptionCard } from './SubscriptionCard';
export type { SubscriptionCardProps } from './SubscriptionCard'; export type { SubscriptionCardProps } from './SubscriptionCard';
export { ScreenHeader } from './ScreenHeader';
export type { ScreenHeaderProps } from './ScreenHeader';
@@ -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 (
<GradientBackground>
<ScreenHeader title="Account" />
<ScrollView
style={styles.container}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
<SettingsSection title="Account" delay={0}>
<MenuItem
icon="log-out-outline"
label="Sign Out"
onPress={handleSignOut}
variant="danger"
showChevron={false}
/>
</SettingsSection>
<View style={styles.spacer} />
{/* Account Danger Zone - delete account only */}
<DangerZone
onDeleteAccount={handleDeleteAccount}
hasRelationship={!!couple}
/>
</ScrollView>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
content: {
paddingTop: spacing.lg,
paddingBottom: Platform.OS === 'ios' ? 40 : 20,
},
spacer: {
height: spacing.md,
},
});
export default AccountScreen;
@@ -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 (
<GradientBackground>
<ScreenHeader title="Notifications" />
<ScrollView
style={styles.container}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
<NotificationSettings
pushEnabled={settings.pushEnabled}
isUpdatingPush={settings.isUpdatingPush}
onPushToggle={settings.handlePushToggle}
delay={0}
/>
</ScrollView>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
content: {
paddingTop: spacing.lg,
paddingBottom: Platform.OS === 'ios' ? 40 : 20,
},
});
export default NotificationsScreen;
@@ -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 (
<GradientBackground>
<ScreenHeader title="Partner" />
<ScrollView
style={styles.container}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
{/* Partner Info */}
<CoupleStatus
partner={partner}
couple={couple}
onUnpair={handleUnpair}
onPairingPress={navigateToPairing}
/>
{/* Relationship Danger Zone - only show if in a relationship */}
{couple && (
<>
<View style={styles.spacer} />
<RelationshipDangerZone
onResetProgress={handleResetProgress}
onDeleteRelationship={handleDeleteRelationship}
/>
</>
)}
</ScrollView>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
content: {
paddingTop: spacing.lg,
paddingBottom: Platform.OS === 'ios' ? 40 : 20,
},
spacer: {
height: spacing.lg,
},
});
export default PartnerScreen;
@@ -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 (
<GradientBackground>
<ScreenHeader title="Preferences" />
<ScrollView
style={styles.container}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
<PrivacySettings
maxIntensity={settings.maxIntensity}
isUpdatingIntensity={settings.isUpdatingIntensity}
onIntensityChange={settings.handleIntensityChange}
biometricAvailable={settings.biometricAvailable}
biometricEnabled={settings.biometricEnabled}
biometricType={settings.biometricType}
isUpdatingBiometric={settings.isUpdatingBiometric}
onBiometricToggle={settings.handleBiometricToggle}
partnerIntensity={settings.partnerIntensity}
partnerName={settings.partnerName}
partnerAvatar={settings.partnerAvatar}
/>
</ScrollView>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
content: {
paddingTop: spacing.lg,
paddingBottom: Platform.OS === 'ios' ? 40 : 20,
},
});
export default PreferencesScreen;
@@ -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<string | null>(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<Gender | null>(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<string, any> = {
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 (
<GradientBackground>
<ScreenHeader
title="Profile"
rightElement={
hasChanges ? (
<TouchableOpacity
onPress={handleSave}
disabled={isSaving}
activeOpacity={0.7}
>
{isSaving ? (
<ActivityIndicator size="small" color={featureColors.profile.accent} />
) : (
<Text style={styles.saveButton}>Save</Text>
)}
</TouchableOpacity>
) : undefined
}
/>
<ScrollView
style={styles.container}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
{/* Avatar Section */}
<Animated.View
entering={FadeInDown.delay(100).duration(500)}
style={styles.avatarSection}
>
<TouchableOpacity
onPress={settings.handleAvatarPress}
disabled={settings.isUploadingAvatar}
activeOpacity={0.7}
style={styles.avatarTouchable}
>
<LinearGradient
colors={ACCENT_GRADIENT}
style={styles.avatarGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
{user?.avatar_url ? (
<Image
source={{ uri: user.avatar_url }}
style={styles.avatarImage}
cachePolicy="disk"
transition={200}
/>
) : (
<View style={styles.avatarInner}>
<Text style={styles.avatarText}>
{user?.name?.[0]?.toUpperCase() || 'U'}
</Text>
</View>
)}
</LinearGradient>
{settings.isUploadingAvatar ? (
<View style={styles.avatarOverlay}>
<ActivityIndicator size="large" color={colors.text} />
</View>
) : (
<View style={styles.avatarEditBadge}>
<Ionicons name="camera" size={16} color={colors.text} />
</View>
)}
</TouchableOpacity>
<Text style={styles.avatarHint}>Tap to change photo</Text>
</Animated.View>
{/* Name Section */}
<SettingsSection title="Display Name" delay={150}>
<View style={styles.inputContainer}>
<Ionicons
name="person-outline"
size={20}
color={colors.textSecondary}
style={styles.inputIcon}
/>
<TextInput
style={styles.input}
value={name}
onChangeText={setName}
placeholder="Enter your name"
placeholderTextColor={colors.textTertiary}
autoCapitalize="words"
autoCorrect={false}
returnKeyType="done"
/>
</View>
<Text style={styles.inputHint}>
This is how your partner will see you
</Text>
</SettingsSection>
{/* Gender Section */}
<SettingsSection title="Gender" delay={200}>
<Text style={styles.genderDescription}>
This helps us show you the right questions
</Text>
<View style={styles.genderGrid}>
{GENDER_OPTIONS.map((option) => (
<Pressable
key={option.value}
style={[
styles.genderOption,
gender === option.value && styles.genderOptionSelected,
]}
onPress={() => setGender(option.value)}
>
<Ionicons
name={option.icon as any}
size={24}
color={gender === option.value ? colors.primary : colors.textSecondary}
/>
<Text
style={[
styles.genderLabel,
gender === option.value && styles.genderLabelSelected,
]}
>
{option.label}
</Text>
</Pressable>
))}
</View>
</SettingsSection>
{/* Email (read-only) */}
<SettingsSection title="Email" delay={250}>
<View style={styles.readOnlyField}>
<Ionicons
name="mail-outline"
size={20}
color={colors.textTertiary}
style={styles.inputIcon}
/>
<Text style={styles.readOnlyText}>{authEmail || 'Loading...'}</Text>
</View>
<Text style={styles.inputHint}>
Email cannot be changed
</Text>
</SettingsSection>
<View style={styles.bottomSpacer} />
</ScrollView>
</GradientBackground>
);
}
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;
@@ -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 (
<GradientBackground>
<ScreenHeader title="Subscription" />
<ScrollView
style={styles.container}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
<SubscriptionCard
hasPremiumAccess={hasPremiumAccess}
isOwnSubscription={!!isOwnSubscription}
expirationDate={formatExpirationDate(subscription.expirationDate)}
onUpgradePress={() => settings.setShowPaywall(true)}
onManagePress={settings.handleManageSubscription}
onRestorePress={settings.handleRestorePurchases}
isRestoring={settings.isPurchasing}
delay={0}
/>
</ScrollView>
<Paywall
visible={settings.showPaywall}
onClose={() => settings.setShowPaywall(false)}
/>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
content: {
paddingTop: spacing.lg,
paddingBottom: Platform.OS === 'ios' ? 40 : 20,
},
});
export default SubscriptionScreen;
@@ -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 (
<GradientBackground>
<ScreenHeader title="Help & Support" />
<ScrollView
style={styles.container}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
<SettingsSection title="Support" delay={0}>
<MenuItem
icon="chatbubble-ellipses-outline"
label="Send Feedback"
description="Report bugs or request features"
onPress={() => setShowFeedbackModal(true)}
/>
<View style={styles.divider} />
<MenuItem
icon="shield-checkmark-outline"
label="Privacy Policy"
onPress={() => Linking.openURL('https://sauci.app/privacy')}
/>
<View style={styles.divider} />
<MenuItem
icon="document-text-outline"
label="Terms of Service"
onPress={() => Linking.openURL('https://sauci.app/terms')}
/>
</SettingsSection>
</ScrollView>
<FeedbackModal
visible={showFeedbackModal}
onClose={() => setShowFeedbackModal(false)}
/>
</GradientBackground>
);
}
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;
@@ -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';
+41 -16
View File
@@ -172,15 +172,27 @@ export function logScreenView(screenName: string, screenClass?: string) {
} }
} }
// Flush events (useful before app backgrounding) // Flush events (useful before app backgrounding)
export function flush() { export function flush() {
// Firebase Analytics batches and sends events automatically // Firebase Analytics batches and sends events automatically
// No manual flush needed, but we keep the function for API compatibility // No manual flush needed, but we keep the function for API compatibility
if (__DEV__) console.log("[Analytics] Flush called (no-op for Firebase)"); if (__DEV__) console.log("[Analytics] Flush called (no-op for Firebase)");
} }
// Pre-defined event helpers for common actions function truncateParam(value: string, maxLength = 100) {
export const Events = { 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 // Auth events
signUp: (method: string) => logEvent("sign_up", { method }), signUp: (method: string) => logEvent("sign_up", { method }),
signIn: (method: string) => logEvent("login", { method }), signIn: (method: string) => logEvent("login", { method }),
@@ -210,13 +222,26 @@ export const Events = {
messageSent: () => logEvent("message_sent"), messageSent: () => logEvent("message_sent"),
mediaUploaded: () => logEvent("chat_media_uploaded"), mediaUploaded: () => logEvent("chat_media_uploaded"),
// Subscription events // Subscription events
paywallShown: (source: string) => logEvent("paywall_shown", { source }), paywallShown: (source: string) => logEvent("paywall_shown", { source }),
purchaseInitiated: (packageType: string) => paywallClosed: (source: string, reason: string) =>
logEvent("purchase_initiated", { package_type: packageType }), logEvent("paywall_closed", { source, reason }),
purchaseCompleted: (packageType: string) => purchaseInitiated: (packageType: string) =>
logEvent("purchase", { package_type: packageType }), logEvent("purchase_initiated", { package_type: packageType }),
purchaseRestored: () => logEvent("purchase_restored"), 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 // Pack events
packEnabled: (packId: string) => logEvent("pack_enabled", { pack_id: packId }), packEnabled: (packId: string) => logEvent("pack_enabled", { pack_id: packId }),
+57
View File
@@ -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<void> {
// 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<void> {
await setBadgeCount(0);
}
/**
* Get the current badge count.
*/
export async function getBadgeCount(): Promise<number> {
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<void> {
const total = newMatchesCount + unreadMessagesCount;
await setBadgeCount(total);
}
+40 -4
View File
@@ -17,6 +17,7 @@ interface AuthState {
refreshPartner: () => Promise<Profile | null>; refreshPartner: () => Promise<Profile | null>;
signOut: () => Promise<void>; signOut: () => Promise<void>;
setUser: (user: Profile | null) => void; setUser: (user: Profile | null) => void;
updateLastActive: () => Promise<void>;
} }
// Import other stores lazily to avoid circular dependency issues // Import other stores lazily to avoid circular dependency issues
@@ -25,7 +26,9 @@ const getOtherStores = () => {
const { usePacksStore } = require("./packsStore"); const { usePacksStore } = require("./packsStore");
const { useMessageStore } = require("./messageStore"); const { useMessageStore } = require("./messageStore");
const { useSubscriptionStore } = require("./subscriptionStore"); 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<AuthState>((set, get) => ({
// Session is invalid - clear everything // Session is invalid - clear everything
set({ user: null, couple: null, partner: null, isAuthenticated: false, isLoading: false }); set({ user: null, couple: null, partner: null, isAuthenticated: false, isLoading: false });
// Clear other stores // Clear other stores
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore } = getOtherStores(); const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
useMatchStore.getState().clearMatches(); useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks(); usePacksStore.getState().clearPacks();
useMessageStore.getState().clearMessages(); useMessageStore.getState().clearMessages();
useSubscriptionStore.getState().clearSubscription(); useSubscriptionStore.getState().clearSubscription();
useNotificationPreferencesStore.getState().clearPreferences();
useStreakStore.getState().clearStreak();
// Sign out from Supabase (clears storage) // Sign out from Supabase (clears storage)
await supabase.auth.signOut(); await supabase.auth.signOut();
return; return;
@@ -145,11 +150,17 @@ export const useAuthStore = create<AuthState>((set, get) => ({
isLoading: false, isLoading: false,
}); });
// Clear other stores // Clear other stores
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore } = getOtherStores(); const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
useMatchStore.getState().clearMatches(); useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks(); usePacksStore.getState().clearPacks();
useMessageStore.getState().clearMessages(); useMessageStore.getState().clearMessages();
useSubscriptionStore.getState().clearSubscription(); 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) // Then try to sign out from Supabase (don't block on this)
try { try {
@@ -169,11 +180,36 @@ export const useAuthStore = create<AuthState>((set, get) => ({
}); });
// Clear other stores when user signs out // Clear other stores when user signs out
if (user === null) { if (user === null) {
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore } = getOtherStores(); const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
useMatchStore.getState().clearMatches(); useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks(); usePacksStore.getState().clearPacks();
useMessageStore.getState().clearMessages(); useMessageStore.getState().clearMessages();
useSubscriptionStore.getState().clearSubscription(); 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);
} }
}, },
})); }));
+4
View File
@@ -6,3 +6,7 @@ export { useMessageStore } from "./messageStore";
export { useSubscriptionStore } from "./subscriptionStore"; export { useSubscriptionStore } from "./subscriptionStore";
export { useResponsesStore, groupResponses } from "./responsesStore"; export { useResponsesStore, groupResponses } from "./responsesStore";
export type { ResponseWithQuestion, UpdateResponseResult, GroupByOption, DateSortOrder } 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";
@@ -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<void>;
updatePreference: (key: keyof Omit<NotificationPreferences, 'user_id' | 'created_at' | 'updated_at'>, value: boolean) => Promise<void>;
clearPreferences: () => void;
}
export const useNotificationPreferencesStore = create<NotificationPreferencesState>((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 });
},
}));
+60
View File
@@ -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<void>;
clearStreak: () => void;
}
export const useStreakStore = create<StreakState>((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 });
},
}));
+52 -35
View File
@@ -7,21 +7,28 @@ import revenueCatService, {
} from "../lib/revenuecat"; } from "../lib/revenuecat";
import { useAuthStore } from "./authStore"; import { useAuthStore } from "./authStore";
interface SubscriptionStoreState { export type PurchaseResult = {
subscription: SubscriptionState; success: boolean;
offerings: PurchasesOffering | null; cancelled?: boolean;
isLoadingOfferings: boolean; errorCode?: unknown;
isPurchasing: boolean; errorMessage?: unknown;
error: string | null; };
// Actions interface SubscriptionStoreState {
initializeRevenueCat: (userId: string) => Promise<void>; subscription: SubscriptionState;
fetchOfferings: () => Promise<void>; offerings: PurchasesOffering | null;
purchasePackage: (pkg: PurchasesPackage) => Promise<boolean>; isLoadingOfferings: boolean;
restorePurchases: () => Promise<boolean>; isPurchasing: boolean;
refreshSubscriptionStatus: () => Promise<void>; error: string | null;
clearSubscription: () => void;
} // Actions
initializeRevenueCat: (userId: string) => Promise<void>;
fetchOfferings: () => Promise<void>;
purchasePackage: (pkg: PurchasesPackage) => Promise<PurchaseResult>;
restorePurchases: () => Promise<boolean>;
refreshSubscriptionStatus: () => Promise<void>;
clearSubscription: () => void;
}
const defaultSubscription: SubscriptionState = { const defaultSubscription: SubscriptionState = {
isProUser: false, isProUser: false,
@@ -121,26 +128,36 @@ export const useSubscriptionStore = create<SubscriptionStoreState>((set, get) =>
} }
}, },
purchasePackage: async (pkg: PurchasesPackage) => { purchasePackage: async (pkg: PurchasesPackage) => {
set({ isPurchasing: true, error: null }); set({ isPurchasing: true, error: null });
try { try {
const customerInfo = await revenueCatService.purchasePackage(pkg); const customerInfo = await revenueCatService.purchasePackage(pkg);
const subscription = revenueCatService.parseCustomerInfo(customerInfo); const subscription = revenueCatService.parseCustomerInfo(customerInfo);
set({ subscription, isPurchasing: false }); set({ subscription, isPurchasing: false });
// Refresh user profile to get updated is_premium // Refresh user profile to get updated is_premium
await useAuthStore.getState().fetchUser(); await useAuthStore.getState().fetchUser();
return true; return { success: true };
} catch (error: any) { } catch (error: any) {
const message = const isCancelled =
error.message === "Purchase cancelled" error?.userCancelled === true ||
? "Purchase was cancelled" error?.message === "Purchase cancelled" ||
: "Purchase failed. Please try again."; error?.code === "PURCHASE_CANCELLED";
set({ error: message, isPurchasing: false });
return false; 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 () => { restorePurchases: async () => {
set({ isPurchasing: true, error: null }); set({ isPurchasing: true, error: null });
@@ -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<string, unknown>;
}
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" } }
);
}
});
@@ -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<string, unknown>;
}
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" } }
);
}
});
@@ -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<string, unknown>;
}
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" } }
);
}
});
@@ -5,7 +5,6 @@
-- EXTENSIONS -- EXTENSIONS
-- ============================================================================ -- ============================================================================
CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA extensions; CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA extensions;
-- ============================================================================ -- ============================================================================
-- CUSTOM TYPES -- 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 subscription_status AS ENUM ('active', 'cancelled', 'expired', 'billing_issue', 'paused');
CREATE TYPE admin_role AS ENUM ('pack_creator', 'super_admin'); CREATE TYPE admin_role AS ENUM ('pack_creator', 'super_admin');
CREATE TYPE audit_action AS ENUM ('INSERT', 'UPDATE', 'DELETE'); CREATE TYPE audit_action AS ENUM ('INSERT', 'UPDATE', 'DELETE');
-- ============================================================================ -- ============================================================================
-- TABLES -- TABLES
-- ============================================================================ -- ============================================================================
@@ -27,7 +25,6 @@ CREATE TABLE couples (
invite_code TEXT UNIQUE DEFAULT substr(md5(random()::text), 1, 8), invite_code TEXT UNIQUE DEFAULT substr(md5(random()::text), 1, 8),
created_at TIMESTAMPTZ DEFAULT now() created_at TIMESTAMPTZ DEFAULT now()
); );
-- Profiles table (extends auth.users) -- Profiles table (extends auth.users)
CREATE TABLE profiles ( CREATE TABLE profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
@@ -40,11 +37,11 @@ CREATE TABLE profiles (
updated_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now(),
email TEXT, email TEXT,
gender TEXT, gender TEXT,
show_explicit_content BOOLEAN DEFAULT false, show_explicit_content BOOLEAN DEFAULT true,
onboarding_completed BOOLEAN DEFAULT false, 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'])) CONSTRAINT valid_gender CHECK (gender IS NULL OR gender = ANY(ARRAY['male', 'female', 'non-binary', 'prefer-not-to-say']))
); );
-- Categories table -- Categories table
CREATE TABLE categories ( CREATE TABLE categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -54,7 +51,6 @@ CREATE TABLE categories (
sort_order INTEGER DEFAULT 0, sort_order INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now() created_at TIMESTAMPTZ DEFAULT now()
); );
-- Question packs table -- Question packs table
CREATE TABLE question_packs ( CREATE TABLE question_packs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -68,7 +64,6 @@ CREATE TABLE question_packs (
category_id UUID REFERENCES categories(id), category_id UUID REFERENCES categories(id),
is_explicit BOOLEAN NOT NULL DEFAULT false is_explicit BOOLEAN NOT NULL DEFAULT false
); );
-- Questions table -- Questions table
CREATE TABLE questions ( CREATE TABLE questions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -80,10 +75,8 @@ CREATE TABLE questions (
allowed_couple_genders TEXT[], allowed_couple_genders TEXT[],
target_user_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.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.'; 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 -- Responses table
CREATE TABLE responses ( CREATE TABLE responses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -94,7 +87,6 @@ CREATE TABLE responses (
created_at TIMESTAMPTZ DEFAULT now(), created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(user_id, question_id) UNIQUE(user_id, question_id)
); );
-- Matches table -- Matches table
CREATE TABLE matches ( CREATE TABLE matches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -105,7 +97,6 @@ CREATE TABLE matches (
created_at TIMESTAMPTZ DEFAULT now(), created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(couple_id, question_id) UNIQUE(couple_id, question_id)
); );
-- Messages table -- Messages table
CREATE TABLE messages ( CREATE TABLE messages (
id UUID PRIMARY KEY DEFAULT extensions.uuid_generate_v4(), id UUID PRIMARY KEY DEFAULT extensions.uuid_generate_v4(),
@@ -119,7 +110,6 @@ CREATE TABLE messages (
delivered_at TIMESTAMPTZ, delivered_at TIMESTAMPTZ,
CONSTRAINT content_or_media CHECK (content IS NOT NULL OR media_path IS NOT NULL) CONSTRAINT content_or_media CHECK (content IS NOT NULL OR media_path IS NOT NULL)
); );
-- Couple packs table (which packs a couple has enabled) -- Couple packs table (which packs a couple has enabled)
CREATE TABLE couple_packs ( CREATE TABLE couple_packs (
couple_id UUID NOT NULL REFERENCES couples(id) ON DELETE CASCADE, couple_id UUID NOT NULL REFERENCES couples(id) ON DELETE CASCADE,
@@ -128,7 +118,6 @@ CREATE TABLE couple_packs (
created_at TIMESTAMPTZ DEFAULT now(), created_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (couple_id, pack_id) PRIMARY KEY (couple_id, pack_id)
); );
-- Feedback table -- Feedback table
CREATE TABLE feedback ( CREATE TABLE feedback (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -144,7 +133,6 @@ CREATE TABLE feedback (
updated_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now(),
question_id UUID REFERENCES questions(id) ON DELETE SET NULL question_id UUID REFERENCES questions(id) ON DELETE SET NULL
); );
-- Subscriptions table -- Subscriptions table
CREATE TABLE subscriptions ( CREATE TABLE subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -164,7 +152,6 @@ CREATE TABLE subscriptions (
updated_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(user_id, original_transaction_id) UNIQUE(user_id, original_transaction_id)
); );
-- RevenueCat webhook events table (for idempotency) -- RevenueCat webhook events table (for idempotency)
CREATE TABLE revenuecat_webhook_events ( CREATE TABLE revenuecat_webhook_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -174,7 +161,6 @@ CREATE TABLE revenuecat_webhook_events (
processed_at TIMESTAMPTZ DEFAULT now(), processed_at TIMESTAMPTZ DEFAULT now(),
payload JSONB payload JSONB
); );
-- Admin users table -- Admin users table
CREATE TABLE admin_users ( CREATE TABLE admin_users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -184,7 +170,6 @@ CREATE TABLE admin_users (
created_by UUID REFERENCES auth.users(id), created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ DEFAULT now() updated_at TIMESTAMPTZ DEFAULT now()
); );
-- Audit logs table -- Audit logs table
CREATE TABLE audit_logs ( CREATE TABLE audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -198,7 +183,6 @@ CREATE TABLE audit_logs (
admin_role admin_role NOT NULL, admin_role admin_role NOT NULL,
created_at TIMESTAMPTZ DEFAULT now() created_at TIMESTAMPTZ DEFAULT now()
); );
-- ============================================================================ -- ============================================================================
-- INDEXES -- 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_admin_user_id ON audit_logs(admin_user_id);
CREATE INDEX idx_audit_logs_action ON audit_logs(action); CREATE INDEX idx_audit_logs_action ON audit_logs(action);
CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at DESC); CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at DESC);
-- ============================================================================ -- ============================================================================
-- FUNCTIONS -- FUNCTIONS
-- ============================================================================ -- ============================================================================
@@ -240,7 +223,6 @@ SET search_path = ''
AS $$ AS $$
SELECT couple_id FROM public.profiles WHERE id = auth.uid(); SELECT couple_id FROM public.profiles WHERE id = auth.uid();
$$; $$;
-- Check if user is any admin -- Check if user is any admin
CREATE OR REPLACE FUNCTION is_admin(check_user_id UUID DEFAULT auth.uid()) CREATE OR REPLACE FUNCTION is_admin(check_user_id UUID DEFAULT auth.uid())
RETURNS BOOLEAN RETURNS BOOLEAN
@@ -255,7 +237,6 @@ BEGIN
); );
END; END;
$$; $$;
-- Check if user is super admin -- Check if user is super admin
CREATE OR REPLACE FUNCTION is_super_admin(check_user_id UUID DEFAULT auth.uid()) CREATE OR REPLACE FUNCTION is_super_admin(check_user_id UUID DEFAULT auth.uid())
RETURNS BOOLEAN RETURNS BOOLEAN
@@ -271,7 +252,6 @@ BEGIN
); );
END; END;
$$; $$;
-- Get admin role for user -- Get admin role for user
CREATE OR REPLACE FUNCTION get_admin_role(check_user_id UUID DEFAULT auth.uid()) CREATE OR REPLACE FUNCTION get_admin_role(check_user_id UUID DEFAULT auth.uid())
RETURNS admin_role RETURNS admin_role
@@ -287,7 +267,6 @@ BEGIN
RETURN user_role; RETURN user_role;
END; END;
$$; $$;
-- Check if user or partner has premium access -- Check if user or partner has premium access
CREATE OR REPLACE FUNCTION has_premium_access(check_user_id UUID) CREATE OR REPLACE FUNCTION has_premium_access(check_user_id UUID)
RETURNS BOOLEAN RETURNS BOOLEAN
@@ -322,7 +301,6 @@ BEGIN
RETURN COALESCE(partner_premium, FALSE); RETURN COALESCE(partner_premium, FALSE);
END; END;
$$; $$;
-- Get recommended questions for a user -- Get recommended questions for a user
CREATE OR REPLACE FUNCTION get_recommended_questions(target_pack_id UUID DEFAULT NULL) CREATE OR REPLACE FUNCTION get_recommended_questions(target_pack_id UUID DEFAULT NULL)
RETURNS TABLE( RETURNS TABLE(
@@ -447,7 +425,6 @@ BEGIN
); );
END; END;
$$; $$;
-- Get pack teaser questions (bypasses premium for preview) -- Get pack teaser questions (bypasses premium for preview)
CREATE OR REPLACE FUNCTION get_pack_teaser_questions(target_pack_id UUID) CREATE OR REPLACE FUNCTION get_pack_teaser_questions(target_pack_id UUID)
RETURNS TABLE(id UUID, text TEXT, intensity INTEGER) RETURNS TABLE(id UUID, text TEXT, intensity INTEGER)
@@ -469,7 +446,6 @@ BEGIN
LIMIT 3; LIMIT 3;
END; END;
$$; $$;
-- Handle new user signup (create profile) -- Handle new user signup (create profile)
CREATE OR REPLACE FUNCTION handle_new_user() CREATE OR REPLACE FUNCTION handle_new_user()
RETURNS TRIGGER RETURNS TRIGGER
@@ -488,7 +464,6 @@ BEGIN
RETURN NEW; RETURN NEW;
END; END;
$$; $$;
-- Update updated_at timestamp -- Update updated_at timestamp
CREATE OR REPLACE FUNCTION update_updated_at() CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER RETURNS TRIGGER
@@ -500,7 +475,6 @@ BEGIN
RETURN NEW; RETURN NEW;
END; END;
$$; $$;
-- Check couple size (max 2 members) -- Check couple size (max 2 members)
CREATE OR REPLACE FUNCTION check_couple_size() CREATE OR REPLACE FUNCTION check_couple_size()
RETURNS TRIGGER RETURNS TRIGGER
@@ -516,7 +490,6 @@ BEGIN
RETURN NEW; RETURN NEW;
END; END;
$$; $$;
-- Sync premium status from subscriptions -- Sync premium status from subscriptions
CREATE OR REPLACE FUNCTION sync_premium_status() CREATE OR REPLACE FUNCTION sync_premium_status()
RETURNS TRIGGER RETURNS TRIGGER
@@ -537,7 +510,6 @@ BEGIN
RETURN NEW; RETURN NEW;
END; END;
$$; $$;
-- Disable premium packs when subscription is lost -- Disable premium packs when subscription is lost
CREATE OR REPLACE FUNCTION disable_premium_packs_on_subscription_loss() CREATE OR REPLACE FUNCTION disable_premium_packs_on_subscription_loss()
RETURNS TRIGGER RETURNS TRIGGER
@@ -566,7 +538,6 @@ BEGIN
RETURN NEW; RETURN NEW;
END; END;
$$; $$;
-- Log admin actions -- Log admin actions
CREATE OR REPLACE FUNCTION log_admin_action( CREATE OR REPLACE FUNCTION log_admin_action(
p_table_name TEXT, p_table_name TEXT,
@@ -611,7 +582,6 @@ BEGIN
RETURN log_id; RETURN log_id;
END; END;
$$; $$;
-- Notify on new message (calls edge function) -- Notify on new message (calls edge function)
CREATE OR REPLACE FUNCTION notify_new_message() CREATE OR REPLACE FUNCTION notify_new_message()
RETURNS TRIGGER RETURNS TRIGGER
@@ -634,7 +604,6 @@ BEGIN
RETURN NEW; RETURN NEW;
END; END;
$$; $$;
-- ============================================================================ -- ============================================================================
-- TRIGGERS -- TRIGGERS
-- ============================================================================ -- ============================================================================
@@ -643,41 +612,33 @@ $$;
CREATE TRIGGER update_profiles_updated_at CREATE TRIGGER update_profiles_updated_at
BEFORE UPDATE ON profiles BEFORE UPDATE ON profiles
FOR EACH ROW EXECUTE FUNCTION update_updated_at(); FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER enforce_couple_size CREATE TRIGGER enforce_couple_size
BEFORE INSERT OR UPDATE OF couple_id ON profiles BEFORE INSERT OR UPDATE OF couple_id ON profiles
FOR EACH ROW EXECUTE FUNCTION check_couple_size(); FOR EACH ROW EXECUTE FUNCTION check_couple_size();
CREATE TRIGGER disable_premium_packs_on_sub_loss CREATE TRIGGER disable_premium_packs_on_sub_loss
AFTER UPDATE OF is_premium ON profiles AFTER UPDATE OF is_premium ON profiles
FOR EACH ROW FOR EACH ROW
WHEN (OLD.is_premium IS DISTINCT FROM NEW.is_premium) WHEN (OLD.is_premium IS DISTINCT FROM NEW.is_premium)
EXECUTE FUNCTION disable_premium_packs_on_subscription_loss(); EXECUTE FUNCTION disable_premium_packs_on_subscription_loss();
-- Subscription triggers -- Subscription triggers
CREATE TRIGGER update_subscriptions_updated_at CREATE TRIGGER update_subscriptions_updated_at
BEFORE UPDATE ON subscriptions BEFORE UPDATE ON subscriptions
FOR EACH ROW EXECUTE FUNCTION update_updated_at(); FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER sync_premium_on_subscription_change CREATE TRIGGER sync_premium_on_subscription_change
AFTER INSERT OR UPDATE ON subscriptions AFTER INSERT OR UPDATE ON subscriptions
FOR EACH ROW EXECUTE FUNCTION sync_premium_status(); FOR EACH ROW EXECUTE FUNCTION sync_premium_status();
-- Admin users trigger -- Admin users trigger
CREATE TRIGGER update_admin_users_updated_at CREATE TRIGGER update_admin_users_updated_at
BEFORE UPDATE ON admin_users BEFORE UPDATE ON admin_users
FOR EACH ROW EXECUTE FUNCTION update_updated_at(); FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- Message notification trigger -- Message notification trigger
CREATE TRIGGER on_new_message CREATE TRIGGER on_new_message
AFTER INSERT ON messages AFTER INSERT ON messages
FOR EACH ROW EXECUTE FUNCTION notify_new_message(); FOR EACH ROW EXECUTE FUNCTION notify_new_message();
-- Auth trigger to create profile on signup -- Auth trigger to create profile on signup
CREATE TRIGGER on_auth_user_created CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION handle_new_user(); FOR EACH ROW EXECUTE FUNCTION handle_new_user();
-- ============================================================================ -- ============================================================================
-- ENABLE ROW LEVEL SECURITY -- 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 revenuecat_webhook_events ENABLE ROW LEVEL SECURITY;
ALTER TABLE admin_users ENABLE ROW LEVEL SECURITY; ALTER TABLE admin_users ENABLE ROW LEVEL SECURITY;
ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY; ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY;
-- ============================================================================ -- ============================================================================
-- RLS POLICIES: COUPLES -- RLS POLICIES: COUPLES
-- ============================================================================ -- ============================================================================
CREATE POLICY "Authenticated users can create couples" CREATE POLICY "Authenticated users can create couples"
ON couples FOR INSERT ON couples FOR INSERT
WITH CHECK (auth.role() = 'authenticated'); WITH CHECK (auth.role() = 'authenticated');
CREATE POLICY "Users can view own couple" CREATE POLICY "Users can view own couple"
ON couples FOR SELECT ON couples FOR SELECT
USING (id = get_auth_user_couple_id()); USING (id = get_auth_user_couple_id());
CREATE POLICY "Users can lookup couple by exact invite code" CREATE POLICY "Users can lookup couple by exact invite code"
ON couples FOR SELECT ON couples FOR SELECT
USING (id = get_auth_user_couple_id()); USING (id = get_auth_user_couple_id());
CREATE POLICY "Super admins can view all couples" CREATE POLICY "Super admins can view all couples"
ON couples FOR SELECT ON couples FOR SELECT
USING (is_super_admin()); USING (is_super_admin());
-- ============================================================================ -- ============================================================================
-- RLS POLICIES: PROFILES -- RLS POLICIES: PROFILES
-- ============================================================================ -- ============================================================================
CREATE POLICY "Users can view own profile" CREATE POLICY "Users can view own profile"
ON profiles FOR SELECT ON profiles FOR SELECT
USING (auth.uid() = id); USING (auth.uid() = id);
CREATE POLICY "Users can view partner profile" CREATE POLICY "Users can view partner profile"
ON profiles FOR SELECT ON profiles FOR SELECT
USING (couple_id IS NOT NULL AND couple_id = get_auth_user_couple_id()); USING (couple_id IS NOT NULL AND couple_id = get_auth_user_couple_id());
CREATE POLICY "Users can update own profile" CREATE POLICY "Users can update own profile"
ON profiles FOR UPDATE ON profiles FOR UPDATE
USING (auth.uid() = id); USING (auth.uid() = id);
CREATE POLICY "Super admins can view all profiles" CREATE POLICY "Super admins can view all profiles"
ON profiles FOR SELECT ON profiles FOR SELECT
USING (is_super_admin()); USING (is_super_admin());
CREATE POLICY "Super admins can update all profiles" CREATE POLICY "Super admins can update all profiles"
ON profiles FOR UPDATE ON profiles FOR UPDATE
USING (is_super_admin()); USING (is_super_admin());
-- ============================================================================ -- ============================================================================
-- RLS POLICIES: CATEGORIES -- RLS POLICIES: CATEGORIES
-- ============================================================================ -- ============================================================================
CREATE POLICY "Categories are viewable by everyone" CREATE POLICY "Categories are viewable by everyone"
ON categories FOR SELECT ON categories FOR SELECT
USING (true); USING (true);
CREATE POLICY "Admins can view all categories" CREATE POLICY "Admins can view all categories"
ON categories FOR SELECT ON categories FOR SELECT
USING (is_admin()); USING (is_admin());
CREATE POLICY "Admins can insert categories" CREATE POLICY "Admins can insert categories"
ON categories FOR INSERT ON categories FOR INSERT
WITH CHECK (is_admin()); WITH CHECK (is_admin());
CREATE POLICY "Admins can update categories" CREATE POLICY "Admins can update categories"
ON categories FOR UPDATE ON categories FOR UPDATE
USING (is_admin()) USING (is_admin())
WITH CHECK (is_admin()); WITH CHECK (is_admin());
CREATE POLICY "Admins can delete categories" CREATE POLICY "Admins can delete categories"
ON categories FOR DELETE ON categories FOR DELETE
USING (is_admin()); USING (is_admin());
-- ============================================================================ -- ============================================================================
-- RLS POLICIES: QUESTION PACKS -- RLS POLICIES: QUESTION PACKS
-- ============================================================================ -- ============================================================================
CREATE POLICY "Anyone can view public packs" CREATE POLICY "Anyone can view public packs"
ON question_packs FOR SELECT ON question_packs FOR SELECT
USING (is_public = true); USING (is_public = true);
CREATE POLICY "Premium users can view premium packs" CREATE POLICY "Premium users can view premium packs"
ON question_packs FOR SELECT ON question_packs FOR SELECT
USING (is_premium = false OR EXISTS ( USING (is_premium = false OR EXISTS (
SELECT 1 FROM profiles WHERE profiles.id = auth.uid() AND profiles.is_premium = true SELECT 1 FROM profiles WHERE profiles.id = auth.uid() AND profiles.is_premium = true
)); ));
CREATE POLICY "Admins can view all question packs" CREATE POLICY "Admins can view all question packs"
ON question_packs FOR SELECT ON question_packs FOR SELECT
USING (is_admin()); USING (is_admin());
CREATE POLICY "Admins can insert question packs" CREATE POLICY "Admins can insert question packs"
ON question_packs FOR INSERT ON question_packs FOR INSERT
WITH CHECK (is_admin()); WITH CHECK (is_admin());
CREATE POLICY "Admins can update question packs" CREATE POLICY "Admins can update question packs"
ON question_packs FOR UPDATE ON question_packs FOR UPDATE
USING (is_admin()); USING (is_admin());
CREATE POLICY "Admins can delete question packs" CREATE POLICY "Admins can delete question packs"
ON question_packs FOR DELETE ON question_packs FOR DELETE
USING (is_admin()); USING (is_admin());
-- ============================================================================ -- ============================================================================
-- RLS POLICIES: QUESTIONS -- 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 SELECT 1 FROM profiles WHERE profiles.id = auth.uid() AND profiles.is_premium = true
)) ))
)); ));
CREATE POLICY "Admins can view all questions" CREATE POLICY "Admins can view all questions"
ON questions FOR SELECT ON questions FOR SELECT
USING (is_admin()); USING (is_admin());
CREATE POLICY "Admins can insert questions" CREATE POLICY "Admins can insert questions"
ON questions FOR INSERT ON questions FOR INSERT
WITH CHECK (is_admin()); WITH CHECK (is_admin());
CREATE POLICY "Admins can update questions" CREATE POLICY "Admins can update questions"
ON questions FOR UPDATE ON questions FOR UPDATE
USING (is_admin()); USING (is_admin());
CREATE POLICY "Admins can delete questions" CREATE POLICY "Admins can delete questions"
ON questions FOR DELETE ON questions FOR DELETE
USING (is_admin()); USING (is_admin());
-- ============================================================================ -- ============================================================================
-- RLS POLICIES: RESPONSES -- RLS POLICIES: RESPONSES
-- ============================================================================ -- ============================================================================
@@ -827,42 +762,33 @@ CREATE POLICY "Users can view couple responses"
USING (user_id = auth.uid() OR couple_id IN ( USING (user_id = auth.uid() OR couple_id IN (
SELECT couple_id FROM profiles WHERE id = auth.uid() SELECT couple_id FROM profiles WHERE id = auth.uid()
)); ));
CREATE POLICY "Users can insert own responses" CREATE POLICY "Users can insert own responses"
ON responses FOR INSERT ON responses FOR INSERT
WITH CHECK (user_id = auth.uid() AND couple_id = get_auth_user_couple_id()); WITH CHECK (user_id = auth.uid() AND couple_id = get_auth_user_couple_id());
CREATE POLICY "Users can update own responses" CREATE POLICY "Users can update own responses"
ON responses FOR UPDATE ON responses FOR UPDATE
USING (user_id = auth.uid()); USING (user_id = auth.uid());
CREATE POLICY "Users can delete own responses" CREATE POLICY "Users can delete own responses"
ON responses FOR DELETE ON responses FOR DELETE
USING (user_id = auth.uid()); USING (user_id = auth.uid());
CREATE POLICY "Super admins can view all responses" CREATE POLICY "Super admins can view all responses"
ON responses FOR SELECT ON responses FOR SELECT
USING (is_super_admin()); USING (is_super_admin());
-- ============================================================================ -- ============================================================================
-- RLS POLICIES: MATCHES -- RLS POLICIES: MATCHES
-- ============================================================================ -- ============================================================================
CREATE POLICY "Users can view couple matches" CREATE POLICY "Users can view couple matches"
ON matches FOR SELECT ON matches FOR SELECT
USING (couple_id = get_auth_user_couple_id()); USING (couple_id = get_auth_user_couple_id());
CREATE POLICY "Users can update match seen status" CREATE POLICY "Users can update match seen status"
ON matches FOR UPDATE ON matches FOR UPDATE
USING (couple_id = get_auth_user_couple_id()); USING (couple_id = get_auth_user_couple_id());
CREATE POLICY "Users can delete own couple matches" CREATE POLICY "Users can delete own couple matches"
ON matches FOR DELETE ON matches FOR DELETE
USING (couple_id = get_auth_user_couple_id()); USING (couple_id = get_auth_user_couple_id());
CREATE POLICY "Super admins can view all matches" CREATE POLICY "Super admins can view all matches"
ON matches FOR SELECT ON matches FOR SELECT
USING (is_super_admin()); USING (is_super_admin());
-- ============================================================================ -- ============================================================================
-- RLS POLICIES: MESSAGES -- RLS POLICIES: MESSAGES
-- ============================================================================ -- ============================================================================
@@ -873,7 +799,6 @@ CREATE POLICY "Users can view messages in their matches"
WHERE m.id = messages.match_id WHERE m.id = messages.match_id
AND m.couple_id IN (SELECT couple_id FROM profiles WHERE id = auth.uid()) AND m.couple_id IN (SELECT couple_id FROM profiles WHERE id = auth.uid())
)); ));
CREATE POLICY "Users can insert messages in their matches" CREATE POLICY "Users can insert messages in their matches"
ON messages FOR INSERT ON messages FOR INSERT
WITH CHECK (auth.uid() = user_id AND EXISTS ( 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 WHERE m.id = messages.match_id
AND m.couple_id IN (SELECT couple_id FROM profiles WHERE id = auth.uid()) AND m.couple_id IN (SELECT couple_id FROM profiles WHERE id = auth.uid())
)); ));
CREATE POLICY "Users can mark messages as read" CREATE POLICY "Users can mark messages as read"
ON messages FOR UPDATE ON messages FOR UPDATE
USING (auth.uid() <> user_id AND EXISTS ( 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()) AND m.couple_id IN (SELECT couple_id FROM profiles WHERE id = auth.uid())
)) ))
WITH CHECK (auth.uid() <> user_id); WITH CHECK (auth.uid() <> user_id);
CREATE POLICY "Users can delete own messages" CREATE POLICY "Users can delete own messages"
ON messages FOR DELETE ON messages FOR DELETE
USING (user_id = auth.uid()); USING (user_id = auth.uid());
CREATE POLICY "Super admins can view all messages" CREATE POLICY "Super admins can view all messages"
ON messages FOR SELECT ON messages FOR SELECT
USING (is_super_admin()); USING (is_super_admin());
-- ============================================================================ -- ============================================================================
-- RLS POLICIES: COUPLE PACKS -- RLS POLICIES: COUPLE PACKS
-- ============================================================================ -- ============================================================================
@@ -908,67 +829,54 @@ CREATE POLICY "Couples can view their own pack settings"
SELECT 1 FROM profiles SELECT 1 FROM profiles
WHERE profiles.id = auth.uid() AND profiles.couple_id = couple_packs.couple_id WHERE profiles.id = auth.uid() AND profiles.couple_id = couple_packs.couple_id
)); ));
CREATE POLICY "Couples can modify their own pack settings" CREATE POLICY "Couples can modify their own pack settings"
ON couple_packs FOR ALL ON couple_packs FOR ALL
USING (EXISTS ( USING (EXISTS (
SELECT 1 FROM profiles SELECT 1 FROM profiles
WHERE profiles.id = auth.uid() AND profiles.couple_id = couple_packs.couple_id WHERE profiles.id = auth.uid() AND profiles.couple_id = couple_packs.couple_id
)); ));
CREATE POLICY "Users can delete own couple pack settings" CREATE POLICY "Users can delete own couple pack settings"
ON couple_packs FOR DELETE ON couple_packs FOR DELETE
USING (EXISTS ( USING (EXISTS (
SELECT 1 FROM profiles SELECT 1 FROM profiles
WHERE profiles.id = auth.uid() AND profiles.couple_id = couple_packs.couple_id WHERE profiles.id = auth.uid() AND profiles.couple_id = couple_packs.couple_id
)); ));
-- ============================================================================ -- ============================================================================
-- RLS POLICIES: FEEDBACK -- RLS POLICIES: FEEDBACK
-- ============================================================================ -- ============================================================================
CREATE POLICY "Users can create feedback" CREATE POLICY "Users can create feedback"
ON feedback FOR INSERT ON feedback FOR INSERT
WITH CHECK (auth.uid() = user_id); WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can view own feedback" CREATE POLICY "Users can view own feedback"
ON feedback FOR SELECT ON feedback FOR SELECT
USING (auth.uid() = user_id); USING (auth.uid() = user_id);
CREATE POLICY "Users can update own pending feedback" CREATE POLICY "Users can update own pending feedback"
ON feedback FOR UPDATE ON feedback FOR UPDATE
USING (auth.uid() = user_id AND status = 'new'); USING (auth.uid() = user_id AND status = 'new');
CREATE POLICY "Super admins can view all feedback" CREATE POLICY "Super admins can view all feedback"
ON feedback FOR SELECT ON feedback FOR SELECT
USING (is_super_admin()); USING (is_super_admin());
CREATE POLICY "Super admins can update all feedback" CREATE POLICY "Super admins can update all feedback"
ON feedback FOR UPDATE ON feedback FOR UPDATE
USING (is_super_admin()); USING (is_super_admin());
-- ============================================================================ -- ============================================================================
-- RLS POLICIES: SUBSCRIPTIONS -- RLS POLICIES: SUBSCRIPTIONS
-- ============================================================================ -- ============================================================================
CREATE POLICY "Users can view own subscriptions" CREATE POLICY "Users can view own subscriptions"
ON subscriptions FOR SELECT ON subscriptions FOR SELECT
USING (auth.uid() = user_id); USING (auth.uid() = user_id);
CREATE POLICY "Super admins can view all subscriptions" CREATE POLICY "Super admins can view all subscriptions"
ON subscriptions FOR SELECT ON subscriptions FOR SELECT
USING (is_super_admin()); USING (is_super_admin());
CREATE POLICY "Super admins can insert subscriptions" CREATE POLICY "Super admins can insert subscriptions"
ON subscriptions FOR INSERT ON subscriptions FOR INSERT
WITH CHECK (is_super_admin()); WITH CHECK (is_super_admin());
CREATE POLICY "Super admins can update subscriptions" CREATE POLICY "Super admins can update subscriptions"
ON subscriptions FOR UPDATE ON subscriptions FOR UPDATE
USING (is_super_admin()); USING (is_super_admin());
CREATE POLICY "Super admins can delete subscriptions" CREATE POLICY "Super admins can delete subscriptions"
ON subscriptions FOR DELETE ON subscriptions FOR DELETE
USING (is_super_admin()); USING (is_super_admin());
-- ============================================================================ -- ============================================================================
-- RLS POLICIES: REVENUECAT WEBHOOK EVENTS -- RLS POLICIES: REVENUECAT WEBHOOK EVENTS
-- ============================================================================ -- ============================================================================
@@ -981,14 +889,12 @@ CREATE POLICY "Super admins can delete subscriptions"
CREATE POLICY "Admin users can read own record" CREATE POLICY "Admin users can read own record"
ON admin_users FOR SELECT ON admin_users FOR SELECT
USING (auth.uid() = user_id); USING (auth.uid() = user_id);
-- ============================================================================ -- ============================================================================
-- RLS POLICIES: AUDIT LOGS -- RLS POLICIES: AUDIT LOGS
-- ============================================================================ -- ============================================================================
CREATE POLICY "Super admins can view audit logs" CREATE POLICY "Super admins can view audit logs"
ON audit_logs FOR SELECT ON audit_logs FOR SELECT
USING (is_super_admin()); USING (is_super_admin());
-- ============================================================================ -- ============================================================================
-- REALTIME SUBSCRIPTIONS -- 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 matches;
ALTER PUBLICATION supabase_realtime ADD TABLE messages; ALTER PUBLICATION supabase_realtime ADD TABLE messages;
ALTER PUBLICATION supabase_realtime ADD TABLE couple_packs; ALTER PUBLICATION supabase_realtime ADD TABLE couple_packs;
-- ============================================================================ -- ============================================================================
-- STORAGE BUCKETS -- STORAGE BUCKETS
-- ============================================================================ -- ============================================================================
INSERT INTO storage.buckets (id, name, public) INSERT INTO storage.buckets (id, name, public)
VALUES ('feedback-screenshots', 'feedback-screenshots', false) VALUES ('feedback-screenshots', 'feedback-screenshots', false)
ON CONFLICT (id) DO NOTHING; ON CONFLICT (id) DO NOTHING;
INSERT INTO storage.buckets (id, name, public) INSERT INTO storage.buckets (id, name, public)
VALUES ('chat-media', 'chat-media', false) VALUES ('chat-media', 'chat-media', false)
ON CONFLICT (id) DO NOTHING; ON CONFLICT (id) DO NOTHING;
-- ============================================================================ -- ============================================================================
-- STORAGE POLICIES -- STORAGE POLICIES
-- ============================================================================ -- ============================================================================
@@ -1019,14 +922,12 @@ CREATE POLICY "Users can upload feedback screenshots"
bucket_id = 'feedback-screenshots' bucket_id = 'feedback-screenshots'
AND (storage.foldername(name))[1] = auth.uid()::text AND (storage.foldername(name))[1] = auth.uid()::text
); );
CREATE POLICY "Users can view own feedback screenshots" CREATE POLICY "Users can view own feedback screenshots"
ON storage.objects FOR SELECT TO authenticated ON storage.objects FOR SELECT TO authenticated
USING ( USING (
bucket_id = 'feedback-screenshots' bucket_id = 'feedback-screenshots'
AND (storage.foldername(name))[1] = auth.uid()::text AND (storage.foldername(name))[1] = auth.uid()::text
); );
-- Chat media policies -- Chat media policies
-- IMPORTANT: Use storage.objects.name (not just "name") to avoid ambiguity with profiles.name -- IMPORTANT: Use storage.objects.name (not just "name") to avoid ambiguity with profiles.name
CREATE POLICY "Users can view chat media in their matches" 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() AND p.id = auth.uid()
) )
); );
CREATE POLICY "Users can upload chat media to their matches" CREATE POLICY "Users can upload chat media to their matches"
ON storage.objects FOR INSERT TO authenticated ON storage.objects FOR INSERT TO authenticated
WITH CHECK ( WITH CHECK (
@@ -1052,7 +952,6 @@ CREATE POLICY "Users can upload chat media to their matches"
AND p.id = auth.uid() AND p.id = auth.uid()
) )
); );
CREATE POLICY "Users can delete their own chat media" CREATE POLICY "Users can delete their own chat media"
ON storage.objects FOR DELETE TO authenticated ON storage.objects FOR DELETE TO authenticated
USING ( USING (
@@ -1064,7 +963,6 @@ CREATE POLICY "Users can delete their own chat media"
AND p.id = auth.uid() AND p.id = auth.uid()
) )
); );
CREATE POLICY "Admins can view all chat media" CREATE POLICY "Admins can view all chat media"
ON storage.objects FOR SELECT TO authenticated ON storage.objects FOR SELECT TO authenticated
USING ( USING (
@@ -4,10 +4,9 @@
-- Fix: Explicitly qualify as storage.objects.name to reference the storage object's file path -- Fix: Explicitly qualify as storage.objects.name to reference the storage object's file path
-- Drop existing broken policies -- 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 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 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 delete their own chat media" ON storage.objects;
-- Recreate with explicit table qualification to avoid column name ambiguity -- Recreate with explicit table qualification to avoid column name ambiguity
CREATE POLICY "Users can upload chat media to their matches" CREATE POLICY "Users can upload chat media to their matches"
ON storage.objects FOR INSERT TO authenticated 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 WHERE (storage.foldername(storage.objects.name))[1] = m.id::text
AND p.id = auth.uid() AND p.id = auth.uid()
) )
); );
CREATE POLICY "Users can view chat media in their matches" CREATE POLICY "Users can view chat media in their matches"
ON storage.objects FOR SELECT TO authenticated ON storage.objects FOR SELECT TO authenticated
USING ( 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 WHERE (storage.foldername(storage.objects.name))[1] = m.id::text
AND p.id = auth.uid() AND p.id = auth.uid()
) )
); );
CREATE POLICY "Users can delete their own chat media" CREATE POLICY "Users can delete their own chat media"
ON storage.objects FOR DELETE TO authenticated ON storage.objects FOR DELETE TO authenticated
USING ( USING (
@@ -43,4 +40,4 @@ CREATE POLICY "Users can delete their own chat media"
WHERE (storage.foldername(storage.objects.name))[1] = m.id::text WHERE (storage.foldername(storage.objects.name))[1] = m.id::text
AND p.id = auth.uid() AND p.id = auth.uid()
) )
); );
@@ -22,17 +22,13 @@ CREATE TABLE IF NOT EXISTS ai_config (
updated_at timestamptz DEFAULT now(), updated_at timestamptz DEFAULT now(),
updated_by uuid REFERENCES auth.users(id) updated_by uuid REFERENCES auth.users(id)
); );
-- Only allow one row in this table (singleton pattern) -- Only allow one row in this table (singleton pattern)
CREATE UNIQUE INDEX IF NOT EXISTS ai_config_singleton ON ai_config ((true)); CREATE UNIQUE INDEX IF NOT EXISTS ai_config_singleton ON ai_config ((true));
-- Insert default config row -- Insert default config row
INSERT INTO ai_config (id) VALUES (gen_random_uuid()) INSERT INTO ai_config (id) VALUES (gen_random_uuid())
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- Enable RLS -- Enable RLS
ALTER TABLE ai_config ENABLE ROW LEVEL SECURITY; ALTER TABLE ai_config ENABLE ROW LEVEL SECURITY;
-- Only super admins can read/write (checked via admin_users table) -- Only super admins can read/write (checked via admin_users table)
CREATE POLICY "Super admins can read ai_config" CREATE POLICY "Super admins can read ai_config"
ON ai_config FOR SELECT ON ai_config FOR SELECT
@@ -43,7 +39,6 @@ CREATE POLICY "Super admins can read ai_config"
AND admin_users.role = 'super_admin' AND admin_users.role = 'super_admin'
) )
); );
CREATE POLICY "Super admins can update ai_config" CREATE POLICY "Super admins can update ai_config"
ON ai_config FOR UPDATE ON ai_config FOR UPDATE
USING ( USING (
@@ -53,7 +48,6 @@ CREATE POLICY "Super admins can update ai_config"
AND admin_users.role = 'super_admin' AND admin_users.role = 'super_admin'
) )
); );
-- Create function to update the updated_at timestamp -- Create function to update the updated_at timestamp
CREATE OR REPLACE FUNCTION update_ai_config_timestamp() CREATE OR REPLACE FUNCTION update_ai_config_timestamp()
RETURNS TRIGGER AS $$ RETURNS TRIGGER AS $$
@@ -63,13 +57,11 @@ BEGIN
RETURN NEW; RETURN NEW;
END; END;
$$ LANGUAGE plpgsql SECURITY DEFINER; $$ LANGUAGE plpgsql SECURITY DEFINER;
-- Create trigger to auto-update timestamp -- Create trigger to auto-update timestamp
DROP TRIGGER IF EXISTS ai_config_updated_at ON ai_config; DROP TRIGGER IF EXISTS ai_config_updated_at ON ai_config;
CREATE TRIGGER ai_config_updated_at CREATE TRIGGER ai_config_updated_at
BEFORE UPDATE ON ai_config BEFORE UPDATE ON ai_config
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION update_ai_config_timestamp(); EXECUTE FUNCTION update_ai_config_timestamp();
-- Add comment for documentation -- Add comment for documentation
COMMENT ON TABLE ai_config IS 'Singleton table storing AI configuration for admin portal. Only super admins can access.'; COMMENT ON TABLE ai_config IS 'Singleton table storing AI configuration for admin portal. Only super admins can access.';
@@ -5,17 +5,14 @@
-- Add the new column (migrate existing single generator model to array format) -- Add the new column (migrate existing single generator model to array format)
ALTER TABLE ai_config ALTER TABLE ai_config
ADD COLUMN IF NOT EXISTS council_generators jsonb; ADD COLUMN IF NOT EXISTS council_generators jsonb;
-- Set default value based on existing council_generator_model or default -- Set default value based on existing council_generator_model or default
UPDATE ai_config UPDATE ai_config
SET council_generators = jsonb_build_array( SET council_generators = jsonb_build_array(
jsonb_build_object('model', COALESCE(council_generator_model, 'anthropic/claude-3.5-sonnet')) jsonb_build_object('model', COALESCE(council_generator_model, 'anthropic/claude-3.5-sonnet'))
) )
WHERE council_generators IS NULL; WHERE council_generators IS NULL;
-- Set the default for new rows -- Set the default for new rows
ALTER TABLE ai_config ALTER TABLE ai_config
ALTER COLUMN council_generators SET DEFAULT '[{"model": "anthropic/claude-3.5-sonnet"}]'::jsonb; ALTER COLUMN council_generators SET DEFAULT '[{"model": "anthropic/claude-3.5-sonnet"}]'::jsonb;
-- Add comment for documentation -- 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.'; 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.';
@@ -12,37 +12,29 @@ CREATE TABLE IF NOT EXISTS app_config (
updated_at timestamptz DEFAULT now(), updated_at timestamptz DEFAULT now(),
updated_by uuid REFERENCES auth.users(id) updated_by uuid REFERENCES auth.users(id)
); );
-- Only allow one row in this table (singleton pattern) -- Only allow one row in this table (singleton pattern)
CREATE UNIQUE INDEX IF NOT EXISTS app_config_singleton ON app_config ((true)); CREATE UNIQUE INDEX IF NOT EXISTS app_config_singleton ON app_config ((true));
-- Insert default config row -- Insert default config row
INSERT INTO app_config (id) VALUES (gen_random_uuid()) INSERT INTO app_config (id) VALUES (gen_random_uuid())
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- Enable RLS -- Enable RLS
ALTER TABLE app_config ENABLE ROW LEVEL SECURITY; ALTER TABLE app_config ENABLE ROW LEVEL SECURITY;
-- All authenticated users can read (needed for mobile app) -- All authenticated users can read (needed for mobile app)
CREATE POLICY "Authenticated users can read app_config" CREATE POLICY "Authenticated users can read app_config"
ON app_config FOR SELECT ON app_config FOR SELECT
USING (auth.role() = 'authenticated'); USING (auth.role() = 'authenticated');
-- Only super admins can update -- Only super admins can update
CREATE POLICY "Super admins can update app_config" CREATE POLICY "Super admins can update app_config"
ON app_config FOR UPDATE ON app_config FOR UPDATE
USING (is_super_admin()); USING (is_super_admin());
-- Create trigger to auto-update timestamp (reuses existing function) -- Create trigger to auto-update timestamp (reuses existing function)
CREATE TRIGGER app_config_updated_at CREATE TRIGGER app_config_updated_at
BEFORE UPDATE ON app_config BEFORE UPDATE ON app_config
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION update_updated_at(); EXECUTE FUNCTION update_updated_at();
-- Add comment for documentation -- 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 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.'; 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 -- RPC FUNCTION: get_answer_gap_status
-- Returns the number of questions user has answered that partner hasn't, -- 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); RETURN QUERY SELECT v_gap_count, v_threshold, (v_gap_count >= v_threshold);
END; 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.'; COMMENT ON FUNCTION get_answer_gap_status() IS 'Returns answer gap status for the current user. Used by mobile app to prevent answer spam.';
@@ -163,5 +163,4 @@ BEGIN
); );
END; 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.'; 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.';
@@ -2,20 +2,15 @@
-- Videos expire 30 days after being viewed by the recipient -- Videos expire 30 days after being viewed by the recipient
-- Add media type to distinguish images from videos -- 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) -- 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 -- 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 -- 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 -- 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 cleanup function that handles both DB updates and storage deletion tracking
CREATE OR REPLACE FUNCTION cleanup_expired_videos() CREATE OR REPLACE FUNCTION cleanup_expired_videos()
RETURNS TABLE(deleted_count int, deleted_paths text[]) AS $$ RETURNS TABLE(deleted_count int, deleted_paths text[]) AS $$
@@ -48,12 +43,11 @@ BEGIN
RETURN QUERY SELECT count, COALESCE(expired_paths, ARRAY[]::text[]); RETURN QUERY SELECT count, COALESCE(expired_paths, ARRAY[]::text[]);
END; END;
$$ LANGUAGE plpgsql SECURITY DEFINER; $$ LANGUAGE plpgsql SECURITY DEFINER;
-- Schedule daily cleanup at 3 AM UTC -- Schedule daily cleanup at 3 AM UTC
-- The database function marks videos as expired, edge function handles storage deletion -- The database function marks videos as expired, edge function handles storage deletion
SELECT cron.schedule( SELECT cron.schedule(
'cleanup-expired-videos', 'cleanup-expired-videos',
'0 3 * * *', '0 3 * * *',
'SELECT * FROM cleanup_expired_videos()' 'SELECT * FROM cleanup_expired_videos()'
); );
@@ -30,29 +30,24 @@ BEGIN
RETURN v_permissions @> jsonb_build_array(check_permission); RETURN v_permissions @> jsonb_build_array(check_permission);
END; END;
$$; $$;
-- Update RLS policies to use permissions -- Update RLS policies to use permissions
-- Profiles -- Profiles
CREATE POLICY "Admins with view_users permission can view all profiles" CREATE POLICY "Admins with view_users permission can view all profiles"
ON profiles FOR SELECT ON profiles FOR SELECT
USING (has_permission('view_users')); USING (has_permission('view_users'));
-- Messages -- Messages
CREATE POLICY "Admins with view_chats permission can view all messages" CREATE POLICY "Admins with view_chats permission can view all messages"
ON messages FOR SELECT ON messages FOR SELECT
USING (has_permission('view_chats')); USING (has_permission('view_chats'));
-- Responses -- Responses
CREATE POLICY "Admins with view_responses permission can view all responses" CREATE POLICY "Admins with view_responses permission can view all responses"
ON responses FOR SELECT ON responses FOR SELECT
USING (has_permission('view_responses')); USING (has_permission('view_responses'));
-- Matches -- Matches
CREATE POLICY "Admins with view_matches permission can view all matches" CREATE POLICY "Admins with view_matches permission can view all matches"
ON matches FOR SELECT ON matches FOR SELECT
USING (has_permission('view_matches')); USING (has_permission('view_matches'));
-- Audit Logs -- Audit Logs
CREATE POLICY "Admins with view_audit_logs permission can view audit logs" CREATE POLICY "Admins with view_audit_logs permission can view audit logs"
ON audit_logs FOR SELECT ON audit_logs FOR SELECT
@@ -3,7 +3,6 @@
CREATE POLICY "Admins with manage_feedback permission can view all feedback" CREATE POLICY "Admins with manage_feedback permission can view all feedback"
ON feedback FOR SELECT ON feedback FOR SELECT
USING (has_permission('manage_feedback')); USING (has_permission('manage_feedback'));
CREATE POLICY "Admins with manage_feedback permission can update all feedback" CREATE POLICY "Admins with manage_feedback permission can update all feedback"
ON feedback FOR UPDATE ON feedback FOR UPDATE
USING (has_permission('manage_feedback')); USING (has_permission('manage_feedback'));
@@ -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" CREATE POLICY "Admins with manage_codes permission can view redemption codes"
ON redemption_codes FOR SELECT 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" CREATE POLICY "Admins with manage_codes permission can insert redemption codes"
ON redemption_codes FOR INSERT 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" CREATE POLICY "Admins with manage_codes permission can update redemption codes"
ON redemption_codes FOR UPDATE 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" CREATE POLICY "Admins with manage_codes permission can delete redemption codes"
ON redemption_codes FOR DELETE 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;
@@ -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 ( CREATE TABLE feature_interests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
feature_name TEXT NOT NULL, feature_name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
UNIQUE (user_id, feature_name)
-- 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); 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; ALTER TABLE feature_interests ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view their own interests" -- Users can insert their own interest
ON feature_interests FOR SELECT
USING (auth.uid() = user_id);
CREATE POLICY "Users can express interest in features" CREATE POLICY "Users can express interest in features"
ON feature_interests FOR INSERT ON feature_interests
WITH CHECK (auth.uid() = user_id); 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" CREATE POLICY "Users can remove their interest"
ON feature_interests FOR DELETE ON feature_interests
USING (auth.uid() = user_id); 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;;
@@ -25,5 +25,4 @@ BEGIN
ORDER BY opt_in_count DESC, fi.feature_name ASC; ORDER BY opt_in_count DESC, fi.feature_name ASC;
END; END;
$$; $$;
GRANT EXECUTE ON FUNCTION public.get_feature_interest_counts() TO authenticated; GRANT EXECUTE ON FUNCTION public.get_feature_interest_counts() TO authenticated;
@@ -3,20 +3,15 @@
-- Version indicator: 1 = plaintext (legacy), 2 = E2EE -- Version indicator: 1 = plaintext (legacy), 2 = E2EE
ALTER TABLE messages ADD COLUMN IF NOT EXISTS version INTEGER DEFAULT 1; ALTER TABLE messages ADD COLUMN IF NOT EXISTS version INTEGER DEFAULT 1;
-- Encrypted message content (base64 encoded ciphertext) -- Encrypted message content (base64 encoded ciphertext)
ALTER TABLE messages ADD COLUMN IF NOT EXISTS encrypted_content TEXT; ALTER TABLE messages ADD COLUMN IF NOT EXISTS encrypted_content TEXT;
-- Initialization vector for AES-GCM (base64 encoded, 12 bytes) -- Initialization vector for AES-GCM (base64 encoded, 12 bytes)
ALTER TABLE messages ADD COLUMN IF NOT EXISTS encryption_iv TEXT; ALTER TABLE messages ADD COLUMN IF NOT EXISTS encryption_iv TEXT;
-- Triple-wrapped encryption keys and metadata -- Triple-wrapped encryption keys and metadata
ALTER TABLE messages ADD COLUMN IF NOT EXISTS keys_metadata JSONB; ALTER TABLE messages ADD COLUMN IF NOT EXISTS keys_metadata JSONB;
-- Add comment for documentation -- Add comment for documentation
COMMENT ON COLUMN messages.version IS COMMENT ON COLUMN messages.version IS
'Encryption version: 1 = plaintext (legacy), 2 = E2EE with triple-wrapped keys'; 'Encryption version: 1 = plaintext (legacy), 2 = E2EE with triple-wrapped keys';
-- Constraint: v2 messages must have encryption fields -- Constraint: v2 messages must have encryption fields
DO $$ DO $$
BEGIN BEGIN
@@ -3,10 +3,8 @@
-- RSA public key in JWK format (JSON Web Key) -- RSA public key in JWK format (JSON Web Key)
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS public_key_jwk JSONB; ALTER TABLE profiles ADD COLUMN IF NOT EXISTS public_key_jwk JSONB;
-- Index for efficient lookups when encrypting for partner -- Index for efficient lookups when encrypting for partner
CREATE INDEX IF NOT EXISTS idx_profiles_couple_id_public_key ON profiles(couple_id) CREATE INDEX IF NOT EXISTS idx_profiles_couple_id_public_key ON profiles(couple_id)
WHERE public_key_jwk IS NOT NULL; WHERE public_key_jwk IS NOT NULL;
COMMENT ON COLUMN profiles.public_key_jwk IS COMMENT ON COLUMN profiles.public_key_jwk IS
'RSA-2048 public key in JWK format for E2EE. Private key stored on device only.'; 'RSA-2048 public key in JWK format for E2EE. Private key stored on device only.';
@@ -9,14 +9,11 @@ CREATE TABLE IF NOT EXISTS master_keys (
rotated_at TIMESTAMPTZ, rotated_at TIMESTAMPTZ,
is_active BOOLEAN DEFAULT true is_active BOOLEAN DEFAULT true
); );
-- Add table comment -- Add table comment
COMMENT ON TABLE master_keys IS COMMENT ON TABLE master_keys IS
'Stores admin RSA public keys for E2EE moderation access. Private key stored securely in environment variables.'; 'Stores admin RSA public keys for E2EE moderation access. Private key stored securely in environment variables.';
-- Enable RLS -- Enable RLS
ALTER TABLE master_keys ENABLE ROW LEVEL SECURITY; ALTER TABLE master_keys ENABLE ROW LEVEL SECURITY;
-- Super admins can view master keys -- Super admins can view master keys
DO $$ DO $$
BEGIN BEGIN
@@ -29,7 +26,6 @@ BEGIN
USING (is_super_admin(auth.uid())); USING (is_super_admin(auth.uid()));
END IF; END IF;
END $$; END $$;
-- Super admins can insert master keys -- Super admins can insert master keys
DO $$ DO $$
BEGIN BEGIN
@@ -42,7 +38,6 @@ BEGIN
WITH CHECK (is_super_admin(auth.uid())); WITH CHECK (is_super_admin(auth.uid()));
END IF; END IF;
END $$; END $$;
-- Super admins can update master keys -- Super admins can update master keys
DO $$ DO $$
BEGIN BEGIN
@@ -55,7 +50,6 @@ BEGIN
USING (is_super_admin(auth.uid())); USING (is_super_admin(auth.uid()));
END IF; END IF;
END $$; END $$;
-- Allow all authenticated users to read active master key public keys (needed for encryption) -- Allow all authenticated users to read active master key public keys (needed for encryption)
DO $$ DO $$
BEGIN BEGIN
@@ -1,8 +1,7 @@
-- Update the content_or_media constraint to allow E2EE messages -- Update the content_or_media constraint to allow E2EE messages
-- E2EE messages have encrypted_content instead of content -- 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 ( ALTER TABLE public.messages ADD CONSTRAINT content_or_media CHECK (
-- v1 (plaintext): must have content or media_path -- v1 (plaintext): must have content or media_path
(version = 1 AND (content IS NOT NULL OR media_path IS NOT NULL)) (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 OR
-- Legacy messages without version: require content or media_path -- Legacy messages without version: require content or media_path
(version IS NULL AND (content IS NOT NULL OR media_path IS NOT NULL)) (version IS NULL AND (content IS NOT NULL OR media_path IS NOT NULL))
); );
@@ -2,7 +2,6 @@
ALTER TABLE messages ALTER TABLE messages
ADD COLUMN moderation_status text DEFAULT 'unmoderated' CHECK (moderation_status IN ('unmoderated', 'safe', 'flagged')), ADD COLUMN moderation_status text DEFAULT 'unmoderated' CHECK (moderation_status IN ('unmoderated', 'safe', 'flagged')),
ADD COLUMN flag_reason text; ADD COLUMN flag_reason text;
-- Add classifier configuration to ai_config table -- Add classifier configuration to ai_config table
ALTER TABLE ai_config ALTER TABLE ai_config
ADD COLUMN classifier_enabled boolean DEFAULT true, 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"}. If the content is illegal/dangerous, respond with {"status": "flagged", "reason": "Brief explanation"}.
Response must be valid JSON.'; Response must be valid JSON.';
-- Function to trigger classification -- Function to trigger classification
CREATE OR REPLACE FUNCTION trigger_classify_message() CREATE OR REPLACE FUNCTION trigger_classify_message()
RETURNS TRIGGER RETURNS TRIGGER
@@ -54,13 +52,11 @@ BEGIN
RETURN NEW; RETURN NEW;
END; END;
$$; $$;
-- Create trigger on messages table -- Create trigger on messages table
CREATE TRIGGER on_message_created_classify CREATE TRIGGER on_message_created_classify
AFTER INSERT ON messages AFTER INSERT ON messages
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION trigger_classify_message(); EXECUTE FUNCTION trigger_classify_message();
-- Add comments -- Add comments
COMMENT ON COLUMN messages.moderation_status IS 'Content moderation status: unmoderated, safe, or flagged.'; 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).'; COMMENT ON COLUMN messages.flag_reason IS 'Reason for flagging the content (if status is flagged).';
@@ -1,9 +1,7 @@
-- Add category column to messages table -- Add category column to messages table
ALTER TABLE messages ALTER TABLE messages
ADD COLUMN category text; ADD COLUMN category text;
COMMENT ON COLUMN messages.category IS 'Content category: Neutral, Romantic, Playful, Explicit, etc.'; COMMENT ON COLUMN messages.category IS 'Content category: Neutral, Romantic, Playful, Explicit, etc.';
-- Update the default classifier prompt in ai_config -- Update the default classifier prompt in ai_config
UPDATE 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. 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.
@@ -1,6 +1,5 @@
-- Add deleted_at column to messages table for "delete for everyone" -- 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 message_deletions table for "delete for self" (per-user deletions)
CREATE TABLE message_deletions ( CREATE TABLE message_deletions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 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, user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
deleted_at TIMESTAMPTZ DEFAULT now(), deleted_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(message_id, user_id) UNIQUE(message_id, user_id)
); );
-- Index for efficient lookup when filtering messages -- Index for efficient lookup when filtering messages
CREATE INDEX idx_message_deletions_user_message ON message_deletions(user_id, message_id); 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_messages_deleted_at ON messages(deleted_at) WHERE deleted_at IS NOT NULL;
-- Enable RLS on message_deletions -- 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 -- RLS Policies for message_deletions
-- Users can view their own deletions (needed for filtering) -- Users can view their own deletions (needed for filtering)
CREATE POLICY "Users can view own deletions" CREATE POLICY "Users can view own deletions"
ON message_deletions FOR SELECT 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) -- Users can insert their own deletions (for messages in their matches)
CREATE POLICY "Users can delete messages for self" CREATE POLICY "Users can delete messages for self"
ON message_deletions FOR INSERT ON message_deletions FOR INSERT
@@ -36,17 +31,14 @@ CREATE POLICY "Users can delete messages for self"
WHERE m.id = message_deletions.message_id WHERE m.id = message_deletions.message_id
AND p.id = auth.uid() AND p.id = auth.uid()
) )
); );
-- Update messages RLS policy to allow authors to set deleted_at -- Update messages RLS policy to allow authors to set deleted_at
CREATE POLICY "Authors can soft delete messages for everyone" CREATE POLICY "Authors can soft delete messages for everyone"
ON messages FOR UPDATE ON messages FOR UPDATE
USING (user_id = auth.uid()) USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid()); WITH CHECK (user_id = auth.uid());
-- Add to realtime for live updates -- 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 -- 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 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 TABLE message_deletions IS 'Per-user message deletions for "delete for self" functionality. Message still visible to other user.';
@@ -1,6 +1,5 @@
-- Create enum for report reasons -- 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 message_reports table
CREATE TABLE message_reports ( CREATE TABLE message_reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -14,23 +13,19 @@ CREATE TABLE message_reports (
-- Prevent duplicate reports from same user -- Prevent duplicate reports from same user
UNIQUE(message_id, reporter_id) UNIQUE(message_id, reporter_id)
); );
-- Indexes for efficient queries -- Indexes for efficient queries
CREATE INDEX idx_message_reports_status ON message_reports(status); 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_created ON message_reports(created_at DESC);
CREATE INDEX idx_message_reports_message ON message_reports(message_id); CREATE INDEX idx_message_reports_message ON message_reports(message_id);
-- Enable RLS -- Enable RLS
ALTER TABLE message_reports ENABLE ROW LEVEL SECURITY; ALTER TABLE message_reports ENABLE ROW LEVEL SECURITY;
-- RLS Policies -- RLS Policies
-- Users can view their own reports -- Users can view their own reports
CREATE POLICY "Users can view own reports" CREATE POLICY "Users can view own reports"
ON message_reports FOR SELECT 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) -- Users can create reports for messages in their couple's matches (but NOT their own messages)
CREATE POLICY "Users can report partner messages" CREATE POLICY "Users can report partner messages"
ON message_reports FOR INSERT ON message_reports FOR INSERT
@@ -44,22 +39,18 @@ CREATE POLICY "Users can report partner messages"
AND p.id = auth.uid() AND p.id = auth.uid()
AND m.user_id != auth.uid() -- Cannot report own messages AND m.user_id != auth.uid() -- Cannot report own messages
) )
); );
-- Admins can view all reports -- Admins can view all reports
CREATE POLICY "Admins can view all reports" CREATE POLICY "Admins can view all reports"
ON message_reports FOR SELECT ON message_reports FOR SELECT
USING (is_admin()); USING (is_admin());
-- Admins can update reports (for review status) -- Admins can update reports (for review status)
CREATE POLICY "Admins can update reports" CREATE POLICY "Admins can update reports"
ON message_reports FOR UPDATE ON message_reports FOR UPDATE
USING (is_admin()); USING (is_admin());
-- Add to realtime for live updates in 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 -- Comments
COMMENT ON TABLE message_reports IS 'User-submitted reports for messages. Separate from AI moderation (moderation_status on messages table).'; 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.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 COLUMN message_reports.reason IS 'User-selected reason for reporting: harassment, spam, inappropriate_content, or other.';
@@ -2,4 +2,4 @@
-- This allows Supabase to join with profiles table in admin queries -- This allows Supabase to join with profiles table in admin queries
ALTER TABLE message_reports ALTER TABLE message_reports
ADD CONSTRAINT message_reports_reporter_profile_fk 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;
@@ -3,19 +3,16 @@
ALTER TABLE profiles ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS max_intensity INTEGER DEFAULT 2 ADD COLUMN IF NOT EXISTS max_intensity INTEGER DEFAULT 2
CHECK (max_intensity >= 1 AND max_intensity <= 5); CHECK (max_intensity >= 1 AND max_intensity <= 5);
UPDATE profiles UPDATE profiles
SET max_intensity = CASE SET max_intensity = CASE
WHEN show_explicit_content IS TRUE THEN 5 WHEN show_explicit_content IS TRUE THEN 5
ELSE 2 ELSE 2
END END
WHERE max_intensity IS NULL; WHERE max_intensity IS NULL;
ALTER TABLE question_packs ALTER TABLE question_packs
ADD COLUMN IF NOT EXISTS min_intensity INTEGER, ADD COLUMN IF NOT EXISTS min_intensity INTEGER,
ADD COLUMN IF NOT EXISTS max_intensity INTEGER, ADD COLUMN IF NOT EXISTS max_intensity INTEGER,
ADD COLUMN IF NOT EXISTS avg_intensity NUMERIC(3,2); ADD COLUMN IF NOT EXISTS avg_intensity NUMERIC(3,2);
CREATE OR REPLACE FUNCTION update_question_pack_intensity_stats(target_pack_id UUID) CREATE OR REPLACE FUNCTION update_question_pack_intensity_stats(target_pack_id UUID)
RETURNS void RETURNS void
LANGUAGE plpgsql LANGUAGE plpgsql
@@ -46,7 +43,6 @@ BEGIN
END IF; END IF;
END; END;
$$; $$;
CREATE OR REPLACE FUNCTION handle_question_intensity_change() CREATE OR REPLACE FUNCTION handle_question_intensity_change()
RETURNS TRIGGER RETURNS TRIGGER
LANGUAGE plpgsql LANGUAGE plpgsql
@@ -66,13 +62,10 @@ BEGIN
RETURN NEW; RETURN NEW;
END; END;
$$; $$;
DROP TRIGGER IF EXISTS question_pack_intensity_stats ON questions; DROP TRIGGER IF EXISTS question_pack_intensity_stats ON questions;
CREATE TRIGGER question_pack_intensity_stats CREATE TRIGGER question_pack_intensity_stats
AFTER INSERT OR UPDATE OR DELETE ON questions AFTER INSERT OR UPDATE OR DELETE ON questions
FOR EACH ROW EXECUTE FUNCTION handle_question_intensity_change(); FOR EACH ROW EXECUTE FUNCTION handle_question_intensity_change();
UPDATE question_packs qp UPDATE question_packs qp
SET min_intensity = stats.min_intensity, SET min_intensity = stats.min_intensity,
max_intensity = stats.max_intensity, max_intensity = stats.max_intensity,
@@ -169,5 +169,4 @@ BEGIN
); );
END; 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.'; 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.';
@@ -1,14 +1,12 @@
-- Add admin_username column to audit_logs table -- Add admin_username column to audit_logs table
ALTER TABLE audit_logs ALTER TABLE audit_logs
ADD COLUMN admin_username TEXT; ADD COLUMN admin_username TEXT;
-- Backfill existing audit logs with admin names from profiles -- Backfill existing audit logs with admin names from profiles
UPDATE audit_logs UPDATE audit_logs
SET admin_username = COALESCE(p.name, p.email, 'Unknown') SET admin_username = COALESCE(p.name, p.email, 'Unknown')
FROM profiles p FROM profiles p
WHERE audit_logs.admin_user_id = p.id WHERE audit_logs.admin_user_id = p.id
AND audit_logs.admin_username IS NULL; AND audit_logs.admin_username IS NULL;
-- Update the log_admin_action function to populate admin_username from profiles -- Update the log_admin_action function to populate admin_username from profiles
CREATE OR REPLACE FUNCTION log_admin_action( CREATE OR REPLACE FUNCTION log_admin_action(
p_table_name TEXT, p_table_name TEXT,
@@ -13,7 +13,6 @@ CREATE TYPE dare_status AS ENUM (
'declined', -- Recipient declined 'declined', -- Recipient declined
'cancelled' -- Sender cancelled before completion 'cancelled' -- Sender cancelled before completion
); );
-- ============================================================================ -- ============================================================================
-- TABLES -- TABLES
-- ============================================================================ -- ============================================================================
@@ -34,17 +33,14 @@ CREATE TABLE dare_packs (
avg_intensity NUMERIC(3,2), avg_intensity NUMERIC(3,2),
created_at TIMESTAMPTZ NOT NULL DEFAULT now() created_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
COMMENT ON TABLE dare_packs IS 'Collections of dares organized by theme or intensity level'; 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.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.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'; COMMENT ON COLUMN dare_packs.avg_intensity IS 'Auto-calculated average intensity from dares in this pack';
-- Create indexes for dare_packs -- Create indexes for dare_packs
CREATE INDEX idx_dare_packs_sort_order ON dare_packs(sort_order); 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_visibility ON dare_packs(is_public, is_premium);
CREATE INDEX idx_dare_packs_category ON dare_packs(category_id); CREATE INDEX idx_dare_packs_category ON dare_packs(category_id);
-- Dares table (individual dare content) -- Dares table (individual dare content)
CREATE TABLE dares ( CREATE TABLE dares (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -54,15 +50,12 @@ CREATE TABLE dares (
suggested_duration_hours INTEGER, suggested_duration_hours INTEGER,
created_at TIMESTAMPTZ NOT NULL DEFAULT now() created_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
COMMENT ON TABLE dares IS 'Individual dares within dare packs'; 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.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)'; COMMENT ON COLUMN dares.suggested_duration_hours IS 'Suggested time to complete the dare in hours (null = no suggestion)';
-- Create indexes for dares -- Create indexes for dares
CREATE INDEX idx_dares_pack_id ON dares(pack_id); CREATE INDEX idx_dares_pack_id ON dares(pack_id);
CREATE INDEX idx_dares_intensity ON dares(intensity); CREATE INDEX idx_dares_intensity ON dares(intensity);
-- Sent dares table (tracks dare instances between partners) -- Sent dares table (tracks dare instances between partners)
CREATE TABLE sent_dares ( CREATE TABLE sent_dares (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -81,21 +74,18 @@ CREATE TABLE sent_dares (
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT dare_or_custom CHECK (dare_id IS NOT NULL OR custom_dare_text IS NOT NULL) 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 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.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_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.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.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'; COMMENT ON COLUMN sent_dares.sender_notes IS 'Optional message from sender when sending the dare';
-- Create indexes for sent_dares -- Create indexes for sent_dares
CREATE INDEX idx_sent_dares_couple_id ON sent_dares(couple_id); 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_sender_id ON sent_dares(sender_id);
CREATE INDEX idx_sent_dares_recipient_id ON sent_dares(recipient_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_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'; 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) -- Dare messages table (chat for each dare)
CREATE TABLE dare_messages ( CREATE TABLE dare_messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -105,13 +95,10 @@ CREATE TABLE dare_messages (
read_at TIMESTAMPTZ, read_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now() created_at TIMESTAMPTZ NOT NULL DEFAULT now()
); );
COMMENT ON TABLE dare_messages IS 'Chat messages associated with each sent dare'; COMMENT ON TABLE dare_messages IS 'Chat messages associated with each sent dare';
-- Create indexes for dare_messages -- Create indexes for dare_messages
CREATE INDEX idx_dare_messages_sent_dare_id ON dare_messages(sent_dare_id); 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); CREATE INDEX idx_dare_messages_created_at ON dare_messages(sent_dare_id, created_at);
-- ============================================================================ -- ============================================================================
-- TRIGGERS -- TRIGGERS
-- ============================================================================ -- ============================================================================
@@ -136,12 +123,10 @@ BEGIN
RETURN COALESCE(NEW, OLD); RETURN COALESCE(NEW, OLD);
END; END;
$$ LANGUAGE plpgsql; $$ LANGUAGE plpgsql;
-- Create trigger on dares table -- Create trigger on dares table
CREATE TRIGGER dare_intensity_stats_trigger CREATE TRIGGER dare_intensity_stats_trigger
AFTER INSERT OR UPDATE OR DELETE ON dares AFTER INSERT OR UPDATE OR DELETE ON dares
FOR EACH ROW EXECUTE FUNCTION update_dare_pack_intensity_stats(); FOR EACH ROW EXECUTE FUNCTION update_dare_pack_intensity_stats();
-- ============================================================================ -- ============================================================================
-- STATS FUNCTIONS -- STATS FUNCTIONS
-- ============================================================================ -- ============================================================================
@@ -163,7 +148,6 @@ BEGIN
(SELECT COUNT(*) FROM sent_dares WHERE recipient_id = p_user_id AND status = 'completed')::BIGINT; (SELECT COUNT(*) FROM sent_dares WHERE recipient_id = p_user_id AND status = 'completed')::BIGINT;
END; END;
$$ LANGUAGE plpgsql SECURITY DEFINER; $$ LANGUAGE plpgsql SECURITY DEFINER;
-- Function to get dare stats for a couple -- Function to get dare stats for a couple
CREATE OR REPLACE FUNCTION get_couple_dare_stats(p_couple_id UUID) CREATE OR REPLACE FUNCTION get_couple_dare_stats(p_couple_id UUID)
RETURNS TABLE ( RETURNS TABLE (
@@ -179,7 +163,6 @@ BEGIN
(SELECT COUNT(*) FROM sent_dares WHERE couple_id = p_couple_id AND status = 'active')::BIGINT; (SELECT COUNT(*) FROM sent_dares WHERE couple_id = p_couple_id AND status = 'active')::BIGINT;
END; END;
$$ LANGUAGE plpgsql SECURITY DEFINER; $$ LANGUAGE plpgsql SECURITY DEFINER;
-- ============================================================================ -- ============================================================================
-- ROW LEVEL SECURITY -- ROW LEVEL SECURITY
-- ============================================================================ -- ============================================================================
@@ -189,7 +172,6 @@ ALTER TABLE dare_packs ENABLE ROW LEVEL SECURITY;
ALTER TABLE dares ENABLE ROW LEVEL SECURITY; ALTER TABLE dares ENABLE ROW LEVEL SECURITY;
ALTER TABLE sent_dares ENABLE ROW LEVEL SECURITY; ALTER TABLE sent_dares ENABLE ROW LEVEL SECURITY;
ALTER TABLE dare_messages ENABLE ROW LEVEL SECURITY; ALTER TABLE dare_messages ENABLE ROW LEVEL SECURITY;
-- ------------------------- -- -------------------------
-- dare_packs policies -- dare_packs policies
-- ------------------------- -- -------------------------
@@ -198,7 +180,6 @@ ALTER TABLE dare_messages ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Public dare packs are viewable by everyone" CREATE POLICY "Public dare packs are viewable by everyone"
ON dare_packs FOR SELECT ON dare_packs FOR SELECT
USING (is_public = true); USING (is_public = true);
-- Admins can view all dare packs -- Admins can view all dare packs
CREATE POLICY "Admins can view all dare packs" CREATE POLICY "Admins can view all dare packs"
ON dare_packs FOR SELECT ON dare_packs FOR SELECT
@@ -207,7 +188,6 @@ USING (
SELECT 1 FROM admin_users WHERE user_id = auth.uid() SELECT 1 FROM admin_users WHERE user_id = auth.uid()
) )
); );
-- Admins can insert dare packs -- Admins can insert dare packs
CREATE POLICY "Admins can insert dare packs" CREATE POLICY "Admins can insert dare packs"
ON dare_packs FOR INSERT ON dare_packs FOR INSERT
@@ -216,7 +196,6 @@ WITH CHECK (
SELECT 1 FROM admin_users WHERE user_id = auth.uid() SELECT 1 FROM admin_users WHERE user_id = auth.uid()
) )
); );
-- Admins can update dare packs -- Admins can update dare packs
CREATE POLICY "Admins can update dare packs" CREATE POLICY "Admins can update dare packs"
ON dare_packs FOR UPDATE ON dare_packs FOR UPDATE
@@ -225,7 +204,6 @@ USING (
SELECT 1 FROM admin_users WHERE user_id = auth.uid() SELECT 1 FROM admin_users WHERE user_id = auth.uid()
) )
); );
-- Admins can delete dare packs -- Admins can delete dare packs
CREATE POLICY "Admins can delete dare packs" CREATE POLICY "Admins can delete dare packs"
ON dare_packs FOR DELETE ON dare_packs FOR DELETE
@@ -234,7 +212,6 @@ USING (
SELECT 1 FROM admin_users WHERE user_id = auth.uid() SELECT 1 FROM admin_users WHERE user_id = auth.uid()
) )
); );
-- ------------------------- -- -------------------------
-- dares policies -- dares policies
-- ------------------------- -- -------------------------
@@ -253,7 +230,6 @@ USING (
) )
) )
); );
-- Admins can view all dares -- Admins can view all dares
CREATE POLICY "Admins can view all dares" CREATE POLICY "Admins can view all dares"
ON dares FOR SELECT ON dares FOR SELECT
@@ -262,7 +238,6 @@ USING (
SELECT 1 FROM admin_users WHERE user_id = auth.uid() SELECT 1 FROM admin_users WHERE user_id = auth.uid()
) )
); );
-- Admins can insert dares -- Admins can insert dares
CREATE POLICY "Admins can insert dares" CREATE POLICY "Admins can insert dares"
ON dares FOR INSERT ON dares FOR INSERT
@@ -271,7 +246,6 @@ WITH CHECK (
SELECT 1 FROM admin_users WHERE user_id = auth.uid() SELECT 1 FROM admin_users WHERE user_id = auth.uid()
) )
); );
-- Admins can update dares -- Admins can update dares
CREATE POLICY "Admins can update dares" CREATE POLICY "Admins can update dares"
ON dares FOR UPDATE ON dares FOR UPDATE
@@ -280,7 +254,6 @@ USING (
SELECT 1 FROM admin_users WHERE user_id = auth.uid() SELECT 1 FROM admin_users WHERE user_id = auth.uid()
) )
); );
-- Admins can delete dares -- Admins can delete dares
CREATE POLICY "Admins can delete dares" CREATE POLICY "Admins can delete dares"
ON dares FOR DELETE ON dares FOR DELETE
@@ -289,7 +262,6 @@ USING (
SELECT 1 FROM admin_users WHERE user_id = auth.uid() SELECT 1 FROM admin_users WHERE user_id = auth.uid()
) )
); );
-- ------------------------- -- -------------------------
-- sent_dares policies -- sent_dares policies
-- ------------------------- -- -------------------------
@@ -298,7 +270,6 @@ USING (
CREATE POLICY "Users can view their couple's sent dares" CREATE POLICY "Users can view their couple's sent dares"
ON sent_dares FOR SELECT ON sent_dares FOR SELECT
USING (couple_id = get_auth_user_couple_id()); USING (couple_id = get_auth_user_couple_id());
-- Users can insert sent dares (sender must be the authenticated user) -- Users can insert sent dares (sender must be the authenticated user)
CREATE POLICY "Users can send dares to their partner" CREATE POLICY "Users can send dares to their partner"
ON sent_dares FOR INSERT ON sent_dares FOR INSERT
@@ -306,12 +277,10 @@ WITH CHECK (
sender_id = auth.uid() sender_id = auth.uid()
AND couple_id = get_auth_user_couple_id() AND couple_id = get_auth_user_couple_id()
); );
-- Users can update sent dares in their couple -- Users can update sent dares in their couple
CREATE POLICY "Users can update their couple's sent dares" CREATE POLICY "Users can update their couple's sent dares"
ON sent_dares FOR UPDATE ON sent_dares FOR UPDATE
USING (couple_id = get_auth_user_couple_id()); USING (couple_id = get_auth_user_couple_id());
-- Admins can view all sent dares -- Admins can view all sent dares
CREATE POLICY "Admins can view all sent dares" CREATE POLICY "Admins can view all sent dares"
ON sent_dares FOR SELECT ON sent_dares FOR SELECT
@@ -320,7 +289,6 @@ USING (
SELECT 1 FROM admin_users WHERE user_id = auth.uid() SELECT 1 FROM admin_users WHERE user_id = auth.uid()
) )
); );
-- ------------------------- -- -------------------------
-- dare_messages policies -- dare_messages policies
-- ------------------------- -- -------------------------
@@ -335,7 +303,6 @@ USING (
AND sd.couple_id = get_auth_user_couple_id() AND sd.couple_id = get_auth_user_couple_id()
) )
); );
-- Users can insert messages for their couple's dares -- Users can insert messages for their couple's dares
CREATE POLICY "Users can send messages for their dares" CREATE POLICY "Users can send messages for their dares"
ON dare_messages FOR INSERT ON dare_messages FOR INSERT
@@ -347,7 +314,6 @@ WITH CHECK (
AND sd.couple_id = get_auth_user_couple_id() AND sd.couple_id = get_auth_user_couple_id()
) )
); );
-- Users can update messages in their dares (for read_at) -- Users can update messages in their dares (for read_at)
CREATE POLICY "Users can update message read status" CREATE POLICY "Users can update message read status"
ON dare_messages FOR UPDATE ON dare_messages FOR UPDATE
@@ -358,7 +324,6 @@ USING (
AND sd.couple_id = get_auth_user_couple_id() AND sd.couple_id = get_auth_user_couple_id()
) )
); );
-- Admins can view all dare messages -- Admins can view all dare messages
CREATE POLICY "Admins can view all dare messages" CREATE POLICY "Admins can view all dare messages"
ON dare_messages FOR SELECT ON dare_messages FOR SELECT
@@ -367,7 +332,6 @@ USING (
SELECT 1 FROM admin_users WHERE user_id = auth.uid() SELECT 1 FROM admin_users WHERE user_id = auth.uid()
) )
); );
-- ============================================================================ -- ============================================================================
-- REALTIME -- REALTIME
-- ============================================================================ -- ============================================================================
@@ -6,21 +6,17 @@ CREATE TABLE match_archives (
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
archived_at TIMESTAMPTZ DEFAULT now(), archived_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(match_id, user_id) UNIQUE(match_id, user_id)
); );
-- Index for efficient lookup when filtering matches -- 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 -- 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 -- RLS Policies for match_archives
-- Users can view their own archives (needed for filtering) -- Users can view their own archives (needed for filtering)
CREATE POLICY "Users can view own archives" CREATE POLICY "Users can view own archives"
ON match_archives FOR SELECT 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) -- Users can insert their own archives (for matches in their couples)
CREATE POLICY "Users can archive matches" CREATE POLICY "Users can archive matches"
ON match_archives FOR INSERT ON match_archives FOR INSERT
@@ -32,15 +28,12 @@ CREATE POLICY "Users can archive matches"
WHERE m.id = match_archives.match_id WHERE m.id = match_archives.match_id
AND p.id = auth.uid() AND p.id = auth.uid()
) )
); );
-- Users can delete their own archives (for unarchiving) -- Users can delete their own archives (for unarchiving)
CREATE POLICY "Users can unarchive matches" CREATE POLICY "Users can unarchive matches"
ON match_archives FOR DELETE ON match_archives FOR DELETE
USING (user_id = auth.uid()); USING (user_id = auth.uid());
-- Add to realtime for live updates -- Add to realtime for live updates
ALTER PUBLICATION supabase_realtime ADD TABLE match_archives; ALTER PUBLICATION supabase_realtime ADD TABLE match_archives;
-- Documentation -- 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.';
@@ -1,13 +1,11 @@
-- Add visibility toggle for categories -- Add visibility toggle for categories
ALTER TABLE public.categories ALTER TABLE public.categories
ADD COLUMN IF NOT EXISTS is_public BOOLEAN NOT NULL DEFAULT true; ADD COLUMN IF NOT EXISTS is_public BOOLEAN NOT NULL DEFAULT true;
-- Categories: hide non-public categories from non-admins -- Categories: hide non-public categories from non-admins
DROP POLICY IF EXISTS "Categories are viewable by everyone" ON public.categories; DROP POLICY IF EXISTS "Categories are viewable by everyone" ON public.categories;
CREATE POLICY "Categories are viewable by everyone" CREATE POLICY "Categories are viewable by everyone"
ON public.categories FOR SELECT ON public.categories FOR SELECT
USING (is_public = true); USING (is_public = true);
-- Question packs: require public categories for visibility -- Question packs: require public categories for visibility
DROP POLICY IF EXISTS "Anyone can view public packs" ON public.question_packs; DROP POLICY IF EXISTS "Anyone can view public packs" ON public.question_packs;
CREATE POLICY "Anyone can view public 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; DROP POLICY IF EXISTS "Premium users can view premium packs" ON public.question_packs;
CREATE POLICY "Premium users can view premium packs" CREATE POLICY "Premium users can view premium packs"
ON public.question_packs FOR SELECT 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 -- Questions: require visible packs and public categories
DROP POLICY IF EXISTS "Anyone can view questions in visible packs" ON public.questions; DROP POLICY IF EXISTS "Anyone can view questions in visible packs" ON public.questions;
CREATE POLICY "Anyone can view questions in visible packs" 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 -- Ensure recommendation queries honor category visibility
CREATE OR REPLACE FUNCTION get_recommended_questions(target_pack_id UUID DEFAULT NULL) CREATE OR REPLACE FUNCTION get_recommended_questions(target_pack_id UUID DEFAULT NULL)
RETURNS TABLE( RETURNS TABLE(
@@ -223,7 +218,6 @@ BEGIN
); );
END; END;
$$; $$;
CREATE OR REPLACE FUNCTION get_pack_teaser_questions(target_pack_id UUID) CREATE OR REPLACE FUNCTION get_pack_teaser_questions(target_pack_id UUID)
RETURNS TABLE(id UUID, text TEXT, intensity INTEGER) RETURNS TABLE(id UUID, text TEXT, intensity INTEGER)
LANGUAGE plpgsql LANGUAGE plpgsql
@@ -201,5 +201,4 @@ BEGIN
); );
END; 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.'; 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.';
@@ -4,9 +4,7 @@
-- Add daily_response_limit column to app_config -- Add daily_response_limit column to app_config
ALTER TABLE app_config ALTER TABLE app_config
ADD COLUMN daily_response_limit INTEGER DEFAULT 0; 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.'; 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 -- RPC FUNCTION: get_daily_response_limit_status
-- Returns the current daily response limit status for the authenticated user -- Returns the current daily response limit status for the authenticated user
@@ -73,5 +71,4 @@ BEGIN
(v_count >= v_limit); (v_count >= v_limit);
END; 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.'; 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.';
@@ -1,5 +1,4 @@
ALTER TABLE public.questions ALTER TABLE public.questions
ADD COLUMN required_props TEXT[]; ADD COLUMN required_props TEXT[];
COMMENT ON COLUMN public.questions.required_props IS COMMENT ON COLUMN public.questions.required_props IS
'Array of props/accessories required to perform the activity.'; 'Array of props/accessories required to perform the activity.';
@@ -223,6 +223,5 @@ BEGIN
) DESC, ) DESC,
RANDOM(); RANDOM();
END; 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.';
@@ -103,10 +103,8 @@ BEGIN
-- Return net gap (displayed as "unanswered_by_partner" for backwards compatibility) -- Return net gap (displayed as "unanswered_by_partner" for backwards compatibility)
RETURN QUERY SELECT v_net_gap, v_threshold, (v_net_gap >= v_threshold); RETURN QUERY SELECT v_net_gap, v_threshold, (v_net_gap >= v_threshold);
END; 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 -- Part 2: Update get_recommended_questions() to prioritize partner-answered questions
-- ============================================================================ -- ============================================================================
@@ -336,6 +334,5 @@ BEGIN
) DESC, ) DESC,
q.intensity ASC; q.intensity ASC;
END; 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.';
@@ -4,16 +4,16 @@
-- The "primary" question has inverse_of = NULL -- The "primary" question has inverse_of = NULL
-- The "secondary" (inverse) question points to its primary -- The "secondary" (inverse) question points to its primary
-- Add the column -- Add the columns
ALTER TABLE questions ALTER TABLE questions
ADD COLUMN inverse_of UUID REFERENCES questions(id) ON DELETE SET NULL; ADD COLUMN inverse_of UUID REFERENCES questions(id) ON DELETE SET NULL;
ALTER TABLE questions
-- Add a comment to document the column ADD COLUMN deleted_at TIMESTAMPTZ;
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.'; -- 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.';
-- Create an index for efficient lookups COMMENT ON COLUMN questions.deleted_at IS 'Soft delete timestamp. NULL means the question is active.';
CREATE INDEX idx_questions_inverse_of ON questions(inverse_of) WHERE inverse_of IS NOT NULL; -- 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 -- 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) -- 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) CREATE OR REPLACE FUNCTION get_pack_unique_question_count(pack_uuid UUID)
@@ -26,11 +26,9 @@ AS $$
WHERE pack_id = pack_uuid WHERE pack_id = pack_uuid
AND inverse_of IS NULL AND inverse_of IS NULL
AND deleted_at IS NULL; AND deleted_at IS NULL;
$$; $$;
-- Grant execute permission to authenticated users -- 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 a view to get pack stats including both total and unique counts
CREATE OR REPLACE VIEW pack_question_stats AS CREATE OR REPLACE VIEW pack_question_stats AS
SELECT 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 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 COUNT(*) FILTER (WHERE inverse_of IS NOT NULL AND deleted_at IS NULL) AS inverse_questions
FROM questions FROM questions
GROUP BY pack_id; GROUP BY pack_id;
-- Grant select on the view -- Grant select on the view
GRANT SELECT ON pack_question_stats TO authenticated; GRANT SELECT ON pack_question_stats TO authenticated;
@@ -1,42 +1,41 @@
-- Add indexes for unindexed foreign keys -- Add indexes for unindexed foreign keys
-- These improve JOIN and DELETE performance -- 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_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_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_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_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_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_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_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_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_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_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_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_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_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_sent_dares_dare_id ON public.sent_dares(dare_id);
-- Drop unused indexes (identified by Supabase advisor as never used) -- Drop unused indexes (identified by Supabase advisor as never used)
-- These are just consuming storage with no benefit -- These are just consuming storage with no benefit
DROP INDEX IF EXISTS public.idx_feedback_type; DROP INDEX IF EXISTS public.idx_feedback_type;
DROP INDEX IF EXISTS public.idx_feedback_status; DROP INDEX IF EXISTS public.idx_feedback_status;
DROP INDEX IF EXISTS public.idx_subscriptions_expires_at; 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_webhook_events_event_id;
DROP INDEX IF EXISTS public.idx_audit_logs_record_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_audit_logs_action;
DROP INDEX IF EXISTS public.idx_pack_topics_topic_id; 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_feature_interests_feature_name;
DROP INDEX IF EXISTS public.idx_profiles_couple_id_public_key; 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_messages_deleted_at;
DROP INDEX IF EXISTS public.idx_message_reports_created; DROP INDEX IF EXISTS public.idx_message_reports_created;
DROP INDEX IF EXISTS public.idx_dare_packs_visibility; 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_pack_id;
DROP INDEX IF EXISTS public.idx_dares_intensity; 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_sender_id;
DROP INDEX IF EXISTS public.idx_sent_dares_recipient_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_status;
DROP INDEX IF EXISTS public.idx_sent_dares_expires_at; 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_sent_dare_id;
DROP INDEX IF EXISTS public.idx_dare_messages_created_at; 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_questions_deleted_at;
@@ -10,29 +10,23 @@ CREATE TABLE pending_pack_notifications (
created_at TIMESTAMPTZ DEFAULT now(), created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(couple_id) -- Only one pending notification per couple (enables UPSERT for timer reset) UNIQUE(couple_id) -- Only one pending notification per couple (enables UPSERT for timer reset)
); );
-- Index for efficient cron queries -- Index for efficient cron queries
CREATE INDEX idx_pending_pack_notifications_notify_at CREATE INDEX idx_pending_pack_notifications_notify_at
ON pending_pack_notifications(notify_at); ON pending_pack_notifications(notify_at);
-- Enable RLS -- Enable RLS
ALTER TABLE pending_pack_notifications ENABLE ROW LEVEL SECURITY; ALTER TABLE pending_pack_notifications ENABLE ROW LEVEL SECURITY;
-- RLS policies -- RLS policies
CREATE POLICY "Users can view their couple's pending notifications" CREATE POLICY "Users can view their couple's pending notifications"
ON pending_pack_notifications FOR SELECT 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" CREATE POLICY "Super admins can view all pending notifications"
ON pending_pack_notifications FOR SELECT ON pending_pack_notifications FOR SELECT
USING (is_super_admin()); USING (is_super_admin());
-- Updated_at trigger -- Updated_at trigger
CREATE TRIGGER update_pending_pack_notifications_updated_at CREATE TRIGGER update_pending_pack_notifications_updated_at
BEFORE UPDATE ON pending_pack_notifications 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 -- Function to queue pack change notifications
CREATE OR REPLACE FUNCTION queue_pack_change_notification() CREATE OR REPLACE FUNCTION queue_pack_change_notification()
RETURNS TRIGGER RETURNS TRIGGER
@@ -79,14 +73,12 @@ BEGIN
RETURN NEW; RETURN NEW;
END; END;
$$; $$;
-- Create trigger on couple_packs -- Create trigger on couple_packs
CREATE TRIGGER on_couple_pack_enabled CREATE TRIGGER on_couple_pack_enabled
AFTER INSERT OR UPDATE OF enabled ON couple_packs AFTER INSERT OR UPDATE OF enabled ON couple_packs
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION queue_pack_change_notification(); EXECUTE FUNCTION queue_pack_change_notification();
-- Schedule cron job (runs every 5 minutes) -- Schedule cron job (runs every 5 minutes)
SELECT cron.schedule( SELECT cron.schedule(
'send-pack-change-notifications', 'send-pack-change-notifications',
@@ -98,4 +90,4 @@ SELECT cron.schedule(
body := '{}'::jsonb body := '{}'::jsonb
) )
$$ $$
); );
@@ -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;
$$;;
@@ -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'));
;
@@ -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;
;
@@ -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;
@@ -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
)
$$
);
@@ -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
)
$$
);
@@ -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
)
$$
);