4 Commits
Author SHA1 Message Date
jjbrunton 1911f8f058 Switch analytics from PostHog to Firebase 2026-01-16 15:38:17 +00:00
jjbrunton 23e24aa4c2 Fix admin user detail page not showing email 2026-01-16 14:57:40 +00:00
jjbruntonandClaude Opus 4.5 e671ae9d1c Add admin user details, pack notifications, remove crypto
- Add enabled packs and feature interests to admin user page
- Add clickable links from responses to packs in admin
- Add pack change notification edge function
- Remove react-native-quick-crypto (unused E2EE)
- Bump mobile version to 1.0.1 (versionCode 22)
- Add EAS config and preview build scripts
- Gitignore keystore and APK files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 19:03:34 +00:00
jjbrunton a7a13c9614 Add release notes and fix answer gap deadlock
- Add mobile release notes for v1.0.1
- Fix answer gap deadlock with NET gap calculation
- Randomize question order for variety
- Dismiss keyboard when match modal appears
- Add keyboard dismiss on chat scroll
- Pin EAS build node version
2026-01-11 15:10:14 +00:00
31 changed files with 2130 additions and 504 deletions

No files matched your search

+1 -1
View File
@@ -25,7 +25,7 @@
"@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-tooltip": "^1.0.7", "@radix-ui/react-tooltip": "^1.0.7",
"@sauci/shared": "workspace:*", "@sauci/shared": "file:../../packages/shared",
"@supabase/supabase-js": "^2.39.0", "@supabase/supabase-js": "^2.39.0",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.0", "clsx": "^2.1.0",
@@ -49,6 +49,7 @@ interface ResponseActivity {
id: string; id: string;
text: string; text: string;
pack: { pack: {
id: string;
name: string; name: string;
} | null; } | null;
} | null; } | null;
@@ -155,7 +156,7 @@ export function UserActivityPage() {
profile:profiles!responses_user_id_fkey(id, name, email), profile:profiles!responses_user_id_fkey(id, name, email),
question:questions( question:questions(
id, text, id, text,
pack:question_packs(name) pack:question_packs(id, name)
) )
`, { count: 'exact' }) `, { count: 'exact' })
.order('created_at', { ascending: false }) .order('created_at', { ascending: false })
@@ -317,18 +318,22 @@ export function UserActivityPage() {
const fetchSignups = useCallback(async () => { const fetchSignups = useCallback(async () => {
setLoading(prev => ({ ...prev, signups: true })); setLoading(prev => ({ ...prev, signups: true }));
try { try {
const from = (signupsPage - 1) * signupsPageSize; // Use RPC to get profiles with auth info (includes email from auth.users)
const to = from + signupsPageSize - 1; const { data: allData, error } = await supabase
.rpc('get_profiles_with_auth_info');
const { data, error, count } = await supabase
.from('profiles')
.select('id, name, email, avatar_url, created_at, onboarding_completed, couple_id', { count: 'exact' })
.order('created_at', { ascending: false })
.range(from, to);
if (error) throw error; if (error) throw error;
setSignups(data || []);
setSignupsTotal(count || 0); // Sort by created_at descending and apply pagination
const sorted = (allData || []).sort((a: SignupActivity, b: SignupActivity) =>
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
const from = (signupsPage - 1) * signupsPageSize;
const to = from + signupsPageSize;
setSignups(sorted.slice(from, to));
setSignupsTotal(sorted.length);
} catch (error) { } catch (error) {
console.error('Failed to load signups:', error); console.error('Failed to load signups:', error);
toast.error("Failed to load signups"); toast.error("Failed to load signups");
@@ -502,14 +507,31 @@ export function UserActivityPage() {
<TableRow key={item.id}> <TableRow key={item.id}>
<TableCell>{renderUserLink(item.profile)}</TableCell> <TableCell>{renderUserLink(item.profile)}</TableCell>
<TableCell className="max-w-[300px]"> <TableCell className="max-w-[300px]">
<span className="truncate block"> {item.question?.pack?.id ? (
{item.question?.text || 'Unknown question'} <Link
</span> to={`/packs/${item.question.pack.id}/questions`}
className="truncate block text-primary hover:underline"
>
{item.question?.text || 'Unknown question'}
</Link>
) : (
<span className="truncate block">
{item.question?.text || 'Unknown question'}
</span>
)}
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant="outline"> {item.question?.pack?.id ? (
{item.question?.pack?.name || 'Unknown'} <Link to={`/packs/${item.question.pack.id}/questions`}>
</Badge> <Badge variant="outline" className="hover:bg-primary/10 cursor-pointer">
{item.question.pack.name}
</Badge>
</Link>
) : (
<Badge variant="outline">
{item.question?.pack?.name || 'Unknown'}
</Badge>
)}
</TableCell> </TableCell>
<TableCell>{renderAnswerBadge(item.answer)}</TableCell> <TableCell>{renderAnswerBadge(item.answer)}</TableCell>
<TableCell className="text-muted-foreground text-sm whitespace-nowrap"> <TableCell className="text-muted-foreground text-sm whitespace-nowrap">
+160 -6
View File
@@ -38,14 +38,17 @@ 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 } from 'lucide-react'; 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 { format } from 'date-fns'; import { format } from 'date-fns';
import { IconPreview } from '@/components/ui/icon-picker';
type UsageReason = 'improve_communication' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship'; type UsageReason = 'improve_communication' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship';
type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say'; type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say';
// ... (interfaces and constants are the same) // ... (interfaces and constants are the same)
type IntensityLevel = 1 | 2 | 3 | 4 | 5;
interface Profile { interface Profile {
id: string; id: string;
name: string | null; name: string | null;
@@ -58,6 +61,7 @@ interface Profile {
usage_reason: UsageReason | null; usage_reason: UsageReason | null;
show_explicit_content: boolean | null; show_explicit_content: boolean | null;
onboarding_completed: boolean | null; onboarding_completed: boolean | null;
max_intensity: IntensityLevel | null;
} }
const usageReasonLabels: Record<UsageReason, { label: string; icon: string }> = { const usageReasonLabels: Record<UsageReason, { label: string; icon: string }> = {
@@ -75,6 +79,14 @@ const genderLabels: Record<Gender, string> = {
'prefer-not-to-say': 'Prefer not to say', 'prefer-not-to-say': 'Prefer not to say',
}; };
const intensityLabels: Record<IntensityLevel, { label: string; emoji: string }> = {
1: { label: 'Gentle', emoji: '💭' },
2: { label: 'Warm', emoji: '💕' },
3: { label: 'Playful', emoji: '😏' },
4: { label: 'Steamy', emoji: '🔥' },
5: { label: 'Intense', emoji: '🌶️' },
};
interface Partner { interface Partner {
id: string; id: string;
name: string | null; name: string | null;
@@ -88,6 +100,7 @@ interface Response {
id: string; id: string;
text: string; text: string;
pack: { pack: {
id: string;
name: string; name: string;
}; };
}; };
@@ -105,6 +118,30 @@ interface Match {
message_count?: number; message_count?: number;
} }
interface EnabledPack {
pack_id: string;
enabled: boolean;
created_at: string | null;
pack: {
id: string;
name: string;
description: string | null;
icon: string | null;
is_premium: boolean;
is_explicit: boolean;
category: {
id: string;
name: string;
} | null;
};
}
interface FeatureInterest {
id: string;
feature_name: string;
created_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" />,
@@ -238,6 +275,8 @@ export function UserDetailPage() {
const [responses, setResponses] = useState<Response[]>([]); const [responses, setResponses] = useState<Response[]>([]);
const [matches, setMatches] = useState<Match[]>([]); const [matches, setMatches] = useState<Match[]>([]);
const [mediaMessages, setMediaMessages] = useState<any[]>([]); const [mediaMessages, setMediaMessages] = useState<any[]>([]);
const [enabledPacks, setEnabledPacks] = useState<EnabledPack[]>([]);
const [featureInterests, setFeatureInterests] = useState<FeatureInterest[]>([]);
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);
@@ -292,7 +331,7 @@ export function UserDetailPage() {
expires_at: expiresAt, expires_at: expiresAt,
}); });
if (error) throw error; if (error) throw error;
const { data: updatedProfile } = await supabase.from('profiles').select('*').eq('id', userId).single(); const { data: updatedProfile } = await supabase.rpc('get_profile_with_auth_info', { user_id: userId }).single();
if (updatedProfile) setProfile(updatedProfile); if (updatedProfile) setProfile(updatedProfile);
setUpgradeOpen(false); setUpgradeOpen(false);
alert('User upgraded to premium successfully!'); alert('User upgraded to premium successfully!');
@@ -308,9 +347,17 @@ export function UserDetailPage() {
if (!userId) return; if (!userId) return;
setLoading(true); setLoading(true);
try { try {
const { data: profileData } = await supabase.from('profiles').select('*').eq('id', userId).single(); const { data: profileData } = await supabase.rpc('get_profile_with_auth_info', { user_id: userId }).single();
setProfile(profileData); setProfile(profileData);
// Fetch feature interests for the user
const { data: featureInterestsData } = await supabase
.from('feature_interests')
.select('id, feature_name, created_at')
.eq('user_id', userId)
.order('created_at', { ascending: false });
setFeatureInterests((featureInterestsData || []) as FeatureInterest[]);
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);
@@ -323,7 +370,7 @@ export function UserDetailPage() {
const { data: responseData, count: responsesCount, error: responsesError } = await supabase const { data: responseData, count: responsesCount, error: responsesError } = await supabase
.from('responses') .from('responses')
.select('id, answer, created_at, question:questions(id, text, pack:question_packs(name))', { count: 'exact' }) .select('id, answer, created_at, question:questions(id, text, pack:question_packs(id, name))', { count: 'exact' })
.eq('user_id', userId) .eq('user_id', userId)
.order('created_at', { ascending: false }) .order('created_at', { ascending: false })
.range(responsesFrom, responsesTo); .range(responsesFrom, responsesTo);
@@ -388,11 +435,39 @@ export function UserDetailPage() {
setMediaMessages([]); setMediaMessages([]);
setMediaTotal(0); setMediaTotal(0);
} }
// Fetch enabled packs for the couple
const { data: enabledPacksData, error: packsError } = await supabase
.from('couple_packs')
.select(`
pack_id,
enabled,
created_at,
pack:question_packs(
id,
name,
description,
icon,
is_premium,
is_explicit,
category:categories(id, name)
)
`)
.eq('couple_id', profileData.couple_id)
.eq('enabled', true)
.order('created_at', { ascending: false });
if (packsError) {
console.error("Error fetching enabled packs:", packsError);
setEnabledPacks([]);
} else {
setEnabledPacks((enabledPacksData || []) as unknown as EnabledPack[]);
}
} else { } else {
setMatches([]); setMatches([]);
setMatchesTotal(0); setMatchesTotal(0);
setMediaMessages([]); setMediaMessages([]);
setMediaTotal(0); setMediaTotal(0);
setEnabledPacks([]);
} }
} catch (error) { } catch (error) {
console.error('Failed to load user data:', error); console.error('Failed to load user data:', error);
@@ -456,20 +531,46 @@ export function UserDetailPage() {
</div> </div>
</div></CardContent></Card> </div></CardContent></Card>
{/* Onboarding Details */} {/* Onboarding Details */}
<Card><CardHeader className="pb-3"><CardTitle className="text-lg flex items-center gap-2"><CheckCircle className="h-5 w-5 text-primary" />Onboarding Details</CardTitle><CardDescription>Information collected during user onboarding</CardDescription></CardHeader><CardContent><div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <Card><CardHeader className="pb-3"><CardTitle className="text-lg flex items-center gap-2"><CheckCircle className="h-5 w-5 text-primary" />Onboarding Details</CardTitle><CardDescription>Information collected during user onboarding</CardDescription></CardHeader><CardContent><div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
<div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900"><User className="h-4 w-4 text-blue-600 dark:text-blue-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Gender</p><p className="font-medium">{profile.gender ? genderLabels[profile.gender] : 'Not set'}</p></div></div> <div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900"><User className="h-4 w-4 text-blue-600 dark:text-blue-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Gender</p><p className="font-medium">{profile.gender ? genderLabels[profile.gender] : 'Not set'}</p></div></div>
<div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-900"><Target className="h-4 w-4 text-purple-600 dark:text-purple-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Why using Sauci</p><p className="font-medium">{profile.usage_reason ? usageReasonLabels[profile.usage_reason]?.label : 'Not set'}</p></div></div> <div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-900"><Target className="h-4 w-4 text-purple-600 dark:text-purple-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Why using Sauci</p><p className="font-medium">{profile.usage_reason ? usageReasonLabels[profile.usage_reason]?.label : 'Not set'}</p></div></div>
<div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-pink-100 dark:bg-pink-900"><Heart className="h-4 w-4 text-pink-600 dark:text-pink-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Comfort Zone</p><p className="font-medium">{profile.max_intensity ? `${intensityLabels[profile.max_intensity].emoji} ${intensityLabels[profile.max_intensity].label} (${profile.max_intensity})` : 'Not set'}</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.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>
{/* Feature Opt-Ins */}
{featureInterests.length > 0 && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<Sparkles className="h-5 w-5 text-primary" />
Feature Opt-Ins
</CardTitle>
<CardDescription>Features the user has expressed interest in</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{featureInterests.map((interest) => (
<Badge key={interest.id} variant="secondary" className="capitalize">
{interest.feature_name}
<span className="ml-1 text-xs text-muted-foreground">
({format(new Date(interest.created_at), 'MMM d')})
</span>
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{/* Tabs */} {/* Tabs */}
<Tabs defaultValue={canViewResponses ? "responses" : canViewMatches ? "matches" : canViewMedia ? "media" : "responses"}> <Tabs defaultValue={canViewResponses ? "responses" : canViewMatches ? "matches" : canViewMedia ? "media" : "responses"}>
<TabsList> <TabsList>
{canViewResponses && <TabsTrigger value="responses">Responses ({responsesTotal})</TabsTrigger>} {canViewResponses && <TabsTrigger value="responses">Responses ({responsesTotal})</TabsTrigger>}
{canViewMatches && <TabsTrigger value="matches" disabled={!profile.couple_id}>Matches ({matchesTotal})</TabsTrigger>} {canViewMatches && <TabsTrigger value="matches" disabled={!profile.couple_id}>Matches ({matchesTotal})</TabsTrigger>}
{canViewMedia && <TabsTrigger value="media">Media ({mediaTotal})</TabsTrigger>} {canViewMedia && <TabsTrigger value="media">Media ({mediaTotal})</TabsTrigger>}
<TabsTrigger value="packs" disabled={!profile.couple_id}>Enabled Packs ({enabledPacks.length})</TabsTrigger>
</TabsList> </TabsList>
{canViewResponses && <TabsContent value="responses" className="mt-4">{responses.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No responses yet</p></Card> : <div className="space-y-4"><div className="rounded-md border"><Table><TableHeader><TableRow><TableHead>Question</TableHead><TableHead>Pack</TableHead><TableHead className="w-24">Answer</TableHead><TableHead className="w-32">Date</TableHead></TableRow></TableHeader><TableBody>{responses.map((r) => (<TableRow key={r.id}><TableCell className="max-w-md"><span className="line-clamp-2">{r.question.text}</span></TableCell><TableCell className="text-muted-foreground">{r.question.pack?.name || '—'}</TableCell><TableCell><div className="flex items-center gap-2">{answerIcons[r.answer]}<span className="capitalize">{r.answer}</span></div></TableCell><TableCell className="text-muted-foreground text-sm">{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}</TableCell></TableRow>))}</TableBody></Table></div><PaginationControls page={responsesPage} pageSize={responsesPageSize} totalCount={responsesTotal} onPageChange={setResponsesPage} onPageSizeChange={(size) => { setResponsesPage(1); setResponsesPageSize(size); }} /></div>}</TabsContent>} {canViewResponses && <TabsContent value="responses" className="mt-4">{responses.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No responses yet</p></Card> : <div className="space-y-4"><div className="rounded-md border"><Table><TableHeader><TableRow><TableHead>Question</TableHead><TableHead>Pack</TableHead><TableHead className="w-24">Answer</TableHead><TableHead className="w-32">Date</TableHead></TableRow></TableHeader><TableBody>{responses.map((r) => (<TableRow key={r.id}><TableCell className="max-w-md">{r.question.pack?.id ? (<Link to={`/packs/${r.question.pack.id}/questions`} className="line-clamp-2 text-primary hover:underline cursor-pointer">{r.question.text}</Link>) : (<span className="line-clamp-2">{r.question.text}</span>)}</TableCell><TableCell>{r.question.pack?.id ? (<Link to={`/packs/${r.question.pack.id}/questions`} className="text-primary hover:underline">{r.question.pack.name}</Link>) : (<span className="text-muted-foreground">{r.question.pack?.name || '—'}</span>)}</TableCell><TableCell><div className="flex items-center gap-2">{answerIcons[r.answer]}<span className="capitalize">{r.answer}</span></div></TableCell><TableCell className="text-muted-foreground text-sm">{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}</TableCell></TableRow>))}</TableBody></Table></div><PaginationControls page={responsesPage} pageSize={responsesPageSize} totalCount={responsesTotal} onPageChange={setResponsesPage} onPageSizeChange={(size) => { setResponsesPage(1); setResponsesPageSize(size); }} /></div>}</TabsContent>}
{canViewMatches && <TabsContent value="matches" className="mt-4">{matches.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No matches yet</p></Card> : <div className="space-y-4"><div className="grid gap-4 md:grid-cols-2">{matches.map((match) => (<Card key={match.id} className="hover:shadow-md transition-shadow"><CardHeader className="pb-2"><div className="flex items-start justify-between"><Badge variant="secondary">{matchTypeLabels[match.match_type]}</Badge>{match.is_new && <Badge variant="default" className="text-xs">New</Badge>}</div><CardDescription className="line-clamp-2 mt-2">{match.question?.text || 'Unknown question'}</CardDescription></CardHeader><CardContent><div className="flex items-center justify-between"><div className="flex items-center gap-1 text-sm text-muted-foreground"><MessageCircle className="h-4 w-4" />{match.message_count} message{match.message_count !== 1 ? 's' : ''}</div>{canViewChats && <Link to={`/users/${userId}/matches/${match.id}`}><Button variant="ghost" size="sm">View Chat<ChevronRight className="ml-1 h-4 w-4" /></Button></Link>}</div></CardContent></Card>))}</div><PaginationControls page={matchesPage} pageSize={matchesPageSize} totalCount={matchesTotal} onPageChange={setMatchesPage} onPageSizeChange={(size) => { setMatchesPage(1); setMatchesPageSize(size); }} /></div>}</TabsContent>} {canViewMatches && <TabsContent value="matches" className="mt-4">{matches.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No matches yet</p></Card> : <div className="space-y-4"><div className="grid gap-4 md:grid-cols-2">{matches.map((match) => (<Card key={match.id} className="hover:shadow-md transition-shadow"><CardHeader className="pb-2"><div className="flex items-start justify-between"><Badge variant="secondary">{matchTypeLabels[match.match_type]}</Badge>{match.is_new && <Badge variant="default" className="text-xs">New</Badge>}</div><CardDescription className="line-clamp-2 mt-2">{match.question?.text || 'Unknown question'}</CardDescription></CardHeader><CardContent><div className="flex items-center justify-between"><div className="flex items-center gap-1 text-sm text-muted-foreground"><MessageCircle className="h-4 w-4" />{match.message_count} message{match.message_count !== 1 ? 's' : ''}</div>{canViewChats && <Link to={`/users/${userId}/matches/${match.id}`}><Button variant="ghost" size="sm">View Chat<ChevronRight className="ml-1 h-4 w-4" /></Button></Link>}</div></CardContent></Card>))}</div><PaginationControls page={matchesPage} pageSize={matchesPageSize} totalCount={matchesTotal} onPageChange={setMatchesPage} onPageSizeChange={(size) => { setMatchesPage(1); setMatchesPageSize(size); }} /></div>}</TabsContent>}
{canViewMedia && <TabsContent value="media" className="mt-4">{mediaMessages.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><Image className="h-12 w-12 text-muted-foreground mb-4" /><p className="text-muted-foreground">No media uploaded</p></Card> : <div className="space-y-4"><div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">{mediaMessages.map((message) => (<Card key={message.id} className="overflow-hidden"><div className="aspect-square bg-muted relative"> {canViewMedia && <TabsContent value="media" className="mt-4">{mediaMessages.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><Image className="h-12 w-12 text-muted-foreground mb-4" /><p className="text-muted-foreground">No media uploaded</p></Card> : <div className="space-y-4"><div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">{mediaMessages.map((message) => (<Card key={message.id} className="overflow-hidden"><div className="aspect-square bg-muted relative">
{message.media_type === 'video' ? ( {message.media_type === 'video' ? (
@@ -478,6 +579,59 @@ export function UserDetailPage() {
<AdminDecryptedImage messageId={message.id} alt={`Media from ${message.created_at}`} /> <AdminDecryptedImage messageId={message.id} alt={`Media from ${message.created_at}`} />
)} )}
</div><CardContent className="p-3"><div className="flex items-center justify-between text-sm"><div className="truncate text-muted-foreground">{message.media_type === 'video' ? 'Video' : 'Image'}</div>{canViewChats && message.match_id && <Link to={`/users/${userId}/matches/${message.match_id}`} className="text-primary hover:underline text-xs">View Chat</Link>}</div><div className="text-xs text-muted-foreground mt-1">{message.created_at ? format(new Date(message.created_at), 'MMM d, yyyy') : '—'}</div></CardContent></Card>))}</div><PaginationControls page={mediaPage} pageSize={mediaPageSize} totalCount={mediaTotal} onPageChange={setMediaPage} onPageSizeChange={(size) => { setMediaPage(1); setMediaPageSize(size); }} /></div>}</TabsContent>} </div><CardContent className="p-3"><div className="flex items-center justify-between text-sm"><div className="truncate text-muted-foreground">{message.media_type === 'video' ? 'Video' : 'Image'}</div>{canViewChats && message.match_id && <Link to={`/users/${userId}/matches/${message.match_id}`} className="text-primary hover:underline text-xs">View Chat</Link>}</div><div className="text-xs text-muted-foreground mt-1">{message.created_at ? format(new Date(message.created_at), 'MMM d, yyyy') : '—'}</div></CardContent></Card>))}</div><PaginationControls page={mediaPage} pageSize={mediaPageSize} totalCount={mediaTotal} onPageChange={setMediaPage} onPageSizeChange={(size) => { setMediaPage(1); setMediaPageSize(size); }} /></div>}</TabsContent>}
<TabsContent value="packs" className="mt-4">
{enabledPacks.length === 0 ? (
<Card className="flex flex-col items-center justify-center py-12">
<Package className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground">No packs enabled</p>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{enabledPacks.map((ep) => (
<Link key={ep.pack_id} to={`/packs/${ep.pack.id}/questions`}>
<Card className="hover:shadow-md transition-shadow cursor-pointer">
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<IconPreview value={ep.pack.icon} fallback="heart-outline" className="text-2xl" />
<CardTitle className="text-base">{ep.pack.name}</CardTitle>
</div>
<div className="flex gap-1">
{ep.pack.is_premium && (
<Badge className="bg-amber-500 text-xs">
<Crown className="h-3 w-3 mr-1" />
Premium
</Badge>
)}
{ep.pack.is_explicit && (
<Badge variant="destructive" className="text-xs">
18+
</Badge>
)}
</div>
</div>
{ep.pack.description && (
<CardDescription className="line-clamp-2 mt-2">
{ep.pack.description}
</CardDescription>
)}
</CardHeader>
<CardContent>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{ep.pack.category?.name || 'Uncategorized'}
</span>
<span className="text-muted-foreground text-xs">
Enabled {ep.created_at ? format(new Date(ep.created_at), 'MMM d, yyyy') : '—'}
</span>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
)}
</TabsContent>
</Tabs> </Tabs>
</div> </div>
); );
+4 -1
View File
@@ -5,4 +5,7 @@
expo-env.d.ts expo-env.d.ts
# @end expo-cli # @end expo-cli
*.aab *.aab
*.ipa *.ipa
*.apk
*.jks
*.keystore
+1 -1
View File
@@ -17,7 +17,7 @@
<key>IS_ADS_ENABLED</key> <key>IS_ADS_ENABLED</key>
<false></false> <false></false>
<key>IS_ANALYTICS_ENABLED</key> <key>IS_ANALYTICS_ENABLED</key>
<false></false> <true></true>
<key>IS_APPINVITE_ENABLED</key> <key>IS_APPINVITE_ENABLED</key>
<true></true> <true></true>
<key>IS_GCM_ENABLED</key> <key>IS_GCM_ENABLED</key>
+2 -2
View File
@@ -93,8 +93,8 @@ android {
applicationId 'com.sauci.app' applicationId 'com.sauci.app'
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 21 versionCode 24
versionName "1.0.0" versionName "1.0.2"
} }
signingConfigs { signingConfigs {
debug { debug {
@@ -13,6 +13,14 @@
} }
}, },
"oauth_client": [ "oauth_client": [
{
"client_id": "764866133492-03k7q0mq6nqrv0phunncqjn85cgj6sep.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "com.sauci.app",
"certificate_hash": "f06026c754b046f280feac438a6910bd9a62b459"
}
},
{ {
"client_id": "764866133492-4g566mrb8tm5uti4ejub6oogcvnln7kc.apps.googleusercontent.com", "client_id": "764866133492-4g566mrb8tm5uti4ejub6oogcvnln7kc.apps.googleusercontent.com",
"client_type": 1, "client_type": 1,
+4 -1
View File
@@ -56,4 +56,7 @@ EX_DEV_CLIENT_NETWORK_INSPECTOR=true
expo.useLegacyPackaging=false expo.useLegacyPackaging=false
android.targetSdkVersion=35 android.targetSdkVersion=35
android.extraMavenRepos=[] android.extraMavenRepos=[]
# Enable 16 KB page size support (required by Google Play for API 35+)
android.bundle.pageAlignment=16kb
+7 -3
View File
@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "Sauci", "name": "Sauci",
"slug": "sauci", "slug": "sauci",
"version": "1.0.0", "version": "1.0.2",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/icon.png", "icon": "./assets/icon.png",
"scheme": "app.sauci", "scheme": "app.sauci",
@@ -16,8 +16,9 @@
"ios": { "ios": {
"supportsTablet": false, "supportsTablet": false,
"bundleIdentifier": "com.sauci.app", "bundleIdentifier": "com.sauci.app",
"buildNumber": "25", "buildNumber": "30",
"usesAppleSignIn": true, "usesAppleSignIn": true,
"googleServicesFile": "./GoogleService-Info.plist",
"infoPlist": { "infoPlist": {
"UIBackgroundModes": [ "UIBackgroundModes": [
"remote-notification" "remote-notification"
@@ -176,7 +177,7 @@
"backgroundColor": "#1a1a2e" "backgroundColor": "#1a1a2e"
}, },
"package": "com.sauci.app", "package": "com.sauci.app",
"versionCode": 21, "versionCode": 24,
"googleServicesFile": "./google-services.json", "googleServicesFile": "./google-services.json",
"permissions": [ "permissions": [
"RECEIVE_BOOT_COMPLETED", "RECEIVE_BOOT_COMPLETED",
@@ -197,6 +198,9 @@
{ {
"android": { "android": {
"targetSdkVersion": 35 "targetSdkVersion": 35
},
"ios": {
"useFrameworks": "static"
} }
} }
], ],
-1
View File
@@ -1,6 +1,5 @@
// Polyfills must be imported FIRST before any other React Native imports // Polyfills must be imported FIRST before any other React Native imports
import "../src/polyfills/web"; import "../src/polyfills/web";
import "../src/polyfills/crypto";
// Initialize Sentry early to capture all errors // Initialize Sentry early to capture all errors
import { initSentry, setUserContext, clearUserContext } from "../src/lib/sentry"; import { initSentry, setUserContext, clearUserContext } from "../src/lib/sentry";
+3
View File
@@ -5,13 +5,16 @@
}, },
"build": { "build": {
"development": { "development": {
"node": "20.10.0",
"developmentClient": true, "developmentClient": true,
"distribution": "internal" "distribution": "internal"
}, },
"preview": { "preview": {
"node": "20.10.0",
"distribution": "internal" "distribution": "internal"
}, },
"production": { "production": {
"node": "20.10.0",
"autoIncrement": true, "autoIncrement": true,
"env": { "env": {
"EXPO_PUBLIC_SUPABASE_URL": "https://ckjcrkjpmhqhiucifukx.supabase.co", "EXPO_PUBLIC_SUPABASE_URL": "https://ckjcrkjpmhqhiucifukx.supabase.co",
+8
View File
@@ -13,6 +13,14 @@
} }
}, },
"oauth_client": [ "oauth_client": [
{
"client_id": "764866133492-03k7q0mq6nqrv0phunncqjn85cgj6sep.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "com.sauci.app",
"certificate_hash": "f06026c754b046f280feac438a6910bd9a62b459"
}
},
{ {
"client_id": "764866133492-4g566mrb8tm5uti4ejub6oogcvnln7kc.apps.googleusercontent.com", "client_id": "764866133492-4g566mrb8tm5uti4ejub6oogcvnln7kc.apps.googleusercontent.com",
"client_type": 1, "client_type": 1,
-30
View File
@@ -331,7 +331,6 @@ PODS:
- libavif/core - libavif/core
- libdav1d (>= 0.6.0) - libdav1d (>= 0.6.0)
- libdav1d (1.2.0) - libdav1d (1.2.0)
- OpenSSL-Universal (3.3.3001)
- PromisesObjC (2.4.0) - PromisesObjC (2.4.0)
- PurchasesHybridCommon (17.25.0): - PurchasesHybridCommon (17.25.0):
- RevenueCat (= 5.51.1) - RevenueCat (= 5.51.1)
@@ -1634,29 +1633,6 @@ PODS:
- ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- Yoga - Yoga
- react-native-quick-crypto (0.7.17):
- DoubleConversion
- glog
- hermes-engine
- OpenSSL-Universal
- RCT-Folly (= 2024.10.14.00)
- RCTRequired
- RCTTypeSafety
- React
- React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-NativeModulesApple
- React-RCTFabric
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- react-native-safe-area-context (4.12.0): - react-native-safe-area-context (4.12.0):
- React-Core - React-Core
- React-nativeconfig (0.76.9) - React-nativeconfig (0.76.9)
@@ -2176,7 +2152,6 @@ DEPENDENCIES:
- React-Mapbuffer (from `../../../node_modules/react-native/ReactCommon`) - React-Mapbuffer (from `../../../node_modules/react-native/ReactCommon`)
- React-microtasksnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) - React-microtasksnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)
- react-native-compressor (from `../../../node_modules/react-native-compressor`) - react-native-compressor (from `../../../node_modules/react-native-compressor`)
- react-native-quick-crypto (from `../../../node_modules/react-native-quick-crypto`)
- react-native-safe-area-context (from `../../../node_modules/react-native-safe-area-context`) - react-native-safe-area-context (from `../../../node_modules/react-native-safe-area-context`)
- React-nativeconfig (from `../../../node_modules/react-native/ReactCommon`) - React-nativeconfig (from `../../../node_modules/react-native/ReactCommon`)
- React-NativeModulesApple (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) - React-NativeModulesApple (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
@@ -2224,7 +2199,6 @@ SPEC REPOS:
- GTMSessionFetcher - GTMSessionFetcher
- libavif - libavif
- libdav1d - libdav1d
- OpenSSL-Universal
- PromisesObjC - PromisesObjC
- PurchasesHybridCommon - PurchasesHybridCommon
- RevenueCat - RevenueCat
@@ -2372,8 +2346,6 @@ EXTERNAL SOURCES:
:path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks" :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks"
react-native-compressor: react-native-compressor:
:path: "../../../node_modules/react-native-compressor" :path: "../../../node_modules/react-native-compressor"
react-native-quick-crypto:
:path: "../../../node_modules/react-native-quick-crypto"
react-native-safe-area-context: react-native-safe-area-context:
:path: "../../../node_modules/react-native-safe-area-context" :path: "../../../node_modules/react-native-safe-area-context"
React-nativeconfig: React-nativeconfig:
@@ -2494,7 +2466,6 @@ SPEC CHECKSUMS:
hermes-engine: 9e868dc7be781364296d6ee2f56d0c1a9ef0bb11 hermes-engine: 9e868dc7be781364296d6ee2f56d0c1a9ef0bb11
libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7 libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7
libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f
OpenSSL-Universal: 6082b0bf950e5636fe0d78def171184e2b3899c2
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
PurchasesHybridCommon: 6a79a873ab52f777bfa36e9516f3fcd84d3b3428 PurchasesHybridCommon: 6a79a873ab52f777bfa36e9516f3fcd84d3b3428
RCT-Folly: ea9d9256ba7f9322ef911169a9f696e5857b9e17 RCT-Folly: ea9d9256ba7f9322ef911169a9f696e5857b9e17
@@ -2527,7 +2498,6 @@ SPEC CHECKSUMS:
React-Mapbuffer: 33546a3ebefbccb8770c33a1f8a5554fa96a54de React-Mapbuffer: 33546a3ebefbccb8770c33a1f8a5554fa96a54de
React-microtasksnativemodule: 5c3d795318c22ab8df55100e50b151384a4a60b3 React-microtasksnativemodule: 5c3d795318c22ab8df55100e50b151384a4a60b3
react-native-compressor: 2c3a41768bdd367f6e121f555dff9d3eda1810e5 react-native-compressor: 2c3a41768bdd367f6e121f555dff9d3eda1810e5
react-native-quick-crypto: 7bb26af9b3705dbb9277c09305104d37ede74916
react-native-safe-area-context: 8b8404e70b0cbf2a56428a17017c14c1dcc16448 react-native-safe-area-context: 8b8404e70b0cbf2a56428a17017c14c1dcc16448
React-nativeconfig: 8efdb1ef1e9158c77098a93085438f7e7b463678 React-nativeconfig: 8efdb1ef1e9158c77098a93085438f7e7b463678
React-NativeModulesApple: cebca2e5320a3d66e123cade23bd90a167ffce5e React-NativeModulesApple: cebca2e5320a3d66e123cade23bd90a167ffce5e
@@ -352,12 +352,10 @@
); );
inputPaths = ( inputPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-frameworks.sh", "${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-frameworks.sh",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/OpenSSL-Universal/OpenSSL.framework/OpenSSL",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes", "${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes",
); );
name = "[CP] Embed Pods Frameworks"; name = "[CP] Embed Pods Frameworks";
outputPaths = ( outputPaths = (
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/OpenSSL.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework",
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -410,11 +408,11 @@
); );
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = com.sauci.app; PRODUCT_BUNDLE_IDENTIFIER = com.sauci.app;
PRODUCT_NAME = "Sauci"; PRODUCT_NAME = Sauci;
SWIFT_OBJC_BRIDGING_HEADER = "Sauci/Sauci-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Sauci/Sauci-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1"; TARGETED_DEVICE_FAMILY = 1;
VERSIONING_SYSTEM = "apple-generic"; VERSIONING_SYSTEM = "apple-generic";
}; };
name = Debug; name = Debug;
@@ -442,10 +440,10 @@
); );
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.sauci.app; PRODUCT_BUNDLE_IDENTIFIER = com.sauci.app;
PRODUCT_NAME = "Sauci"; PRODUCT_NAME = Sauci;
SWIFT_OBJC_BRIDGING_HEADER = "Sauci/Sauci-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Sauci/Sauci-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1"; TARGETED_DEVICE_FAMILY = 1;
VERSIONING_SYSTEM = "apple-generic"; VERSIONING_SYSTEM = "apple-generic";
}; };
name = Release; name = Release;
+2 -2
View File
@@ -21,7 +21,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.0.0</string> <string>1.0.1</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
@@ -47,7 +47,7 @@
</dict> </dict>
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>25</string> <string>30</string>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
<string>12.0</string> <string>12.0</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
+1 -3
View File
@@ -25,11 +25,9 @@ config.resolver.nodeModulesPaths = [
// of the workspace root, unless they are installed in the project root. // of the workspace root, unless they are installed in the project root.
config.resolver.disableHierarchicalLookup = true; config.resolver.disableHierarchicalLookup = true;
// 4. E2EE crypto polyfill: resolve 'crypto' to react-native-quick-crypto // 4. Keep extraNodeModules in case we need to add monorepo overrides later
// This enables Web Crypto API for encryption operations
config.resolver.extraNodeModules = { config.resolver.extraNodeModules = {
...config.resolver.extraNodeModules, ...config.resolver.extraNodeModules,
crypto: require.resolve('react-native-quick-crypto'),
}; };
module.exports = config; module.exports = config;
+5 -3
View File
@@ -14,12 +14,16 @@
"test:watch": "jest --watch", "test:watch": "jest --watch",
"test:ci": "jest --runInBand --ci", "test:ci": "jest --runInBand --ci",
"release:ios": "dotenv -e .env -- npx expo prebuild --platform ios && dotenv -e .env -- eas build --platform ios --profile production --local", "release:ios": "dotenv -e .env -- npx expo prebuild --platform ios && dotenv -e .env -- eas build --platform ios --profile production --local",
"release:android": "dotenv -e .env -- npx expo prebuild --platform android && dotenv -e .env -- eas build --platform android --profile production --local" "release:ios:preview": "dotenv -e .env -- npx expo prebuild --platform ios && dotenv -e .env -- eas build --platform ios --profile preview --local",
"release:android": "dotenv -e .env -- npx expo prebuild --platform android && dotenv -e .env -- eas build --platform android --profile production --local",
"release:android:preview": "dotenv -e .env -- npx expo prebuild --platform android && dotenv -e .env -- eas build --platform android --profile preview --local"
}, },
"dependencies": { "dependencies": {
"@expo/ngrok": "^4.1.0", "@expo/ngrok": "^4.1.0",
"@expo/vector-icons": "~14.0.4", "@expo/vector-icons": "~14.0.4",
"@react-native-async-storage/async-storage": "1.23.1", "@react-native-async-storage/async-storage": "1.23.1",
"@react-native-firebase/analytics": "^23.8.2",
"@react-native-firebase/app": "^23.8.2",
"@react-native-google-signin/google-signin": "^16.1.1", "@react-native-google-signin/google-signin": "^16.1.1",
"@react-navigation/bottom-tabs": "^7.0.0", "@react-navigation/bottom-tabs": "^7.0.0",
"@react-navigation/native": "^7.0.0", "@react-navigation/native": "^7.0.0",
@@ -51,14 +55,12 @@
"expo-router": "~4.0.0", "expo-router": "~4.0.0",
"expo-secure-store": "~14.0.0", "expo-secure-store": "~14.0.0",
"expo-status-bar": "~2.0.0", "expo-status-bar": "~2.0.0",
"posthog-react-native": "^4.17.1",
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-native": "0.76.9", "react-native": "0.76.9",
"react-native-compressor": "^1.13.0", "react-native-compressor": "^1.13.0",
"react-native-gesture-handler": "~2.20.0", "react-native-gesture-handler": "~2.20.0",
"react-native-purchases": "^9.6.12", "react-native-purchases": "^9.6.12",
"react-native-quick-crypto": "^0.7.11",
"react-native-reanimated": "~3.16.0", "react-native-reanimated": "~3.16.0",
"react-native-safe-area-context": "4.12.0", "react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0", "react-native-screens": "~4.4.0",
+36
View File
@@ -0,0 +1,36 @@
# Sauci Mobile Release Notes
## v1.0.1 (Build 30) - January 2025
### New Features
- **Match Archiving** - Archive old matches to keep your active list focused
- **Daily Swipe Limits** - Pace your discovery with daily question limits
- **Intensity Preferences** - Set your comfort level for question intensity (1-5 scale)
- **Infinite Scroll** - Smoother browsing through Matches and Answers with infinite scroll
- **Chat Date Separators** - Easily see when conversations happened
- **Lifecycle Notifications** - Get reminded to check in with your partner
- **Message Reporting** - Report inappropriate messages for review
- **Account Deletion** - Full account deletion option in settings
- **Reset Progress** - Start fresh by resetting your question progress
### Improvements
- **Enhanced Swipe UI** - Smoother swiping experience with visual refinements
- **Improved Chat Layout** - Match card moved into message list for cleaner design
- **Keyboard Behavior** - Chat header collapses when keyboard opens for more screen space
- **Profile Settings** - New appearance and notification preferences
- **Premium Design** - Updated subscription card with gold styling
- **Version Display** - App version now visible in settings
### Bug Fixes
- Fixed subscription status incorrectly showing "Via partner's" after direct purchase
- Fixed Android navigation issues
- Various performance improvements and stability fixes
### Privacy
- Removed partner answered indicator for enhanced privacy
---
## v1.0.0 (Build 25) - January 8, 2025
Initial release.
@@ -14,6 +14,7 @@ import {
StyleSheet, StyleSheet,
Pressable, Pressable,
Animated, Animated,
Keyboard,
} from 'react-native'; } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { BlurView } from 'expo-blur'; import { BlurView } from 'expo-blur';
@@ -106,6 +107,9 @@ export const MatchNotificationModal: React.FC<Props> = ({
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
// Dismiss keyboard so it doesn't block the modal buttons
Keyboard.dismiss();
// Reset animations // Reset animations
modalScale.setValue(0.8); modalScale.setValue(0.8);
modalOpacity.setValue(0); modalOpacity.setValue(0);
@@ -139,6 +139,7 @@ const ChatMessagesComponent: React.FC<ChatMessagesProps> = ({
style={styles.messageList} style={styles.messageList}
contentContainerStyle={styles.listContent} contentContainerStyle={styles.listContent}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
// Performance optimizations // Performance optimizations
removeClippedSubviews={true} removeClippedSubviews={true}
+61 -35
View File
@@ -1,7 +1,31 @@
import PostHog from "posthog-react-native"; import { getApp } from "@react-native-firebase/app";
import {
getAnalytics,
logEvent as firebaseLogEvent,
setUserId as firebaseSetUserId,
setUserProperty,
setAnalyticsCollectionEnabled,
resetAnalyticsData,
FirebaseAnalyticsTypes,
} from "@react-native-firebase/analytics";
let posthog: PostHog | null = null;
let isInitialized = false; let isInitialized = false;
let analyticsInstance: FirebaseAnalyticsTypes.Module | null = null;
function getAnalyticsInstance(): FirebaseAnalyticsTypes.Module | null {
if (analyticsInstance) {
return analyticsInstance;
}
try {
const app = getApp();
analyticsInstance = getAnalytics(app);
return analyticsInstance;
} catch (error) {
console.warn("[Analytics] Failed to get analytics instance:", error);
return null;
}
}
export async function initAnalytics() { export async function initAnalytics() {
if (isInitialized) { if (isInitialized) {
@@ -9,30 +33,19 @@ export async function initAnalytics() {
} }
// Skip initialization in non-RN environments (e.g., during tsc/Node.js) // Skip initialization in non-RN environments (e.g., during tsc/Node.js)
// AsyncStorage requires window to be defined
if (typeof window === "undefined" && typeof global !== "undefined" && !("__BUNDLE_START_TIME__" in global)) { if (typeof window === "undefined" && typeof global !== "undefined" && !("__BUNDLE_START_TIME__" in global)) {
return; return;
} }
const apiKey = process.env.EXPO_PUBLIC_POSTHOG_API_KEY;
const host = process.env.EXPO_PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com";
if (!apiKey) {
console.warn("[Analytics] PostHog API key not configured");
return;
}
try { try {
posthog = new PostHog(apiKey, { const analytics = getAnalyticsInstance();
host, if (analytics) {
// Disable automatic capture in production for privacy await setAnalyticsCollectionEnabled(analytics, true);
captureAppLifecycleEvents: true, isInitialized = true;
}); console.log("[Analytics] Firebase Analytics initialized");
}
isInitialized = true;
console.log("[Analytics] PostHog initialized");
} catch (error) { } catch (error) {
console.warn("[Analytics] Failed to initialize PostHog:", error); console.warn("[Analytics] Failed to initialize Firebase Analytics:", error);
} }
} }
@@ -43,7 +56,10 @@ export function logEvent(
) { ) {
try { try {
if (__DEV__) console.log("[Analytics] Event:", name, params); if (__DEV__) console.log("[Analytics] Event:", name, params);
posthog?.capture(name, params); const analytics = getAnalyticsInstance();
if (analytics) {
firebaseLogEvent(analytics, name, params);
}
} catch (error) { } catch (error) {
console.warn("[Analytics] Failed to log event:", error); console.warn("[Analytics] Failed to log event:", error);
} }
@@ -53,7 +69,10 @@ export function logEvent(
export function setUserId(userId: string) { export function setUserId(userId: string) {
try { try {
if (__DEV__) console.log("[Analytics] Set user ID:", userId); if (__DEV__) console.log("[Analytics] Set user ID:", userId);
posthog?.identify(userId); const analytics = getAnalyticsInstance();
if (analytics) {
firebaseSetUserId(analytics, userId);
}
} catch (error) { } catch (error) {
console.warn("[Analytics] Failed to set user ID:", error); console.warn("[Analytics] Failed to set user ID:", error);
} }
@@ -63,7 +82,11 @@ export function setUserId(userId: string) {
export function clearUserId() { export function clearUserId() {
try { try {
if (__DEV__) console.log("[Analytics] Cleared user ID"); if (__DEV__) console.log("[Analytics] Cleared user ID");
posthog?.reset(); const analytics = getAnalyticsInstance();
if (analytics) {
firebaseSetUserId(analytics, null);
resetAnalyticsData(analytics);
}
} catch (error) { } catch (error) {
console.warn("[Analytics] Failed to clear user ID:", error); console.warn("[Analytics] Failed to clear user ID:", error);
} }
@@ -75,14 +98,13 @@ export function setUserProperties(
) { ) {
try { try {
if (__DEV__) console.log("[Analytics] Set user properties:", properties); if (__DEV__) console.log("[Analytics] Set user properties:", properties);
// Filter out null values for PostHog const analytics = getAnalyticsInstance();
const filteredProps: Record<string, string> = {}; if (analytics) {
for (const [key, value] of Object.entries(properties)) { // Firebase Analytics requires setting user properties one at a time
if (value !== null) { for (const [key, value] of Object.entries(properties)) {
filteredProps[key] = value; setUserProperty(analytics, key, value);
} }
} }
posthog?.capture("$set", { $set: filteredProps });
} catch (error) { } catch (error) {
console.warn("[Analytics] Failed to set user properties:", error); console.warn("[Analytics] Failed to set user properties:", error);
} }
@@ -92,7 +114,13 @@ export function setUserProperties(
export function logScreenView(screenName: string, screenClass?: string) { export function logScreenView(screenName: string, screenClass?: string) {
try { try {
if (__DEV__) console.log("[Analytics] Screen view:", screenName); if (__DEV__) console.log("[Analytics] Screen view:", screenName);
posthog?.screen(screenName, { screen_class: screenClass ?? screenName }); const analytics = getAnalyticsInstance();
if (analytics) {
firebaseLogEvent(analytics, "screen_view", {
screen_name: screenName,
screen_class: screenClass ?? screenName,
});
}
} catch (error) { } catch (error) {
console.warn("[Analytics] Failed to log screen view:", error); console.warn("[Analytics] Failed to log screen view:", error);
} }
@@ -100,11 +128,9 @@ 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() {
try { // Firebase Analytics batches and sends events automatically
posthog?.flush(); // No manual flush needed, but we keep the function for API compatibility
} catch (error) { if (__DEV__) console.log("[Analytics] Flush called (no-op for Firebase)");
console.warn("[Analytics] Failed to flush:", error);
}
} }
// Pre-defined event helpers for common actions // Pre-defined event helpers for common actions
-32
View File
@@ -1,32 +0,0 @@
/**
* Crypto polyfill for React Native
*
* This file must be imported BEFORE any code that uses crypto.
* It installs react-native-quick-crypto as the global crypto implementation.
*
* On web, we skip this since the native Web Crypto API is available.
*/
import { Platform } from 'react-native';
if (Platform.OS !== 'web') {
try {
// Import react-native-quick-crypto to install its polyfill
const QuickCrypto = require('react-native-quick-crypto');
// Install the polyfill globally if not already present
if (typeof global.crypto === 'undefined') {
(global as any).crypto = QuickCrypto;
}
// Also ensure subtle is available
if (global.crypto && !global.crypto.subtle) {
(global.crypto as any).subtle = QuickCrypto.subtle;
}
console.log('[Crypto] react-native-quick-crypto polyfill installed');
} catch (error) {
console.error('[Crypto] Failed to install crypto polyfill:', error);
console.error('[Crypto] Make sure you have run: npx expo prebuild --clean');
}
}
+11 -12
View File
@@ -120,16 +120,15 @@ jest.mock('base64-arraybuffer', () => ({
decode: jest.fn(() => new ArrayBuffer(0)), decode: jest.fn(() => new ArrayBuffer(0)),
})); }));
jest.mock('posthog-react-native', () => { jest.mock('@react-native-firebase/app', () => ({
return class PostHog { getApp: jest.fn(() => ({})),
capture = jest.fn(); }));
identify = jest.fn();
reset = jest.fn();
screen = jest.fn();
flush = jest.fn();
constructor() { jest.mock('@react-native-firebase/analytics', () => ({
// no-op getAnalytics: jest.fn(() => ({})),
} logEvent: jest.fn(),
}; setUserId: jest.fn(),
}); setUserProperty: jest.fn(),
setAnalyticsCollectionEnabled: jest.fn(() => Promise.resolve()),
resetAnalyticsData: jest.fn(),
}));
@@ -0,0 +1,142 @@
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
/**
* Send delayed pack change notifications.
* Called by pg_cron every 5 minutes to check for pending notifications
* where notify_at has passed.
*
* Notifications are sent when a user enables new question packs. The notification
* is delayed by 30 minutes, with the timer resetting on each new pack enable.
* This prevents spam when a user enables multiple packs in quick succession.
*/
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_pack_notifications")
.select("id, couple_id, changed_by_user_id, 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[] = [];
for (const notification of pendingNotifications) {
// Get the partner's push token (not the user who made changes)
const { data: partner, error: partnerError } = await supabase
.from("profiles")
.select("push_token")
.eq("couple_id", notification.couple_id)
.neq("id", notification.changed_by_user_id)
.maybeSingle();
if (partnerError) {
console.error(
`Error fetching partner for couple ${notification.couple_id}:`,
partnerError
);
continue;
}
if (partner?.push_token) {
messages.push({
to: partner.push_token,
title: "Question packs updated",
body: "Your partner updated the question packs",
sound: "default",
data: { type: "pack_change" },
});
}
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_pack_notifications")
.delete()
.in("id", processedIds);
if (deleteError) {
console.error("Error deleting processed notifications:", deleteError);
}
}
console.log(`Sent ${messages.length} pack change notifications`);
return new Response(
JSON.stringify({
sent: messages.length,
processed: processedIds.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,228 @@
-- Randomize question order for variety
-- Changes ORDER BY from intensity ASC to RANDOM()
-- Keeps partner-answered priority for gap resolution
CREATE OR REPLACE FUNCTION get_recommended_questions(target_pack_id UUID DEFAULT NULL)
RETURNS TABLE(
id UUID,
text TEXT,
partner_text TEXT,
is_two_part BOOLEAN,
pack_id UUID,
intensity INTEGER,
partner_answered BOOLEAN,
allowed_couple_genders TEXT[],
target_user_genders TEXT[]
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = 'public'
AS $$
DECLARE
v_user_id UUID;
v_couple_id UUID;
v_partner_id UUID;
v_user_gender TEXT;
v_partner_gender TEXT;
v_couple_type TEXT;
v_has_premium BOOLEAN;
v_gap_threshold INTEGER;
v_my_unanswered INTEGER;
v_partner_unanswered INTEGER;
v_net_gap INTEGER;
v_user_max_intensity INTEGER;
v_daily_limit INTEGER;
v_daily_count INTEGER;
v_today_start TIMESTAMPTZ;
v_tomorrow_start TIMESTAMPTZ;
BEGIN
v_user_id := auth.uid();
SELECT p.couple_id, p.gender, COALESCE(p.max_intensity, 5)
INTO v_couple_id, v_user_gender, v_user_max_intensity
FROM public.profiles p
WHERE p.id = v_user_id;
IF v_couple_id IS NULL THEN
RETURN;
END IF;
SELECT p.id, p.gender INTO v_partner_id, v_partner_gender
FROM public.profiles p
WHERE p.couple_id = v_couple_id AND p.id != v_user_id
LIMIT 1;
-- Check premium access (used for both answer gap and daily limit)
v_has_premium := public.has_premium_access(v_user_id);
-- Check daily response limit (only when not browsing a specific pack and user is non-premium)
IF v_partner_id IS NOT NULL AND target_pack_id IS NULL AND NOT v_has_premium THEN
-- Get daily limit from app_config
SELECT COALESCE(ac.daily_response_limit, 0) INTO v_daily_limit
FROM app_config ac
LIMIT 1;
-- If daily limit > 0, check if user has reached it
IF v_daily_limit > 0 THEN
-- Calculate UTC day boundaries
v_today_start := date_trunc('day', NOW() AT TIME ZONE 'UTC') AT TIME ZONE 'UTC';
v_tomorrow_start := v_today_start + INTERVAL '1 day';
-- Count responses created today (UTC)
SELECT COUNT(*)::INTEGER INTO v_daily_count
FROM responses r
WHERE r.user_id = v_user_id
AND r.created_at >= v_today_start
AND r.created_at < v_tomorrow_start;
-- If over daily limit, return empty (old apps see "All caught up!")
IF v_daily_count >= v_daily_limit THEN
RETURN;
END IF;
END IF;
END IF;
-- Check answer gap threshold using NET gap (only when not browsing a specific pack)
IF v_partner_id IS NOT NULL AND target_pack_id IS NULL THEN
-- Get threshold from app_config
SELECT COALESCE(ac.answer_gap_threshold, 10) INTO v_gap_threshold
FROM app_config ac
LIMIT 1;
-- If threshold > 0, check the NET gap
IF v_gap_threshold > 0 THEN
-- Calculate NET gap to prevent deadlocks
WITH enabled_packs AS (
SELECT cp.pack_id
FROM couple_packs cp
WHERE cp.couple_id = v_couple_id AND cp.enabled = true
)
SELECT
-- Questions I answered that partner hasn't
COALESCE(SUM(CASE WHEN r.user_id = v_user_id AND NOT EXISTS (
SELECT 1 FROM responses pr
WHERE pr.question_id = r.question_id
AND pr.user_id = v_partner_id
) THEN 1 ELSE 0 END), 0)::integer,
-- Questions partner answered that I haven't
COALESCE(SUM(CASE WHEN r.user_id = v_partner_id AND NOT EXISTS (
SELECT 1 FROM responses pr
WHERE pr.question_id = r.question_id
AND pr.user_id = v_user_id
) THEN 1 ELSE 0 END), 0)::integer
INTO v_my_unanswered, v_partner_unanswered
FROM responses r
INNER JOIN questions q ON q.id = r.question_id
WHERE r.couple_id = v_couple_id
AND r.user_id IN (v_user_id, v_partner_id)
AND EXISTS (SELECT 1 FROM enabled_packs)
AND q.pack_id IN (SELECT ep.pack_id FROM enabled_packs ep);
-- Net gap: only positive values mean user is truly ahead
v_net_gap := GREATEST(0, v_my_unanswered - v_partner_unanswered);
-- If over threshold, return empty (old apps see "All caught up!")
IF v_net_gap >= v_gap_threshold THEN
RETURN;
END IF;
END IF;
END IF;
IF v_user_gender IS NOT NULL AND v_partner_gender IS NOT NULL THEN
IF v_user_gender < v_partner_gender THEN
v_couple_type := v_user_gender || '+' || v_partner_gender;
ELSE
v_couple_type := v_partner_gender || '+' || v_user_gender;
END IF;
END IF;
RETURN QUERY
WITH user_config_exists AS (
SELECT 1 FROM public.couple_packs WHERE couple_id = v_couple_id LIMIT 1
),
active_packs AS (
SELECT target_pack_id AS pack_id
WHERE target_pack_id IS NOT NULL
AND EXISTS (
SELECT 1 FROM public.question_packs qp
WHERE qp.id = target_pack_id
AND (qp.is_premium = FALSE OR v_has_premium = TRUE)
)
UNION ALL
SELECT cp.pack_id FROM public.couple_packs cp
INNER JOIN public.question_packs qp ON qp.id = cp.pack_id
WHERE target_pack_id IS NULL
AND cp.couple_id = v_couple_id
AND cp.enabled = TRUE
AND (qp.is_premium = FALSE OR v_has_premium = TRUE)
AND EXISTS (SELECT 1 FROM user_config_exists)
UNION ALL
SELECT p.id AS pack_id FROM public.question_packs p
WHERE target_pack_id IS NULL
AND p.is_public = TRUE
AND (p.is_premium = FALSE OR v_has_premium = TRUE)
AND NOT EXISTS (SELECT 1 FROM user_config_exists)
)
SELECT
q.id,
CASE
WHEN q.partner_text IS NOT NULL AND EXISTS (
SELECT 1 FROM public.responses r
WHERE r.question_id = q.id
AND r.couple_id = v_couple_id
AND r.user_id != v_user_id
) THEN q.partner_text
ELSE q.text
END as text,
q.partner_text,
q.partner_text IS NOT NULL as is_two_part,
q.pack_id,
q.intensity,
EXISTS (
SELECT 1 FROM public.responses r
WHERE r.question_id = q.id
AND r.couple_id = v_couple_id
AND r.user_id != v_user_id
) AS partner_answered,
q.allowed_couple_genders,
q.target_user_genders
FROM public.questions q
WHERE q.pack_id IN (SELECT ap.pack_id FROM active_packs ap)
AND NOT EXISTS (
SELECT 1 FROM public.responses r
WHERE r.question_id = q.id
AND r.user_id = v_user_id
)
AND (
q.allowed_couple_genders IS NULL
OR v_couple_type IS NULL
OR v_couple_type = ANY(q.allowed_couple_genders)
)
AND (
q.target_user_genders IS NULL
OR v_user_gender = ANY(q.target_user_genders)
OR EXISTS (
SELECT 1 FROM public.responses r
WHERE r.question_id = q.id
AND r.couple_id = v_couple_id
AND r.user_id != v_user_id
)
)
-- Filter by user's max_intensity preference
AND (
q.intensity IS NULL
OR q.intensity <= v_user_max_intensity
)
-- ORDER BY: Partner-answered questions first, then RANDOM for variety
ORDER BY
EXISTS (
SELECT 1 FROM public.responses r
WHERE r.question_id = q.id
AND r.couple_id = v_couple_id
AND r.user_id != v_user_id
) DESC,
RANDOM();
END;
$$;
COMMENT ON FUNCTION get_recommended_questions(UUID) IS 'Returns recommended questions with partner-answered first, then randomized for variety. Uses NET gap calculation for blocking.';
@@ -0,0 +1,341 @@
-- Fix answer gap deadlock by calculating NET gap instead of independent gaps
-- Also add ORDER BY to get_recommended_questions to prioritize partner-answered questions
-- ============================================================================
-- Part 1: Fix get_answer_gap_status() to calculate NET gap
-- ============================================================================
--
-- Problem: Both partners can be "ahead" simultaneously when they answer different questions
-- Example: User A answers Q1-Q10, User B answers Q11-Q20
-- - Old: Both have gap=10, both blocked (deadlock!)
-- - New: Both have net_gap=0, neither blocked
--
-- NET gap = (questions I answered that partner hasn't) - (questions partner answered that I haven't)
-- Only positive values mean user is truly ahead
CREATE OR REPLACE FUNCTION get_answer_gap_status()
RETURNS TABLE(
unanswered_by_partner integer,
threshold integer,
is_blocked boolean
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = 'public'
AS $$
DECLARE
v_user_id UUID;
v_couple_id UUID;
v_partner_id UUID;
v_my_unanswered integer;
v_partner_unanswered integer;
v_net_gap integer;
v_threshold integer;
BEGIN
v_user_id := auth.uid();
-- Get user's couple_id
SELECT p.couple_id INTO v_couple_id
FROM profiles p
WHERE p.id = v_user_id;
-- No couple = no blocking needed
IF v_couple_id IS NULL THEN
RETURN QUERY SELECT 0, 0, false;
RETURN;
END IF;
-- Get partner's ID
SELECT p.id INTO v_partner_id
FROM profiles p
WHERE p.couple_id = v_couple_id AND p.id != v_user_id
LIMIT 1;
-- No partner = no blocking needed
IF v_partner_id IS NULL THEN
RETURN QUERY SELECT 0, 0, false;
RETURN;
END IF;
-- Get threshold from app_config
SELECT COALESCE(ac.answer_gap_threshold, 10) INTO v_threshold
FROM app_config ac
LIMIT 1;
-- If threshold is 0, feature is disabled
IF v_threshold = 0 THEN
RETURN QUERY SELECT 0, 0, false;
RETURN;
END IF;
-- Calculate NET gap to prevent deadlocks
WITH enabled_packs AS (
-- Get packs that the couple has explicitly enabled
SELECT cp.pack_id
FROM couple_packs cp
WHERE cp.couple_id = v_couple_id AND cp.enabled = true
)
SELECT
-- Questions I answered that partner hasn't
COALESCE(SUM(CASE WHEN r.user_id = v_user_id AND NOT EXISTS (
SELECT 1 FROM responses pr
WHERE pr.question_id = r.question_id
AND pr.user_id = v_partner_id
) THEN 1 ELSE 0 END), 0)::integer,
-- Questions partner answered that I haven't
COALESCE(SUM(CASE WHEN r.user_id = v_partner_id AND NOT EXISTS (
SELECT 1 FROM responses pr
WHERE pr.question_id = r.question_id
AND pr.user_id = v_user_id
) THEN 1 ELSE 0 END), 0)::integer
INTO v_my_unanswered, v_partner_unanswered
FROM responses r
INNER JOIN questions q ON q.id = r.question_id
WHERE r.couple_id = v_couple_id
AND r.user_id IN (v_user_id, v_partner_id)
AND EXISTS (SELECT 1 FROM enabled_packs)
AND q.pack_id IN (SELECT pack_id FROM enabled_packs);
-- Net gap: only positive values mean user is truly ahead
-- If partner has answered more, user's net gap is 0 (they're behind or even)
v_net_gap := GREATEST(0, v_my_unanswered - v_partner_unanswered);
-- Return net gap (displayed as "unanswered_by_partner" for backwards compatibility)
RETURN QUERY SELECT v_net_gap, v_threshold, (v_net_gap >= v_threshold);
END;
$$;
COMMENT ON FUNCTION get_answer_gap_status() IS 'Returns answer gap status using NET gap calculation. Prevents deadlocks when both partners answer different questions.';
-- ============================================================================
-- Part 2: Update get_recommended_questions() to prioritize partner-answered questions
-- ============================================================================
--
-- Add ORDER BY to ensure:
-- 1. Questions partner has already answered come first (helps resolve gaps)
-- 2. Lower intensity questions shown first within each group
CREATE OR REPLACE FUNCTION get_recommended_questions(target_pack_id UUID DEFAULT NULL)
RETURNS TABLE(
id UUID,
text TEXT,
partner_text TEXT,
is_two_part BOOLEAN,
pack_id UUID,
intensity INTEGER,
partner_answered BOOLEAN,
allowed_couple_genders TEXT[],
target_user_genders TEXT[]
)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = 'public'
AS $$
DECLARE
v_user_id UUID;
v_couple_id UUID;
v_partner_id UUID;
v_user_gender TEXT;
v_partner_gender TEXT;
v_couple_type TEXT;
v_has_premium BOOLEAN;
v_gap_threshold INTEGER;
v_my_unanswered INTEGER;
v_partner_unanswered INTEGER;
v_net_gap INTEGER;
v_user_max_intensity INTEGER;
v_daily_limit INTEGER;
v_daily_count INTEGER;
v_today_start TIMESTAMPTZ;
v_tomorrow_start TIMESTAMPTZ;
BEGIN
v_user_id := auth.uid();
SELECT p.couple_id, p.gender, COALESCE(p.max_intensity, 5)
INTO v_couple_id, v_user_gender, v_user_max_intensity
FROM public.profiles p
WHERE p.id = v_user_id;
IF v_couple_id IS NULL THEN
RETURN;
END IF;
SELECT p.id, p.gender INTO v_partner_id, v_partner_gender
FROM public.profiles p
WHERE p.couple_id = v_couple_id AND p.id != v_user_id
LIMIT 1;
-- Check premium access (used for both answer gap and daily limit)
v_has_premium := public.has_premium_access(v_user_id);
-- Check daily response limit (only when not browsing a specific pack and user is non-premium)
IF v_partner_id IS NOT NULL AND target_pack_id IS NULL AND NOT v_has_premium THEN
-- Get daily limit from app_config
SELECT COALESCE(ac.daily_response_limit, 0) INTO v_daily_limit
FROM app_config ac
LIMIT 1;
-- If daily limit > 0, check if user has reached it
IF v_daily_limit > 0 THEN
-- Calculate UTC day boundaries
v_today_start := date_trunc('day', NOW() AT TIME ZONE 'UTC') AT TIME ZONE 'UTC';
v_tomorrow_start := v_today_start + INTERVAL '1 day';
-- Count responses created today (UTC)
SELECT COUNT(*)::INTEGER INTO v_daily_count
FROM responses r
WHERE r.user_id = v_user_id
AND r.created_at >= v_today_start
AND r.created_at < v_tomorrow_start;
-- If over daily limit, return empty (old apps see "All caught up!")
IF v_daily_count >= v_daily_limit THEN
RETURN;
END IF;
END IF;
END IF;
-- Check answer gap threshold using NET gap (only when not browsing a specific pack)
IF v_partner_id IS NOT NULL AND target_pack_id IS NULL THEN
-- Get threshold from app_config
SELECT COALESCE(ac.answer_gap_threshold, 10) INTO v_gap_threshold
FROM app_config ac
LIMIT 1;
-- If threshold > 0, check the NET gap
IF v_gap_threshold > 0 THEN
-- Calculate NET gap to prevent deadlocks
WITH enabled_packs AS (
SELECT cp.pack_id
FROM couple_packs cp
WHERE cp.couple_id = v_couple_id AND cp.enabled = true
)
SELECT
-- Questions I answered that partner hasn't
COALESCE(SUM(CASE WHEN r.user_id = v_user_id AND NOT EXISTS (
SELECT 1 FROM responses pr
WHERE pr.question_id = r.question_id
AND pr.user_id = v_partner_id
) THEN 1 ELSE 0 END), 0)::integer,
-- Questions partner answered that I haven't
COALESCE(SUM(CASE WHEN r.user_id = v_partner_id AND NOT EXISTS (
SELECT 1 FROM responses pr
WHERE pr.question_id = r.question_id
AND pr.user_id = v_user_id
) THEN 1 ELSE 0 END), 0)::integer
INTO v_my_unanswered, v_partner_unanswered
FROM responses r
INNER JOIN questions q ON q.id = r.question_id
WHERE r.couple_id = v_couple_id
AND r.user_id IN (v_user_id, v_partner_id)
AND EXISTS (SELECT 1 FROM enabled_packs)
AND q.pack_id IN (SELECT ep.pack_id FROM enabled_packs ep);
-- Net gap: only positive values mean user is truly ahead
v_net_gap := GREATEST(0, v_my_unanswered - v_partner_unanswered);
-- If over threshold, return empty (old apps see "All caught up!")
IF v_net_gap >= v_gap_threshold THEN
RETURN;
END IF;
END IF;
END IF;
IF v_user_gender IS NOT NULL AND v_partner_gender IS NOT NULL THEN
IF v_user_gender < v_partner_gender THEN
v_couple_type := v_user_gender || '+' || v_partner_gender;
ELSE
v_couple_type := v_partner_gender || '+' || v_user_gender;
END IF;
END IF;
RETURN QUERY
WITH user_config_exists AS (
SELECT 1 FROM public.couple_packs WHERE couple_id = v_couple_id LIMIT 1
),
active_packs AS (
SELECT target_pack_id AS pack_id
WHERE target_pack_id IS NOT NULL
AND EXISTS (
SELECT 1 FROM public.question_packs qp
WHERE qp.id = target_pack_id
AND (qp.is_premium = FALSE OR v_has_premium = TRUE)
)
UNION ALL
SELECT cp.pack_id FROM public.couple_packs cp
INNER JOIN public.question_packs qp ON qp.id = cp.pack_id
WHERE target_pack_id IS NULL
AND cp.couple_id = v_couple_id
AND cp.enabled = TRUE
AND (qp.is_premium = FALSE OR v_has_premium = TRUE)
AND EXISTS (SELECT 1 FROM user_config_exists)
UNION ALL
SELECT p.id AS pack_id FROM public.question_packs p
WHERE target_pack_id IS NULL
AND p.is_public = TRUE
AND (p.is_premium = FALSE OR v_has_premium = TRUE)
AND NOT EXISTS (SELECT 1 FROM user_config_exists)
)
SELECT
q.id,
CASE
WHEN q.partner_text IS NOT NULL AND EXISTS (
SELECT 1 FROM public.responses r
WHERE r.question_id = q.id
AND r.couple_id = v_couple_id
AND r.user_id != v_user_id
) THEN q.partner_text
ELSE q.text
END as text,
q.partner_text,
q.partner_text IS NOT NULL as is_two_part,
q.pack_id,
q.intensity,
EXISTS (
SELECT 1 FROM public.responses r
WHERE r.question_id = q.id
AND r.couple_id = v_couple_id
AND r.user_id != v_user_id
) AS partner_answered,
q.allowed_couple_genders,
q.target_user_genders
FROM public.questions q
WHERE q.pack_id IN (SELECT ap.pack_id FROM active_packs ap)
AND NOT EXISTS (
SELECT 1 FROM public.responses r
WHERE r.question_id = q.id
AND r.user_id = v_user_id
)
AND (
q.allowed_couple_genders IS NULL
OR v_couple_type IS NULL
OR v_couple_type = ANY(q.allowed_couple_genders)
)
AND (
q.target_user_genders IS NULL
OR v_user_gender = ANY(q.target_user_genders)
OR EXISTS (
SELECT 1 FROM public.responses r
WHERE r.question_id = q.id
AND r.couple_id = v_couple_id
AND r.user_id != v_user_id
)
)
-- Filter by user's max_intensity preference
AND (
q.intensity IS NULL
OR q.intensity <= v_user_max_intensity
)
-- ORDER BY: Partner-answered questions first to help resolve gaps, then by intensity
ORDER BY
EXISTS (
SELECT 1 FROM public.responses r
WHERE r.question_id = q.id
AND r.couple_id = v_couple_id
AND r.user_id != v_user_id
) DESC,
q.intensity ASC;
END;
$$;
COMMENT ON FUNCTION get_recommended_questions(UUID) IS 'Returns recommended questions prioritizing partner-answered questions first to prevent deadlocks. Uses NET gap calculation for blocking.';
@@ -0,0 +1,101 @@
-- Add delayed pack change notification system
-- Notifies partner when a user enables new question packs (with 30-min debounce)
-- Create pending notifications table
CREATE TABLE pending_pack_notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
couple_id UUID NOT NULL REFERENCES couples(id) ON DELETE CASCADE,
changed_by_user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
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_pack_notifications_notify_at
ON pending_pack_notifications(notify_at);
-- Enable RLS
ALTER TABLE pending_pack_notifications ENABLE ROW LEVEL SECURITY;
-- RLS policies
CREATE POLICY "Users can view their couple's pending notifications"
ON pending_pack_notifications FOR SELECT
USING (couple_id = get_auth_user_couple_id());
CREATE POLICY "Super admins can view all pending notifications"
ON pending_pack_notifications FOR SELECT
USING (is_super_admin());
-- Updated_at trigger
CREATE TRIGGER update_pending_pack_notifications_updated_at
BEFORE UPDATE ON pending_pack_notifications
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- Function to queue pack change notifications
CREATE OR REPLACE FUNCTION queue_pack_change_notification()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = 'public'
AS $$
DECLARE
v_user_id UUID;
BEGIN
-- Only trigger when enabling a pack (not disabling)
IF NEW.enabled = false THEN
RETURN NEW;
END IF;
-- For UPDATE, only proceed if changing from disabled to enabled
IF TG_OP = 'UPDATE' AND OLD.enabled = true THEN
RETURN NEW;
END IF;
-- Get the user making the change
v_user_id := auth.uid();
-- Skip if no auth context (service role operations)
IF v_user_id IS NULL THEN
RETURN NEW;
END IF;
-- UPSERT: Insert new notification or reset timer on existing one
INSERT INTO pending_pack_notifications (
couple_id,
changed_by_user_id,
notify_at
)
VALUES (
NEW.couple_id,
v_user_id,
now() + interval '30 minutes'
)
ON CONFLICT (couple_id) DO UPDATE SET
changed_by_user_id = EXCLUDED.changed_by_user_id,
notify_at = now() + interval '30 minutes',
updated_at = now();
RETURN NEW;
END;
$$;
-- Create trigger on couple_packs
CREATE TRIGGER on_couple_pack_enabled
AFTER INSERT OR UPDATE OF enabled ON couple_packs
FOR EACH ROW
EXECUTE FUNCTION queue_pack_change_notification();
-- Schedule cron job (runs every 5 minutes)
SELECT cron.schedule(
'send-pack-change-notifications',
'*/5 * * * *',
$$
SELECT net.http_post(
url := 'https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/send-pack-change-notification',
headers := jsonb_build_object('Content-Type', 'application/json'),
body := '{}'::jsonb
)
$$
);
+21
View File
@@ -0,0 +1,21 @@
{
"cli": {
"version": ">= 16.28.0",
"appVersionSource": "remote"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal"
},
"production": {
"autoIncrement": true
}
},
"submit": {
"production": {}
}
}
+935 -348
View File
File diff suppressed because it is too large. Load diff