feat(backend): add standalone data plane

Replace the Supabase application data plane with the Node/PostgreSQL API, migrate mobile, admin, web, and MCP callers, add controlled cutover tooling, and define hardened Dokploy infrastructure.
This commit is contained in:
jjbrunton committed 2026-08-28 14:42:11 +01:00
1 parent 01bb8225ae
commit 006761d3b0
294 files changed
+16259 -6138

No files matched your search

+15 -4
View File
@@ -1,7 +1,18 @@
# Supabase configuration (required)
# Get these from your Supabase project dashboard: Settings > API
EXPO_PUBLIC_SUPABASE_URL=
EXPO_PUBLIC_SUPABASE_ANON_KEY=
# Hosted Supabase Auth configuration (required). These public values are used
# only for identity; product data always uses EXPO_PUBLIC_API_URL.
# Local/E2E builds may use only the designated non-production Auth project.
EXPO_PUBLIC_SUPABASE_URL=
EXPO_PUBLIC_SUPABASE_ANON_KEY=
# Standalone Sauci product API (required). Supabase is used for Auth only.
# iOS simulator: http://127.0.0.1:3003
# Android emulator: http://10.0.2.2:3003
EXPO_PUBLIC_API_URL=http://127.0.0.1:3003
# Authenticated Maestro additionally reads E2E_EMAIL and E2E_PASSWORD from the
# invoking shell. Do not put those credentials in this Expo environment file or
# commit them. The test user must be disposable, onboarding-complete, and have a
# matching profile in the standalone non-production PostgreSQL database.
# Sentry error tracking (optional - get DSN from https://sentry.io)
EXPO_PUBLIC_SENTRY_DSN=
+43 -166
View File
@@ -5,14 +5,13 @@ import { BlurView } from "expo-blur";
import { useEffect, useState, useCallback, useRef } from "react";
import { useAuthStore, useMatchStore, useMessageStore, useSubscriptionStore, usePacksStore, useStreakStore } from "../../src/store";
import { colors, radius, spacing, typography, shadows } from "../../src/theme";
import { supabase } from "../../src/lib/supabase";
import { authClient } from "../../src/lib/authClient";
import { isBiometricEnabled } from "../../src/lib/biometricAuth";
import { checkAndRegisterPushToken } from "../../src/lib/notifications";
import { syncBadgeCount } from "../../src/lib/badge";
import { BiometricLockScreen } from "../../src/components/BiometricLockScreen";
import { Events } from "../../src/lib/analytics";
import { needsOnboarding } from "../../src/constants/onboarding";
import type { MatchWithQuestion } from "../../src/types";
import type { Database } from "../../src/types/supabase";
type Message = Database["public"]["Tables"]["messages"]["Row"];
@@ -60,16 +59,18 @@ export default function AppLayout() {
const router = useRouter();
const segments = useSegments();
const { isAuthenticated, isLoading, user, signOut, updateLastActive } = useAuthStore();
const { matches, newMatchesCount, addMatch, updateMatchUnreadCount, pendingQuestions, fetchPendingQuestions } = useMatchStore();
const { unreadCount, lastMessage, fetchUnreadCount, addMessage, clearLastMessage } = useMessageStore();
const { newMatchesCount, pendingQuestions, fetchPendingQuestions, fetchMatches } = useMatchStore();
const { unreadCount, lastMessage, fetchUnreadCount, clearLastMessage } = useMessageStore();
const { fetchEnabledPacks } = usePacksStore();
const { initializeRevenueCat } = useSubscriptionStore();
const { initializeRevenueCat, refreshSubscriptionStatus } = useSubscriptionStore();
const { fetchStreak } = useStreakStore();
const messageToastAnim = useRef(new Animated.Value(-100)).current;
const messageTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [isLocked, setIsLocked] = useState(false);
const hasCheckedInitialBiometric = useRef(false);
const wentToBackgroundAt = useRef<number | null>(null);
const knownMatchIdsRef = useRef<Set<string>>(new Set());
const matchPollInitializedRef = useRef(false);
// Check if we're on screens that should hide the tab bar
const segmentStrings = segments as string[];
@@ -92,7 +93,7 @@ export default function AppLayout() {
// Check session validity on mount and when app comes to foreground
useEffect(() => {
const checkSession = async () => {
const { data: { session } } = await supabase.auth.getSession();
const { data: { session } } = await authClient.auth.getSession();
if (!session) {
// No valid session - sign out to clear local state
signOut();
@@ -111,13 +112,16 @@ export default function AppLayout() {
checkSession();
// Update last active timestamp when app comes to foreground
updateLastActive();
if (user?.id && Platform.OS !== "web") {
void refreshSubscriptionStatus();
}
}
});
return () => {
subscription.remove();
};
}, [signOut, updateLastActive]);
}, [signOut, updateLastActive, user?.id, refreshSubscriptionStatus]);
// Handle biometric lock when app goes to background/foreground
useEffect(() => {
@@ -210,171 +214,44 @@ export default function AppLayout() {
}
}, [user?.id, user?.onboarding_completed]);
// Subscribe to realtime changes on the current user's profile
// This detects when partner disconnects (sets our couple_id to NULL)
// Poll the self-hosted API for profile, match, pending-question and pack changes.
// This replaces the former Supabase Postgres change subscriptions.
useEffect(() => {
if (!user?.id) return;
const channel = supabase
.channel(`profile:${user.id}`)
.on(
"postgres_changes",
{
event: "UPDATE",
schema: "public",
table: "profiles",
filter: `id=eq.${user.id}`,
},
async (payload) => {
const newProfile = payload.new as { couple_id: string | null };
// If couple_id changed (especially to NULL when partner disconnects)
if (newProfile.couple_id !== user.couple_id) {
console.log("Profile couple_id changed, refreshing user data...");
await useAuthStore.getState().fetchUser();
}
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
let cancelled = false;
matchPollInitializedRef.current = false;
knownMatchIdsRef.current = new Set();
const poll = async () => {
await Promise.allSettled([
useAuthStore.getState().fetchUser(),
...(user.couple_id ? [fetchMatches(true), fetchPendingQuestions(), fetchEnabledPacks()] : []),
]);
if (cancelled || !user.couple_id) return;
const refreshed = useMatchStore.getState().matches;
if (matchPollInitializedRef.current) {
const added = refreshed.filter(item => !knownMatchIdsRef.current.has(item.id));
for (const item of added) Events.matchCreated(item.match_type || "unknown");
const previousCount = knownMatchIdsRef.current.size;
if (previousCount === 0 && added.length > 0) Events.firstMatch();
else if (added.length > 0 && refreshed.length % 10 === 0) Events.milestoneMatch(refreshed.length);
}
knownMatchIdsRef.current = new Set(refreshed.map(item => item.id));
matchPollInitializedRef.current = true;
};
}, [user?.id, user?.couple_id]);
// Subscribe to realtime match notifications (for badge updates and analytics)
void poll();
const timer = setInterval(() => void poll(), 5_000);
return () => { cancelled = true; clearInterval(timer); };
}, [user?.id, user?.couple_id, fetchMatches, fetchPendingQuestions, fetchEnabledPacks]);
// Poll the self-hosted API for unread chat state. Chat screens perform their
// own message polling; this keeps the global badge current without Supabase Realtime.
useEffect(() => {
if (!user?.couple_id) return;
const channel = supabase
.channel(`matches:${user.couple_id}`)
.on(
"postgres_changes",
{
event: "INSERT",
schema: "public",
table: "matches",
filter: `couple_id=eq.${user.couple_id}`,
},
async (payload) => {
// Fetch the full match with question details
const { data: matchWithQuestion } = await supabase
.from("matches")
.select(`
*,
question:questions(*)
`)
.eq("id", payload.new.id)
.single();
if (matchWithQuestion && matchWithQuestion.question) {
// Track milestone events before adding (so count is accurate)
const currentMatchCount = matches.length;
if (currentMatchCount === 0) {
Events.firstMatch();
} else if ((currentMatchCount + 1) % 10 === 0) {
Events.milestoneMatch(currentMatchCount + 1);
}
// Add to store (updates badge count via newMatchesCount)
addMatch(matchWithQuestion);
Events.matchCreated(matchWithQuestion.match_type || "unknown");
// Note: No modal popup - user will see badge on matches tab
// Confetti animation is shown inline on swipe screen when user creates match
}
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [user?.couple_id, addMatch, matches.length]);
// Subscribe to realtime message notifications
useEffect(() => {
if (!user?.couple_id) return;
const channel = supabase
.channel(`messages:${user.couple_id}`)
.on(
"postgres_changes",
{
event: "INSERT",
schema: "public",
table: "messages",
},
async (payload) => {
const newMessage = payload.new as Message;
// Only process messages from partner
if (newMessage.user_id === user.id) return;
// Mark as delivered (message received by device)
if (!newMessage.delivered_at) {
await supabase
.from("messages")
.update({ delivered_at: new Date().toISOString() })
.eq("id", newMessage.id);
}
// Fetch the match details for context
const { data: match } = await supabase
.from("matches")
.select("id, question:questions(text)")
.eq("id", newMessage.match_id)
.single();
if (match) {
// Handle the joined question data from the query
const questionData = match.question;
const questionText = Array.isArray(questionData)
? questionData[0]?.text
: (questionData as { text: string } | null)?.text;
addMessage({
...newMessage,
delivered_at: newMessage.delivered_at || new Date().toISOString(),
match: { id: match.id, question: { text: questionText || "" } },
});
// Sync per-match unread count for realtime updates
updateMatchUnreadCount(match.id, 1);
}
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [user?.couple_id, user?.id, addMessage]);
// Subscribe to realtime pack setting changes (when partner toggles a pack)
useEffect(() => {
if (!user?.couple_id) return;
const channel = supabase
.channel(`couple_packs:${user.couple_id}`)
.on(
"postgres_changes",
{
event: "*", // INSERT, UPDATE, DELETE
schema: "public",
table: "couple_packs",
filter: `couple_id=eq.${user.couple_id}`,
},
() => {
// Refetch enabled packs when partner changes pack settings
fetchEnabledPacks();
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [user?.couple_id, fetchEnabledPacks]);
const timer = setInterval(() => void fetchUnreadCount(), 3_000);
return () => clearInterval(timer);
}, [user?.couple_id, fetchUnreadCount]);
// Animate message toast when lastMessage changes
useEffect(() => {
+13 -23
View File
@@ -28,7 +28,7 @@ import { GlassToggle } from '../../src/components/ui/GlassToggle';
import { Paywall } from '../../src/components/paywall';
import { colors, gradients, spacing, typography, radius, shadows } from '../../src/theme';
import { useAuthStore } from '../../src/store';
import { supabase } from '../../src/lib/supabase';
import { profileSettingsApi } from '../../src/lib/profileSettingsApi';
import { getProfileError } from '../../src/lib/errors';
import { registerForPushNotificationsAsync, savePushToken } from '../../src/lib/notifications';
import { Events } from '../../src/lib/analytics';
@@ -183,36 +183,26 @@ export default function OnboardingScreen() {
}
// Derive intensity from hide_nsfw for backwards compatibility
const maxIntensity = hideNsfw ? NSFW_OFF_INTENSITY : NSFW_ON_INTENSITY;
const maxIntensity: 2 | 5 = hideNsfw ? NSFW_OFF_INTENSITY : NSFW_ON_INTENSITY;
const showExplicitContent = !hideNsfw;
const updatedFields: string[] = ["name", "gender", "usage_reason", "max_intensity", "show_explicit_content", "hide_nsfw"];
const updateData: Record<string, any> = {
name: name.trim(),
gender,
usage_reason: usageReason,
const updateData = {
name: name.trim(),
gender,
usage_reason: usageReason,
max_intensity: maxIntensity,
show_explicit_content: showExplicitContent,
hide_nsfw: hideNsfw,
onboarding_completed: true,
onboarding_version: REQUIRED_ONBOARDING_VERSION,
updated_at: new Date().toISOString(),
};
if (avatarUrl) {
updateData.avatar_url = avatarUrl;
updatedFields.push("avatar_url");
}
const { error: updateError } = await supabase
.from('profiles')
.update(updateData)
.eq('id', userId);
if (updateError) {
console.error('[Onboarding] Profile update error:', updateError);
throw updateError;
}
};
await profileSettingsApi.updateProfile(updateData);
if (avatarUrl) {
updatedFields.push("avatar_url");
}
console.log('[Onboarding] Profile updated successfully, fetching user...');
await fetchUser();
+13 -60
View File
@@ -17,7 +17,7 @@ import { LinearGradient } from "expo-linear-gradient";
import Animated, { FadeInDown } from "react-native-reanimated";
import * as Clipboard from "expo-clipboard";
import { useAuthStore } from "../../src/store";
import { supabase } from "../../src/lib/supabase";
import { coupleApi } from "../../src/lib/coupleApi";
import { getPairingError } from "../../src/lib/errors";
import { Events } from "../../src/lib/analytics";
import { router } from "expo-router";
@@ -25,7 +25,7 @@ import { GradientBackground, GlassCard, GlassButton } from "../../src/components
import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme";
export default function PairingScreen() {
const { user, fetchCouple, fetchUser, couple, partner, isLoading: isAuthLoading } = useAuthStore();
const { fetchCouple, fetchUser, couple, partner, isLoading: isAuthLoading } = useAuthStore();
const [inviteCode, setInviteCode] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -36,51 +36,25 @@ export default function PairingScreen() {
}
}, [couple, partner]);
// Subscribe to real-time updates for partner joining + polling fallback
useEffect(() => {
if (!couple || partner) return;
// Poll the standalone API while waiting for a partner. Realtime delivery can
// be added later without coupling product data back to Supabase.
useEffect(() => {
if (!couple || partner) return;
// Poll every 5 seconds as fallback
const pollInterval = setInterval(() => {
fetchCouple();
}, 5000);
// Listen for new profiles joining this couple
const subscription = supabase
.channel(`couple-${couple.id}`)
.on(
"postgres_changes",
{
event: "UPDATE",
schema: "public",
table: "profiles",
filter: `couple_id=eq.${couple.id}`,
},
async (payload) => {
// Someone updated their profile to join this couple
if (payload.new.id !== user?.id) {
await fetchCouple();
}
}
)
.subscribe();
return () => {
clearInterval(pollInterval);
subscription.unsubscribe();
};
}, [couple, partner, user?.id, fetchCouple]);
return () => {
clearInterval(pollInterval);
};
}, [couple, partner, fetchCouple]);
const handleCreateCouple = async () => {
setIsSubmitting(true);
try {
const { data, error } = await supabase.functions.invoke("manage-couple", {
method: "POST",
body: {},
});
if (error) throw error;
if (data.error) throw new Error(data.error);
await coupleApi.create();
await fetchUser(); // Refresh user to get couple_id
await fetchCouple(); // Fetch couple data
@@ -102,23 +76,7 @@ export default function PairingScreen() {
setIsSubmitting(true);
try {
// Refresh session to ensure we have a valid token
const { data: { session }, error: sessionError } = await supabase.auth.getSession();
if (sessionError || !session) {
throw new Error("Please log in again");
}
const { data, error } = await supabase.functions.invoke("manage-couple", {
method: "POST",
body: { invite_code: sanitizedCode },
});
if (error) {
// Extract user-friendly error message
const errorMessage = data?.error || "Unable to join couple. Please try again.";
throw new Error(errorMessage);
}
if (data?.error) throw new Error(data.error);
await coupleApi.join(sanitizedCode);
await fetchUser(); // Refresh user to get couple_id
await fetchCouple(); // Fetch couple data
@@ -186,12 +144,7 @@ export default function PairingScreen() {
onPress: async () => {
setIsSubmitting(true);
try {
const { data, error } = await supabase.functions.invoke("manage-couple", {
method: "DELETE",
});
if (error) throw error;
if (data?.error) throw new Error(data.error);
await coupleApi.cancel();
await fetchUser();
await fetchCouple();
+3 -3
View File
@@ -16,7 +16,7 @@ import Animated, {
FadeInUp,
} from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons";
import { supabase } from "../../src/lib/supabase";
import { authClient } from "../../src/lib/authClient";
import { getAuthError } from "../../src/lib/errors";
import { GradientBackground, GlassCard, GlassButton, GlassInput } from "../../src/components/ui";
import { colors, spacing, radius, typography } from "../../src/theme";
@@ -63,7 +63,7 @@ export default function ForgotPasswordScreen() {
}
setIsLoading(true);
const { error: resetError } = await supabase.auth.resetPasswordForEmail(
const { error: resetError } = await authClient.auth.resetPasswordForEmail(
email.trim(),
{
redirectTo: Linking.createURL("/(auth)/reset-password"),
@@ -83,7 +83,7 @@ export default function ForgotPasswordScreen() {
if (resendCooldown > 0) return;
setIsResending(true);
const { error: resendError } = await supabase.auth.resetPasswordForEmail(
const { error: resendError } = await authClient.auth.resetPasswordForEmail(
email.trim(),
{
redirectTo: Linking.createURL("/(auth)/reset-password"),
+11 -11
View File
@@ -43,7 +43,7 @@ if (Platform.OS === "android") {
GoogleSignin = null;
}
}
import { supabase } from "../../src/lib/supabase";
import { authClient } from "../../src/lib/authClient";
import { getAuthError } from "../../src/lib/errors";
import { Events } from "../../src/lib/analytics";
import { useAuthStore } from "../../src/store";
@@ -114,7 +114,7 @@ export default function LoginScreen() {
if (!pendingVerification || resendCooldown > 0) return;
setIsResendingEmail(true);
const { error: resendError } = await supabase.auth.resend({
const { error: resendError } = await authClient.auth.resend({
type: 'signup',
email: pendingVerification.email,
});
@@ -138,7 +138,7 @@ export default function LoginScreen() {
if (!pendingVerification) return;
setIsCheckingVerification(true);
const { error: signInError } = await supabase.auth.signInWithPassword({
const { error: signInError } = await authClient.auth.signInWithPassword({
email: pendingVerification.email,
password: pendingVerification.password,
});
@@ -159,7 +159,7 @@ export default function LoginScreen() {
// Listen for auth state changes (e.g., when user verifies in another tab/browser)
useEffect(() => {
const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => {
const { data: { subscription } } = authClient.auth.onAuthStateChange((event, session) => {
if (event === 'SIGNED_IN' && session) {
// User verified and signed in - clear pending state
setPendingVerification(null);
@@ -191,7 +191,7 @@ export default function LoginScreen() {
}
setIsLoading(true);
const { error: authError } = await supabase.auth.signInWithOtp({
const { error: authError } = await authClient.auth.signInWithOtp({
email: email.trim(),
options: {
emailRedirectTo: Linking.createURL("/(auth)/login"),
@@ -226,7 +226,7 @@ export default function LoginScreen() {
setIsLoading(true);
if (isSignUp) {
const { data, error: signUpError } = await supabase.auth.signUp({
const { data, error: signUpError } = await authClient.auth.signUp({
email: email.trim(),
password,
options: {
@@ -251,7 +251,7 @@ export default function LoginScreen() {
Events.signUp("password");
}
} else {
const { error: signInError } = await supabase.auth.signInWithPassword({
const { error: signInError } = await authClient.auth.signInWithPassword({
email: email.trim(),
password,
});
@@ -290,7 +290,7 @@ export default function LoginScreen() {
console.log("[Apple Sign In] Got credential:", !!credential, "identityToken:", !!credential?.identityToken);
if (credential.identityToken) {
const { error } = await supabase.auth.signInWithIdToken({
const { error } = await authClient.auth.signInWithIdToken({
provider: "apple",
token: credential.identityToken,
});
@@ -315,7 +315,7 @@ export default function LoginScreen() {
} else {
console.log("[Apple Sign In] Falling back to OAuth");
// Fallback to web OAuth when native module unavailable
const { error } = await supabase.auth.signInWithOAuth({
const { error } = await authClient.auth.signInWithOAuth({
provider: "apple",
options: {
redirectTo: Linking.createURL("/(auth)/login"),
@@ -342,7 +342,7 @@ export default function LoginScreen() {
if (response.type === "success" && response.data.idToken) {
console.log("[Google Sign In] Got idToken, signing in with Supabase...");
const { error } = await supabase.auth.signInWithIdToken({
const { error } = await authClient.auth.signInWithIdToken({
provider: "google",
token: response.data.idToken,
});
@@ -368,7 +368,7 @@ export default function LoginScreen() {
const handleGuestSignIn = async () => {
clearError();
setIsGuestLoading(true);
const { error: authError } = await supabase.auth.signInAnonymously();
const { error: authError } = await authClient.auth.signInAnonymously();
setIsGuestLoading(false);
if (authError) {
+2 -2
View File
@@ -15,7 +15,7 @@ import Animated, {
FadeInUp,
} from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons";
import { supabase } from "../../src/lib/supabase";
import { authClient } from "../../src/lib/authClient";
import { getAuthError } from "../../src/lib/errors";
import { GradientBackground, GlassCard, GlassButton, GlassInput } from "../../src/components/ui";
import { colors, spacing, radius, typography } from "../../src/theme";
@@ -54,7 +54,7 @@ export default function ResetPasswordScreen() {
}
setIsLoading(true);
const { error: updateError } = await supabase.auth.updateUser({
const { error: updateError } = await authClient.auth.updateUser({
password,
});
setIsLoading(false);
+6 -6
View File
@@ -43,7 +43,7 @@ const GestureHandlerRootView = Platform.OS === "web"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as Linking from "expo-linking";
import { useAuthStore } from "../src/store";
import { supabase } from "../src/lib/supabase";
import { authClient } from "../src/lib/authClient";
import { colors, spacing, radius, typography } from "../src/theme";
import { hasSeenGuestAccountWarning, markGuestAccountWarningSeen } from "../src/lib/guestAccountWarningSeen";
@@ -148,7 +148,7 @@ export default function RootLayout() {
if (accessToken && refreshToken) {
// OAuth flow - set session directly
console.log('[DeepLink] Setting session with tokens');
const { data, error } = await supabase.auth.setSession({
const { data, error } = await authClient.auth.setSession({
access_token: accessToken,
refresh_token: refreshToken,
});
@@ -160,7 +160,7 @@ export default function RootLayout() {
} else if (tokenHash && type) {
// Magic link / email verification flow - verify the OTP
console.log('[DeepLink] Verifying OTP with token_hash and type:', type);
const { data, error } = await supabase.auth.verifyOtp({
const { data, error } = await authClient.auth.verifyOtp({
token_hash: tokenHash,
type: type as 'signup' | 'magiclink' | 'recovery' | 'invite' | 'email',
});
@@ -174,12 +174,12 @@ export default function RootLayout() {
} else {
// Fallback for web or other scenarios
console.log('[DeepLink] No auth params found, falling back to getSession');
await supabase.auth.getSession();
await authClient.auth.getSession();
}
} catch (error) {
console.error('[DeepLink] Error handling deep link:', error);
// Still try to get session as fallback
await supabase.auth.getSession();
await authClient.auth.getSession();
}
};
@@ -206,7 +206,7 @@ export default function RootLayout() {
});
// Listen for auth changes
const { data: { subscription: authSubscription } } = supabase.auth.onAuthStateChange(
const { data: { subscription: authSubscription } } = authClient.auth.onAuthStateChange(
async (event, session) => {
console.log('[Auth] onAuthStateChange:', event, 'hasSession:', !!session, 'userId:', session?.user?.id);
if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED" || event === "USER_UPDATED") {
+3 -9
View File
@@ -3,7 +3,7 @@ import { useLocalSearchParams, useRouter } from "expo-router";
import { useEffect, useState } from "react";
import { LinearGradient } from "expo-linear-gradient";
import { BlurView } from "expo-blur";
import { supabase } from "../../src/lib/supabase";
import { appDataApi } from "../../src/lib/appDataApi";
import { usePacksStore, useSubscriptionStore, useAuthStore } from "../../src/store";
import { Ionicons } from "@expo/vector-icons";
import Animated, {
@@ -69,14 +69,8 @@ export default function PackDetailsScreen() {
const fetchPackDetails = async () => {
try {
const { data, error } = await supabase
.from("questions")
.select("id, pack_id, text, partner_text, intensity, allowed_couple_genders, target_user_genders, created_at")
.eq("pack_id", id)
.order("created_at");
if (error) throw error;
setQuestions(data || []);
const { questions } = await appDataApi.packQuestions(String(id));
setQuestions(questions);
} catch (error) {
console.error("Error fetching questions:", error);
} finally {
+15
View File
@@ -52,6 +52,21 @@ target 'Sauci' do
:ccache_enabled => podfile_properties['apple.ccacheEnabled'] == 'true',
)
# The fmt version bundled with React Native 0.76 enables consteval for
# Apple Clang, but Apple Clang 21 (Xcode 26.4+) rejects fmt's FMT_STRING
# call sites. Patch the generated pod until React Native supplies a newer
# fmt release. Keeping this here makes clean pod installs reproducible.
fmt_base_header = File.join(__dir__, 'Pods', 'fmt', 'include', 'fmt', 'base.h')
if File.exist?(fmt_base_header)
fmt_base_source = File.read(fmt_base_header)
fmt_base_source.sub!(
/#elif defined\(__apple_build_version__\) && __apple_build_version__ < 14000029L/,
'#elif defined(__apple_build_version__)'
)
File.chmod(0644, fmt_base_header)
File.write(fmt_base_header, fmt_base_source)
end
# This is necessary for Xcode 14, because it signs resource bundles by default
# when building for devices.
installer.target_installation_results.pod_target_installation_results
+1 -1
View File
@@ -2798,6 +2798,6 @@ SPEC CHECKSUMS:
WidgetBridge: 15f1466d7f79505a5e600e1400fcff1a40f807a4
Yoga: 1259c7a8cbaccf7b4c3ddf8ee36ca11be9dee407
PODFILE CHECKSUM: 3746f9f348a0d8fcc1584151ac3b5c42e0fa172e
PODFILE CHECKSUM: fa3d5fa10e0ed7db5a11b50a5a66865694aea535
COCOAPODS: 1.16.2
@@ -0,0 +1,49 @@
import { act, renderHook, waitFor } from '@testing-library/react-native';
import { useDrawingSync } from '@/features/live-draw/hooks/useDrawingSync';
import { appDataApi } from '@/lib/appDataApi';
import { ApiError } from '@/lib/apiClient';
jest.mock('@/lib/appDataApi', () => ({ appDataApi: { getLiveDraw: jest.fn(), putLiveDraw: jest.fn() } }));
jest.mock('@/features/live-draw/utils/widgetBridge', () => ({ updateWidget: jest.fn() }));
const userId = '11111111-1111-4111-8111-111111111111';
const stroke = { id: 's1', userId, points: [{ x: 0.2, y: 0.3 }], color: '#fff', width: 2, timestamp: 1, isEraser: false };
describe('useDrawingSync API polling', () => {
beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); });
afterEach(() => jest.useRealTimers());
it('loads, polls by revision, and persists complete drawing state', async () => {
(appDataApi.getLiveDraw as jest.Mock)
.mockResolvedValueOnce({ strokes: [], revision: 1, updated_at: 'one', updated_by: userId })
.mockResolvedValueOnce({ strokes: [stroke], revision: 2, updated_at: 'two', updated_by: userId });
(appDataApi.putLiveDraw as jest.Mock).mockResolvedValue({ strokes: [stroke], revision: 3, updated_at: 'three', updated_by: userId });
const onLoadStrokes = jest.fn();
const { result } = renderHook(() => useDrawingSync({
coupleId: 'couple', userId, onStrokeStart: jest.fn(), onStrokeContinue: jest.fn(),
onStrokeEnd: jest.fn(), onClearCanvas: jest.fn(), onUndo: jest.fn(), onRedo: jest.fn(), onLoadStrokes,
}));
await waitFor(() => expect(onLoadStrokes).toHaveBeenCalledWith([]));
await act(async () => { jest.advanceTimersByTime(750); await Promise.resolve(); });
await waitFor(() => expect(onLoadStrokes).toHaveBeenCalledWith([stroke]));
await act(async () => result.current.persistStrokes([stroke]));
expect(appDataApi.putLiveDraw).toHaveBeenCalledWith([stroke], 2);
});
it('merges a concurrent partner stroke and retries from the returned revision', async () => {
const partnerStroke = { ...stroke, id: 'partner', userId: '22222222-2222-4222-8222-222222222222' };
const mine = { ...stroke, id: 'mine' };
(appDataApi.getLiveDraw as jest.Mock).mockResolvedValue({ strokes: [stroke], revision: 1, updated_at: 'one', updated_by: userId });
(appDataApi.putLiveDraw as jest.Mock)
.mockRejectedValueOnce(new ApiError('conflict', 409, { current_state: { strokes: [stroke, partnerStroke], revision: 2, updated_at: 'two', updated_by: partnerStroke.userId } }))
.mockResolvedValueOnce({ strokes: [stroke, partnerStroke, mine], revision: 3, updated_at: 'three', updated_by: userId });
const onLoadStrokes = jest.fn();
const { result } = renderHook(() => useDrawingSync({
coupleId: 'couple', userId, onStrokeStart: jest.fn(), onStrokeContinue: jest.fn(),
onStrokeEnd: jest.fn(), onClearCanvas: jest.fn(), onUndo: jest.fn(), onRedo: jest.fn(), onLoadStrokes,
}));
await waitFor(() => expect(onLoadStrokes).toHaveBeenCalledWith([stroke]));
await act(async () => result.current.persistStrokes([stroke, mine]));
expect(appDataApi.putLiveDraw).toHaveBeenNthCalledWith(1, [stroke, mine], 1);
expect(appDataApi.putLiveDraw).toHaveBeenNthCalledWith(2, [stroke, partnerStroke, mine], 2);
expect(onLoadStrokes).toHaveBeenLastCalledWith([stroke, partnerStroke, mine]);
});
});
@@ -1,197 +1,68 @@
import { renderHook, act, waitFor } from '@testing-library/react-native';
import { useFeatureInterest } from '@/hooks/useFeatureInterest';
import { useAuthStore } from '@/store/authStore';
import { supabase } from '@/lib/supabase';
function createThenableQuery(result: any) {
const query: any = {
select: jest.fn(() => query),
eq: jest.fn(() => query),
insert: jest.fn(() => query),
delete: jest.fn(() => query),
maybeSingle: jest.fn(() => query),
then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject),
};
return query;
}
describe('useFeatureInterest', () => {
beforeEach(() => {
jest.clearAllMocks();
useAuthStore.setState({ user: { id: 'user1' } } as any);
});
it('fetches initial interest status as true when interest exists', async () => {
const query = createThenableQuery({ data: { id: 'interest1' } });
(supabase.from as jest.Mock).mockReturnValue(query);
const { result } = renderHook(() => useFeatureInterest('dares'));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.isInterested).toBe(true);
expect(result.current.isAuthenticated).toBe(true);
expect(query.eq).toHaveBeenCalledWith('user_id', 'user1');
expect(query.eq).toHaveBeenCalledWith('feature_name', 'dares');
});
it('fetches initial interest status as false when no interest exists', async () => {
const query = createThenableQuery({ data: null });
(supabase.from as jest.Mock).mockReturnValue(query);
const { result } = renderHook(() => useFeatureInterest('dares'));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.isInterested).toBe(false);
});
it('returns isAuthenticated false when user is null', async () => {
useAuthStore.setState({ user: null } as any);
const { result } = renderHook(() => useFeatureInterest('dares'));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.isAuthenticated).toBe(false);
expect(result.current.isInterested).toBe(false);
expect(supabase.from).not.toHaveBeenCalled();
});
it('adds interest when toggling from not interested', async () => {
// Initial query returns no interest
const initialQuery = createThenableQuery({ data: null });
const insertQuery = createThenableQuery({ error: null });
(supabase.from as jest.Mock)
.mockReturnValueOnce(initialQuery) // Initial fetch
.mockReturnValueOnce(insertQuery); // Insert on toggle
const { result } = renderHook(() => useFeatureInterest('dares'));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.isInterested).toBe(false);
await act(async () => {
await result.current.toggleInterest();
});
expect(result.current.isInterested).toBe(true);
expect(insertQuery.insert).toHaveBeenCalledWith({
user_id: 'user1',
feature_name: 'dares',
});
});
it('removes interest when toggling from interested', async () => {
// Initial query returns existing interest
const initialQuery = createThenableQuery({ data: { id: 'interest1' } });
const deleteQuery = createThenableQuery({ error: null });
(supabase.from as jest.Mock)
.mockReturnValueOnce(initialQuery) // Initial fetch
.mockReturnValueOnce(deleteQuery); // Delete on toggle
const { result } = renderHook(() => useFeatureInterest('dares'));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.isInterested).toBe(true);
await act(async () => {
await result.current.toggleInterest();
});
expect(result.current.isInterested).toBe(false);
expect(deleteQuery.delete).toHaveBeenCalled();
expect(deleteQuery.eq).toHaveBeenCalledWith('user_id', 'user1');
expect(deleteQuery.eq).toHaveBeenCalledWith('feature_name', 'dares');
});
it('reverts optimistic update on error', async () => {
const initialQuery = createThenableQuery({ data: null });
const insertQuery = createThenableQuery({ error: new Error('Insert failed') });
(supabase.from as jest.Mock)
.mockReturnValueOnce(initialQuery)
.mockReturnValueOnce(insertQuery);
const { result } = renderHook(() => useFeatureInterest('dares'));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.isInterested).toBe(false);
await act(async () => {
await result.current.toggleInterest();
});
// Should revert to original state after error
expect(result.current.isInterested).toBe(false);
});
it('does not toggle when user is not authenticated', async () => {
useAuthStore.setState({ user: null } as any);
const { result } = renderHook(() => useFeatureInterest('dares'));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
await act(async () => {
await result.current.toggleInterest();
});
// Should not make any API calls
expect(supabase.from).not.toHaveBeenCalled();
});
it('isToggling is false when not toggling', async () => {
const initialQuery = createThenableQuery({ data: null });
(supabase.from as jest.Mock).mockReturnValueOnce(initialQuery);
const { result } = renderHook(() => useFeatureInterest('dares'));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.isToggling).toBe(false);
});
it('isToggling is false after toggle completes', async () => {
const initialQuery = createThenableQuery({ data: null });
const insertQuery = createThenableQuery({ error: null });
(supabase.from as jest.Mock)
.mockReturnValueOnce(initialQuery)
.mockReturnValueOnce(insertQuery);
const { result } = renderHook(() => useFeatureInterest('dares'));
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
await act(async () => {
await result.current.toggleInterest();
});
expect(result.current.isToggling).toBe(false);
});
});
import { act, renderHook, waitFor } from "@testing-library/react-native";
import { useFeatureInterest } from "@/hooks/useFeatureInterest";
import { apiClient } from "@/lib/apiClient";
import { useAuthStore } from "@/store/authStore";
describe("useFeatureInterest", () => {
beforeEach(() => {
jest.clearAllMocks();
useAuthStore.setState({ user: { id: "user1" } } as any);
});
it("reads interest state from the API", async () => {
const getSpy = jest.spyOn(apiClient, "get").mockResolvedValueOnce({ feature: "live draw", interested: true });
const { result } = renderHook(() => useFeatureInterest("live draw"));
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(getSpy).toHaveBeenCalledWith("/v1/me/feature-interests/live%20draw");
expect(result.current.isInterested).toBe(true);
expect(result.current.isAuthenticated).toBe(true);
});
it("does not call the API without a product user", async () => {
useAuthStore.setState({ user: null } as any);
const getSpy = jest.spyOn(apiClient, "get");
const { result } = renderHook(() => useFeatureInterest("dares"));
await waitFor(() => expect(result.current.isLoading).toBe(false));
expect(result.current.isAuthenticated).toBe(false);
expect(getSpy).not.toHaveBeenCalled();
});
it("adds interest through PUT", async () => {
jest.spyOn(apiClient, "get").mockResolvedValueOnce({ feature: "dares", interested: false });
const putSpy = jest.spyOn(apiClient, "put").mockResolvedValueOnce({ feature: "dares", interested: true });
const { result } = renderHook(() => useFeatureInterest("dares"));
await waitFor(() => expect(result.current.isLoading).toBe(false));
await act(async () => result.current.toggleInterest());
expect(putSpy).toHaveBeenCalledWith("/v1/me/feature-interests/dares");
expect(result.current.isInterested).toBe(true);
expect(result.current.isToggling).toBe(false);
});
it("removes interest through DELETE", async () => {
jest.spyOn(apiClient, "get").mockResolvedValueOnce({ feature: "dares", interested: true });
const deleteSpy = jest.spyOn(apiClient, "delete").mockResolvedValueOnce({ feature: "dares", interested: false });
const { result } = renderHook(() => useFeatureInterest("dares"));
await waitFor(() => expect(result.current.isLoading).toBe(false));
await act(async () => result.current.toggleInterest());
expect(deleteSpy).toHaveBeenCalledWith("/v1/me/feature-interests/dares");
expect(result.current.isInterested).toBe(false);
});
it("reverts its optimistic state when the API fails", async () => {
jest.spyOn(apiClient, "get").mockResolvedValueOnce({ feature: "dares", interested: false });
jest.spyOn(apiClient, "put").mockRejectedValueOnce(new Error("API unavailable"));
const { result } = renderHook(() => useFeatureInterest("dares"));
await waitFor(() => expect(result.current.isLoading).toBe(false));
await act(async () => result.current.toggleInterest());
expect(result.current.isInterested).toBe(false);
expect(result.current.isToggling).toBe(false);
});
});
@@ -1,7 +1,9 @@
import { Alert } from 'react-native';
import { renderHook, act } from '@testing-library/react-native';
import { useMediaUpload } from '@/features/chat/hooks/useMediaUpload';
import { supabase } from '@/lib/supabase';
import { uploadMedia } from '@/lib/mediaApi';
jest.mock('@/lib/mediaApi', () => ({ uploadMedia: jest.fn() }));
describe('useMediaUpload', () => {
beforeEach(() => {
@@ -13,20 +15,8 @@ describe('useMediaUpload', () => {
jest.restoreAllMocks();
});
it('uploads image and inserts message record', async () => {
jest.spyOn(Date, 'now').mockReturnValue(1700000000000);
jest.spyOn(Math, 'random').mockReturnValue(0.123456);
const upload: any = jest.fn(async () => ({ error: null }));
(supabase.storage.from as jest.Mock).mockReturnValue({ upload });
const insert: any = jest.fn(async () => ({ error: null }));
(supabase.from as jest.Mock).mockImplementation((table: string) => {
if (table === 'messages') {
return { insert };
}
return {};
});
it('uploads image and creates its message through the API', async () => {
(uploadMedia as jest.Mock).mockResolvedValue({ reference: 'media:11111111-1111-4111-8111-111111111111' });
const { result } = renderHook(() => useMediaUpload('match1', 'me'));
@@ -34,18 +24,8 @@ describe('useMediaUpload', () => {
await result.current.uploadMedia('file://photo.jpg', 'image');
});
expect(upload).toHaveBeenCalledTimes(1);
const [path, body, options] = (upload.mock.calls as any[])[0] as any[];
expect(path).toMatch(/^match1\/1700000000000_/);
expect(options).toEqual({ contentType: 'image/jpeg', upsert: false });
expect(body).toBeInstanceOf(ArrayBuffer);
expect(insert).toHaveBeenCalledTimes(1);
const insertPayload = (insert.mock.calls as any[])[0][0] as any;
expect(insertPayload.match_id).toBe('match1');
expect(insertPayload.user_id).toBe('me');
expect(insertPayload.media_type).toBe('image');
expect(insertPayload.media_path).toBe(path);
expect(uploadMedia).toHaveBeenCalledWith('file://photo.jpg', {
kind: 'chat', mimeType: 'image/jpeg', matchId: 'match1',
});
});
});
@@ -0,0 +1,56 @@
import { act, renderHook } from '@testing-library/react-native';
import { ActionSheetIOS, Alert, Platform } from 'react-native';
import * as Haptics from 'expo-haptics';
import { useMessageActions } from '@/features/chat/hooks/useMessageActions';
import { chatApi } from '@/lib/chatApi';
jest.mock('@/lib/chatApi', () => ({ chatApi: { deleteForSelf: jest.fn(), deleteForEveryone: jest.fn() } }));
describe('useMessageActions', () => {
beforeEach(() => {
jest.clearAllMocks();
(chatApi.deleteForSelf as jest.Mock).mockResolvedValue({ deleted: true });
(chatApi.deleteForEveryone as jest.Mock).mockResolvedValue({ deleted: true });
jest.spyOn(Alert, 'alert').mockImplementation(() => undefined);
});
it('guards self deletion without an authenticated user', async () => {
const { result } = renderHook(() => useMessageActions({ userId: undefined }));
await act(async () => result.current.deleteForSelf('message'));
expect(chatApi.deleteForSelf).not.toHaveBeenCalled();
});
it('delegates both deletion scopes to authenticated API routes', async () => {
const { result } = renderHook(() => useMessageActions({ userId: 'me' }));
await act(async () => result.current.deleteForSelf('message'));
await act(async () => result.current.deleteForEveryone('message'));
expect(chatApi.deleteForSelf).toHaveBeenCalledWith('message');
expect(chatApi.deleteForEveryone).toHaveBeenCalledWith('message');
});
it('converts deletion API failures into user-visible errors', async () => {
jest.spyOn(console, 'error').mockImplementation(() => undefined);
(chatApi.deleteForSelf as jest.Mock).mockRejectedValue(new Error('forbidden'));
const { result } = renderHook(() => useMessageActions({ userId: 'me' }));
await act(async () => result.current.deleteForSelf('message'));
expect(Alert.alert).toHaveBeenCalledWith('Error', 'Failed to delete message');
});
it('offers report for a partner message and ignores deleted messages', () => {
const onReport = jest.fn();
const actionSheet = jest.spyOn(ActionSheetIOS, 'showActionSheetWithOptions').mockImplementation((_options, callback) => callback(2));
const haptic = jest.spyOn(Haptics, 'impactAsync').mockResolvedValue();
const { result } = renderHook(() => useMessageActions({ userId: 'me', onReport }));
if (Platform.OS === 'ios') {
act(() => result.current.showDeleteOptions({ id: 'message', deleted_at: null } as any, false));
expect(actionSheet).toHaveBeenCalled();
expect(onReport).toHaveBeenCalledWith(expect.objectContaining({ id: 'message' }));
}
jest.clearAllMocks();
act(() => result.current.showDeleteOptions({ id: 'deleted', deleted_at: 'now' } as any, false));
expect(haptic).not.toHaveBeenCalled();
expect(onReport).not.toHaveBeenCalled();
});
});
@@ -1,160 +1,90 @@
import { renderHook, act, waitFor } from '@testing-library/react-native';
import { act, renderHook, waitFor } from '@testing-library/react-native';
import { useMessageSubscription } from '@/hooks/useMessageSubscription';
import { supabase } from '@/lib/supabase';
import { chatApi } from '@/lib/chatApi';
type ThenableResult = { data?: any; error?: any; count?: any };
jest.mock('@/lib/chatApi', () => ({ chatApi: { listMessages: jest.fn(), markRead: jest.fn() } }));
function createThenableQuery(result: ThenableResult) {
const query: any = {
select: jest.fn(() => query),
eq: jest.fn(() => query),
neq: jest.fn(() => query),
is: jest.fn(() => query),
in: jest.fn(() => query),
order: jest.fn(() => query),
update: jest.fn(() => query),
then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject),
};
const message = (id: string, user_id = 'partner') => ({ id, match_id: 'match1', user_id, content: id } as any);
return query;
}
describe('useMessageSubscription', () => {
describe('useMessageSubscription API polling', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-01T00:00:00.000Z'));
(chatApi.listMessages as jest.Mock).mockResolvedValue({ messages: [] });
(chatApi.markRead as jest.Mock).mockResolvedValue({ updated: 0, read_at: 'now' });
});
afterEach(() => jest.useRealTimers());
afterEach(() => {
jest.useRealTimers();
});
it('fetches initial messages and marks partner messages read', async () => {
const partnerMessage = {
id: 'm1',
match_id: 'match1',
user_id: 'partner',
created_at: '2024-01-01T00:00:00.000Z',
delivered_at: null,
read_at: null,
};
const fetchQuery = createThenableQuery({ data: [partnerMessage] });
const updateQuery = createThenableQuery({ data: null });
const deletionsQuery = createThenableQuery({ data: [] });
const fromMock = jest.fn((table: string) => {
if (table === 'message_deletions') return deletionsQuery;
if (table === 'messages') {
// If it's the first call to messages, it's fetch, otherwise update.
// But simplified for the test logic:
return fetchQuery;
}
return createThenableQuery({ data: null });
});
// Use mockReturnValueOnce to handle the sequence:
// 1. message_deletions (select)
// 2. messages (select)
// 3. messages (update)
const fromMockSequence = jest.fn()
.mockReturnValueOnce(deletionsQuery) // message_deletions select
.mockReturnValueOnce(fetchQuery) // messages select
.mockReturnValueOnce(updateQuery); // messages update
(supabase as any).from = fromMockSequence;
const channelMock: any = {
on: jest.fn(() => channelMock),
subscribe: jest.fn(() => channelMock),
};
(supabase as any).channel = jest.fn(() => channelMock);
(supabase as any).removeChannel = jest.fn();
const { result } = renderHook(() =>
useMessageSubscription({ matchId: 'match1', userId: 'me' })
it('fails closed without both a match and authenticated user', async () => {
const { result, rerender } = renderHook<ReturnType<typeof useMessageSubscription>, { matchId: string | undefined; userId: string | undefined }>(
({ matchId, userId }) => useMessageSubscription({ matchId, userId }),
{ initialProps: { matchId: undefined as string | undefined, userId: 'me' as string | undefined } },
);
expect(result.current.loading).toBe(false);
expect(chatApi.listMessages).not.toHaveBeenCalled();
rerender({ matchId: 'match1', userId: undefined });
expect(chatApi.listMessages).not.toHaveBeenCalled();
});
it('loads initial messages without treating them as newly arrived', async () => {
const onNewMessage = jest.fn();
(chatApi.listMessages as jest.Mock).mockResolvedValue({ messages: [message('m1')] });
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', onNewMessage }));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(fromMockSequence).toHaveBeenCalledWith('messages');
expect(updateQuery.update).toHaveBeenCalledWith(
expect.objectContaining({
delivered_at: expect.stringMatching(/^2024-01-01T00:00:00\./),
read_at: expect.stringMatching(/^2024-01-01T00:00:00\./),
})
);
expect(updateQuery.in).toHaveBeenCalledWith('id', ['m1']);
expect(result.current.messages[0].read_at).toMatch(/^2024-01-01T00:00:00\./);
expect(result.current.messages[0].delivered_at).toMatch(/^2024-01-01T00:00:00\./);
expect(result.current.messages).toEqual([message('m1')]);
expect(onNewMessage).not.toHaveBeenCalled();
expect(chatApi.markRead).not.toHaveBeenCalled();
});
it('prepends inserted message and marks it read when focused', async () => {
const fetchQuery = createThenableQuery({ data: [] });
const updateQuery = createThenableQuery({ data: null });
const fromMock = jest.fn((table: string) => {
if (table === 'messages') return updateQuery;
return createThenableQuery({ data: null });
});
// First call should be fetch.
(supabase as any).from = jest
.fn()
.mockReturnValueOnce(fetchQuery)
.mockImplementation(fromMock);
let insertHandler: ((payload: any) => Promise<void> | void) | null = null;
const channelMock: any = {
on: jest.fn((_type: string, filter: any, cb: any) => {
if (filter?.event === 'INSERT') {
insertHandler = cb;
}
return channelMock;
}),
subscribe: jest.fn(() => channelMock),
};
(supabase as any).channel = jest.fn(() => channelMock);
(supabase as any).removeChannel = jest.fn();
const { result } = renderHook(() =>
useMessageSubscription({ matchId: 'match1', userId: 'me' })
);
it('refreshes on the polling interval, marks focused messages read, and reports new partner messages', async () => {
const onNewMessage = jest.fn();
(chatApi.listMessages as jest.Mock)
.mockResolvedValueOnce({ messages: [message('m1')] })
.mockResolvedValueOnce({ messages: [message('m2'), message('m1')] });
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', onNewMessage, pollInterval: 500 }));
await waitFor(() => expect(result.current.loading).toBe(false));
act(() => { result.current.isFocusedRef.current = true; });
await act(async () => { jest.advanceTimersByTime(500); await Promise.resolve(); });
await waitFor(() => expect(result.current.messages[0]?.id).toBe('m2'));
expect(chatApi.markRead).toHaveBeenCalledWith('match1');
expect(onNewMessage).toHaveBeenCalledTimes(1);
});
act(() => {
result.current.isFocusedRef.current = true;
});
it('does not notify for the current user own newly polled message', async () => {
const onNewMessage = jest.fn();
(chatApi.listMessages as jest.Mock)
.mockResolvedValueOnce({ messages: [] })
.mockResolvedValueOnce({ messages: [message('mine', 'me')] });
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', onNewMessage, pollInterval: 500 }));
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => { jest.advanceTimersByTime(500); await Promise.resolve(); });
expect(onNewMessage).not.toHaveBeenCalled();
});
const newMessage = {
id: 'm2',
match_id: 'match1',
user_id: 'partner',
created_at: '2024-01-01T00:00:01.000Z',
delivered_at: null,
read_at: null,
};
it('surfaces loading completion and preserves a usable empty state after an API error', async () => {
(chatApi.listMessages as jest.Mock).mockRejectedValueOnce(new Error('forbidden'));
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me' }));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.messages).toEqual([]);
});
expect(insertHandler).toBeTruthy();
it('prevents overlapping polls and clears the interval on unmount', async () => {
let resolveFirst!: (value: { messages: any[] }) => void;
(chatApi.listMessages as jest.Mock).mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve; }));
const { unmount } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', pollInterval: 500 }));
expect(chatApi.listMessages).toHaveBeenCalledTimes(1);
act(() => { jest.advanceTimersByTime(1_500); });
expect(chatApi.listMessages).toHaveBeenCalledTimes(1);
await act(async () => resolveFirst({ messages: [] }));
unmount();
act(() => { jest.advanceTimersByTime(2_000); });
expect(chatApi.listMessages).toHaveBeenCalledTimes(1);
});
await act(async () => {
await insertHandler?.({ new: newMessage });
});
expect(updateQuery.update).toHaveBeenCalledWith(
expect.objectContaining({
delivered_at: expect.stringMatching(/^2024-01-01T00:00:00\./),
read_at: expect.stringMatching(/^2024-01-01T00:00:00\./),
})
);
expect(result.current.messages[0].id).toBe('m2');
expect(result.current.messages[0].read_at).toMatch(/^2024-01-01T00:00:00\./);
expect(result.current.messages[0].delivered_at).toMatch(/^2024-01-01T00:00:00\./);
it('supports optimistic external updates', async () => {
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me' }));
await waitFor(() => expect(result.current.loading).toBe(false));
act(() => result.current.setMessages([message('optimistic', 'me')]));
expect(result.current.messages[0]?.id).toBe('optimistic');
});
});
@@ -1,78 +1,79 @@
import { renderHook, act } from '@testing-library/react-native';
import { act, renderHook, waitFor } from '@testing-library/react-native';
import { useTypingIndicator } from '@/hooks/useTypingIndicator';
import { supabase } from '@/lib/supabase';
import { chatApi } from '@/lib/chatApi';
describe('useTypingIndicator', () => {
jest.mock('@/lib/chatApi', () => ({ chatApi: { getTyping: jest.fn(), setTyping: jest.fn() } }));
describe('useTypingIndicator API polling', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.clearAllMocks();
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-08-27T12:00:00Z'));
(chatApi.getTyping as jest.Mock).mockResolvedValue({ typing: false, expires_at: null });
(chatApi.setTyping as jest.Mock).mockResolvedValue({ typing: true });
});
afterEach(() => jest.useRealTimers());
it('derives the match id and polls partner typing state', async () => {
(chatApi.getTyping as jest.Mock).mockResolvedValueOnce({ typing: true, expires_at: '2026-08-27T12:00:04Z' });
const { result } = renderHook(() => useTypingIndicator({ channelName: 'chat:match1', userId: 'me' }));
await waitFor(() => expect(result.current.partnerTyping).toBe(true));
expect(chatApi.getTyping).toHaveBeenCalledWith('match1');
});
afterEach(() => {
jest.useRealTimers();
it('clears typing when the server state expires on a later poll', async () => {
(chatApi.getTyping as jest.Mock)
.mockResolvedValueOnce({ typing: true, expires_at: 'soon' })
.mockResolvedValueOnce({ typing: false, expires_at: null });
const { result } = renderHook(() => useTypingIndicator({ channelName: 'unused', matchId: 'match1', userId: 'me', pollInterval: 500 }));
await waitFor(() => expect(result.current.partnerTyping).toBe(true));
await act(async () => { jest.advanceTimersByTime(500); await Promise.resolve(); });
await waitFor(() => expect(result.current.partnerTyping).toBe(false));
});
it('toggles partnerTyping on broadcast and auto-clears', async () => {
let broadcastHandler: ((payload: any) => void) | null = null;
const channelMock: any = {
on: jest.fn((_type: string, _filter: any, cb: any) => {
broadcastHandler = cb;
return channelMock;
}),
subscribe: jest.fn(() => channelMock),
};
(supabase as any).channel = jest.fn(() => channelMock);
(supabase as any).removeChannel = jest.fn();
const { result } = renderHook(() =>
useTypingIndicator({
channelName: 'typing:test',
userId: 'me',
typingTimeout: 3000,
})
);
expect(result.current.partnerTyping).toBe(false);
expect(broadcastHandler).toBeTruthy();
act(() => {
broadcastHandler?.({ payload: { userId: 'partner' } });
});
expect(result.current.partnerTyping).toBe(true);
act(() => {
jest.advanceTimersByTime(3000);
});
it('fails closed on polling errors', async () => {
(chatApi.getTyping as jest.Mock).mockRejectedValueOnce(new Error('network'));
const { result } = renderHook(() => useTypingIndicator({ channelName: 'chat:match1', userId: 'me' }));
await act(async () => { await Promise.resolve(); });
expect(result.current.partnerTyping).toBe(false);
});
it('sends typing broadcast for current user', () => {
const send = jest.fn();
const channelMock: any = {
on: jest.fn(() => channelMock),
subscribe: jest.fn(() => channelMock),
send,
};
(supabase as any).channel = jest.fn(() => channelMock);
(supabase as any).removeChannel = jest.fn();
const { result } = renderHook(() =>
useTypingIndicator({ channelName: 'typing:test', userId: 'me' })
it('does not poll without a user or while unfocused and clears existing state on blur', async () => {
const { result, rerender } = renderHook<ReturnType<typeof useTypingIndicator>, { focused: boolean; userId: string | undefined }>(
({ focused, userId }) => useTypingIndicator({ channelName: 'chat:match1', userId, isFocused: focused }),
{ initialProps: { focused: true, userId: 'me' as string | undefined } },
);
await act(async () => { await Promise.resolve(); });
act(() => result.current.clearTypingIndicator());
rerender({ focused: false, userId: 'me' });
expect(result.current.partnerTyping).toBe(false);
const callsAtBlur = (chatApi.getTyping as jest.Mock).mock.calls.length;
act(() => { jest.advanceTimersByTime(3_000); });
expect(chatApi.getTyping).toHaveBeenCalledTimes(callsAtBlur);
rerender({ focused: true, userId: undefined });
expect(chatApi.getTyping).toHaveBeenCalledTimes(callsAtBlur);
});
act(() => {
result.current.sendTypingEvent();
});
it('throttles writes and allows another event after the interval', () => {
const { result } = renderHook(() => useTypingIndicator({ channelName: 'chat:match1', userId: 'me', throttleInterval: 2_000 }));
act(() => { result.current.sendTypingEvent(); result.current.sendTypingEvent(); });
expect(chatApi.setTyping).toHaveBeenCalledTimes(1);
act(() => { jest.advanceTimersByTime(2_000); result.current.sendTypingEvent(); });
expect(chatApi.setTyping).toHaveBeenCalledTimes(2);
});
expect(send).toHaveBeenCalledWith({
type: 'broadcast',
event: 'typing',
payload: { userId: 'me' },
});
it('does not write typing state without an authenticated user', () => {
const { result } = renderHook(() => useTypingIndicator({ channelName: 'chat:match1', userId: undefined }));
act(() => result.current.sendTypingEvent());
expect(chatApi.setTyping).not.toHaveBeenCalled();
});
it('clears polling on unmount', async () => {
const { unmount } = renderHook(() => useTypingIndicator({ channelName: 'chat:match1', userId: 'me', pollInterval: 500 }));
await act(async () => { await Promise.resolve(); });
unmount();
const calls = (chatApi.getTyping as jest.Mock).mock.calls.length;
act(() => { jest.advanceTimersByTime(2_000); });
expect(chatApi.getTyping).toHaveBeenCalledTimes(calls);
});
});
@@ -0,0 +1,24 @@
import { accountOperationsApi } from '../../lib/accountOperationsApi';
import { apiClient } from '../../lib/apiClient';
jest.mock('../../lib/apiClient', () => ({
apiClient: {
delete: jest.fn(),
post: jest.fn(),
},
}));
describe('accountOperationsApi', () => {
it('routes destructive and subscription operations through the standalone API', async () => {
await accountOperationsApi.deleteRelationship();
await accountOperationsApi.resetProgress();
await accountOperationsApi.deleteAccount();
await accountOperationsApi.syncSubscription();
expect(apiClient.delete).toHaveBeenNthCalledWith(1, '/v1/couple/data');
expect(apiClient.delete).toHaveBeenNthCalledWith(2, '/v1/couple/progress');
expect(apiClient.delete).toHaveBeenNthCalledWith(3, '/v1/me');
expect(apiClient.post).toHaveBeenCalledWith('/v1/me/subscription/sync');
});
});
@@ -0,0 +1,57 @@
import { ApiError, apiRequest } from "@/lib/apiClient";
import { authClient } from "@/lib/authClient";
function response(status: number, body?: unknown) {
return {
ok: status >= 200 && status < 300,
status,
text: jest.fn(async () => body === undefined ? "" : JSON.stringify(body)),
} as unknown as Response;
}
describe("apiClient", () => {
const fetchMock = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
global.fetch = fetchMock;
(authClient.auth.getSession as jest.Mock).mockResolvedValue({
data: { session: { access_token: "hosted-auth-token" } },
error: null,
});
});
it("injects the current Supabase access token", async () => {
fetchMock.mockResolvedValue(response(200, { profile: { id: "user-1" } }));
await apiRequest("/v1/me");
expect(fetchMock).toHaveBeenCalledWith(
"http://127.0.0.1:3003/v1/me",
expect.objectContaining({ headers: expect.any(Headers) }),
);
const headers = fetchMock.mock.calls[0][1].headers as Headers;
expect(headers.get("Authorization")).toBe("Bearer hosted-auth-token");
});
it("fails closed when there is no authenticated session", async () => {
(authClient.auth.getSession as jest.Mock).mockResolvedValue({ data: { session: null }, error: null });
await expect(apiRequest("/v1/me")).rejects.toMatchObject({ status: 401 });
expect(fetchMock).not.toHaveBeenCalled();
});
it("preserves API status and error details", async () => {
fetchMock.mockResolvedValue(response(403, {
error: { message: "Couple access denied", code: "FORBIDDEN" },
}));
await expect(apiRequest("/v1/me")).rejects.toEqual(
expect.objectContaining<ApiError>({
name: "ApiError",
message: "Couple access denied",
status: 403,
details: { error: { message: "Couple access denied", code: "FORBIDDEN" } },
}),
);
});
});
@@ -0,0 +1,21 @@
import { appDataApi } from '@/lib/appDataApi';
import { apiClient } from '@/lib/apiClient';
jest.mock('@/lib/apiClient', () => ({ apiClient: { get: jest.fn(), post: jest.fn(), patch: jest.fn(), put: jest.fn() } }));
describe('appDataApi', () => {
beforeEach(() => jest.clearAllMocks());
it('routes pack and match reads through the authenticated Node API', async () => {
(apiClient.get as jest.Mock).mockResolvedValue({});
await appDataApi.packQuestions('pack id'); await appDataApi.packTeaser('pack id'); await appDataApi.matchContext('match id');
expect(apiClient.get).toHaveBeenNthCalledWith(1, '/v1/packs/pack%20id/questions');
expect(apiClient.get).toHaveBeenNthCalledWith(2, '/v1/packs/pack%20id/teaser');
expect(apiClient.get).toHaveBeenNthCalledWith(3, '/v1/matches/match%20id/context');
});
it('persists media view and drawing state without accepting a couple ID', async () => {
(apiClient.patch as jest.Mock).mockResolvedValue({}); (apiClient.put as jest.Mock).mockResolvedValue({});
await appDataApi.markMediaViewed('m1', null); await appDataApi.putLiveDraw([], 7);
expect(apiClient.patch).toHaveBeenCalledWith('/v1/messages/m1/media-viewed', { expires_at: null });
expect(apiClient.put).toHaveBeenCalledWith('/v1/live-draw', { strokes: [], base_revision: 7 });
});
});
@@ -0,0 +1,50 @@
import { apiClient } from '@/lib/apiClient';
import { chatApi } from '@/lib/chatApi';
jest.mock('@/lib/apiClient', () => ({ apiClient: { get: jest.fn(), post: jest.fn(), put: jest.fn(), delete: jest.fn() } }));
describe('chatApi', () => {
beforeEach(() => jest.clearAllMocks());
it('uses match-scoped message and typing routes', async () => {
(apiClient.get as jest.Mock).mockResolvedValue({ messages: [] });
(apiClient.post as jest.Mock).mockResolvedValue({ message: { id: 'message' } });
(apiClient.put as jest.Mock).mockResolvedValue({ typing: true });
await chatApi.listMessages('match/id');
await chatApi.sendText('match/id', 'hello');
await chatApi.markRead('match/id');
await chatApi.setTyping('match/id');
await chatApi.getTyping('match/id');
expect(apiClient.get).toHaveBeenNthCalledWith(1, '/v1/matches/match%2Fid/messages');
expect(apiClient.post).toHaveBeenNthCalledWith(1, '/v1/matches/match%2Fid/messages', { content: 'hello' });
expect(apiClient.post).toHaveBeenNthCalledWith(2, '/v1/matches/match%2Fid/read');
expect(apiClient.put).toHaveBeenCalledWith('/v1/matches/match%2Fid/typing');
expect(apiClient.get).toHaveBeenNthCalledWith(2, '/v1/matches/match%2Fid/typing');
});
it('uses authenticated unread, receipt, deletion and reporting routes', async () => {
(apiClient.get as jest.Mock).mockResolvedValue({ total: 0, by_match: {} });
(apiClient.post as jest.Mock).mockResolvedValue({});
(apiClient.delete as jest.Mock).mockResolvedValue({ deleted: true });
await chatApi.unread();
await chatApi.markDelivered('message/id');
await chatApi.deleteForSelf('message/id');
await chatApi.deleteForEveryone('message/id');
await chatApi.report('message/id', 'harassment');
expect(apiClient.get).toHaveBeenCalledWith('/v1/chat/unread');
expect(apiClient.post).toHaveBeenNthCalledWith(1, '/v1/messages/message%2Fid/delivered');
expect(apiClient.delete).toHaveBeenNthCalledWith(1, '/v1/messages/message%2Fid?scope=self');
expect(apiClient.delete).toHaveBeenNthCalledWith(2, '/v1/messages/message%2Fid?scope=everyone');
expect(apiClient.post).toHaveBeenNthCalledWith(2, '/v1/messages/message%2Fid/reports', { reason: 'harassment' });
});
it('propagates API authorization and tenancy errors', async () => {
const forbidden = new Error('match not found');
(apiClient.get as jest.Mock).mockRejectedValue(forbidden);
await expect(chatApi.listMessages('foreign-match')).rejects.toBe(forbidden);
});
});
@@ -0,0 +1,24 @@
import { apiRequest } from "../../lib/apiClient";
import { coupleApi } from "../../lib/coupleApi";
jest.mock("../../lib/apiClient", () => ({ apiRequest: jest.fn() }));
const request = apiRequest as jest.Mock;
describe("coupleApi", () => {
beforeEach(() => request.mockResolvedValue({ success: true }));
it("uses the authenticated standalone API for the complete pairing lifecycle", async () => {
await coupleApi.getState();
await coupleApi.create();
await coupleApi.join("ABCD2345");
await coupleApi.cancel();
expect(request.mock.calls).toEqual([
["/v1/couple"],
["/v1/couple", { method: "POST", body: {} }],
["/v1/couple", { method: "POST", body: { invite_code: "ABCD2345" } }],
["/v1/couple", { method: "DELETE" }],
]);
});
});
@@ -0,0 +1,13 @@
import { mediaId, getMediaUrl } from '@/lib/mediaApi';
describe('mediaApi references', () => {
it('extracts only UUID media references', () => {
expect(mediaId('media:22222222-2222-4222-8222-222222222222')).toBe('22222222-2222-4222-8222-222222222222');
expect(mediaId('../../private/file')).toBeNull();
});
it('leaves legacy HTTPS media URLs usable during migration', async () => {
const url = 'https://legacy.example/media/photo.jpg';
await expect(getMediaUrl(url)).resolves.toMatchObject({ url });
});
});
@@ -0,0 +1,30 @@
import { apiClient } from '../../lib/apiClient';
import { profileSettingsApi } from '../../lib/profileSettingsApi';
jest.mock('../../lib/apiClient', () => ({
apiClient: {
get: jest.fn(),
post: jest.fn(),
patch: jest.fn(),
},
}));
describe('profileSettingsApi', () => {
it('routes profile, activity, preference, and feedback operations through the standalone API', async () => {
await profileSettingsApi.updateProfile({ name: 'Alice' });
await profileSettingsApi.updateLastActive();
await profileSettingsApi.getNotificationPreferences();
await profileSettingsApi.updateNotificationPreference('messages_enabled', false);
await profileSettingsApi.submitFeedback({ type: 'bug', title: 'Title', description: 'Details' });
expect(apiClient.patch).toHaveBeenNthCalledWith(1, '/v1/me/profile', { name: 'Alice' });
expect(apiClient.post).toHaveBeenNthCalledWith(1, '/v1/me/activity');
expect(apiClient.get).toHaveBeenCalledWith('/v1/me/notification-preferences');
expect(apiClient.patch).toHaveBeenNthCalledWith(2, '/v1/me/notification-preferences', {
key: 'messages_enabled', value: false,
});
expect(apiClient.post).toHaveBeenNthCalledWith(2, '/v1/feedback', {
type: 'bug', title: 'Title', description: 'Details',
});
});
});
@@ -1,14 +1,35 @@
import { useAuthStore } from '@/store/authStore';
import { useMatchStore } from '@/store/matchStore';
import { usePacksStore } from '@/store/packsStore';
import { useMessageStore } from '@/store/messageStore';
import { useSubscriptionStore } from '@/store/subscriptionStore';
import { supabase } from '@/lib/supabase';
import { ApiError, apiClient } from "@/lib/apiClient";
import { authClient } from "@/lib/authClient";
import { coupleApi } from "@/lib/coupleApi";
import { profileSettingsApi } from "@/lib/profileSettingsApi";
import { useAuthStore } from "@/store/authStore";
import { useMatchStore } from "@/store/matchStore";
import { useMessageStore } from "@/store/messageStore";
import { usePacksStore } from "@/store/packsStore";
import { useSubscriptionStore } from "@/store/subscriptionStore";
import type { Profile } from "@/types";
describe('authStore', () => {
const profile = {
id: "me",
name: "Test User",
email: "test@example.com",
avatar_url: null,
push_token: null,
is_premium: false,
couple_id: null,
gender: null,
show_explicit_content: false,
max_intensity: 3,
hide_nsfw: true,
onboarding_completed: false,
onboarding_version: 0,
created_at: "2026-08-27T00:00:00.000Z",
updated_at: "2026-08-27T00:00:00.000Z",
};
describe("authStore", () => {
beforeEach(() => {
jest.clearAllMocks();
useAuthStore.setState({
user: null,
couple: null,
@@ -17,69 +38,93 @@ describe('authStore', () => {
isAuthenticated: false,
isAnonymous: false,
} as any);
useMatchStore.setState({ matches: [{ id: 'm1' }], newMatchesCount: 1 } as any);
usePacksStore.setState({ enabledPackIds: ['p1'] } as any);
useMessageStore.setState({ unreadCount: 3, lastMessage: { id: 'msg1' }, activeMatchId: 'match1' } as any);
useMatchStore.setState({ matches: [{ id: "m1" }], newMatchesCount: 1 } as any);
usePacksStore.setState({ enabledPackIds: ["p1"] } as any);
useMessageStore.setState({ unreadCount: 3, lastMessage: { id: "msg1" }, activeMatchId: "match1" } as any);
useSubscriptionStore.setState({ subscription: { isProUser: true } } as any);
});
it('sets unauthenticated when no session exists', async () => {
(supabase.auth.getSession as jest.Mock).mockResolvedValueOnce({ data: { session: null } });
it("sets unauthenticated when no hosted auth session exists", async () => {
(authClient.auth.getSession as jest.Mock).mockResolvedValueOnce({ data: { session: null } });
await useAuthStore.getState().fetchUser();
const state = useAuthStore.getState();
expect(state.user).toBeNull();
expect(state.isAuthenticated).toBe(false);
expect(state.isAnonymous).toBe(false);
expect(state.isLoading).toBe(false);
expect(useAuthStore.getState()).toMatchObject({
user: null,
isAuthenticated: false,
isAnonymous: false,
isLoading: false,
});
});
it('sets isAnonymous from auth user', async () => {
(supabase.auth.getSession as jest.Mock).mockResolvedValueOnce({
data: { session: { user: { id: 'me' } } },
it("bootstraps and reads the profile through GET /v1/me", async () => {
(authClient.auth.getSession as jest.Mock).mockResolvedValueOnce({
data: { session: { user: { id: "me", is_anonymous: true } } },
});
const getSpy = jest.spyOn(apiClient, "get").mockResolvedValueOnce({ profile });
(supabase.auth.getUser as jest.Mock).mockResolvedValueOnce({
data: { user: { id: 'me', is_anonymous: true } },
error: null,
await useAuthStore.getState().fetchUser();
expect(getSpy).toHaveBeenCalledWith("/v1/me");
expect(useAuthStore.getState()).toMatchObject({
user: profile,
isAuthenticated: true,
isAnonymous: true,
isLoading: false,
});
});
const profileQuery: any = {
select: jest.fn(() => profileQuery),
eq: jest.fn(() => profileQuery),
maybeSingle: jest.fn(async () => ({ data: { id: 'me', couple_id: null } })),
it("reads couple and partner state through the standalone API", async () => {
const pairedProfile = { ...profile, couple_id: "couple-1" };
const couple = {
id: "couple-1",
invite_code: "ABCD2345",
created_at: "2026-08-27T00:00:00.000Z",
};
const partner: Profile = {
...profile,
id: "partner",
name: "Partner",
couple_id: "couple-1",
max_intensity: 3,
};
useAuthStore.setState({ user: pairedProfile } as any);
const stateSpy = jest.spyOn(coupleApi, "getState").mockResolvedValueOnce({ couple, partner });
(supabase.from as jest.Mock).mockReturnValue(profileQuery);
await useAuthStore.getState().fetchCouple();
await useAuthStore.getState().fetchUser();
const state = useAuthStore.getState();
expect(state.isAuthenticated).toBe(true);
expect(state.isAnonymous).toBe(true);
expect(stateSpy).toHaveBeenCalledWith();
expect(useAuthStore.getState()).toMatchObject({ couple, partner });
});
it('clears stores and signs out when session invalid', async () => {
(supabase.auth.getSession as jest.Mock).mockResolvedValueOnce({
data: { session: { user: { id: 'me' } } },
});
it("updates activity through the standalone API", async () => {
useAuthStore.setState({ user: profile } as any);
const activitySpy = jest.spyOn(profileSettingsApi, "updateLastActive").mockResolvedValueOnce();
(supabase.auth.getUser as jest.Mock).mockResolvedValueOnce({
data: { user: null },
error: new Error('invalid'),
});
await useAuthStore.getState().updateLastActive();
(supabase.auth.signOut as jest.Mock).mockResolvedValueOnce({ error: null });
expect(activitySpy).toHaveBeenCalledWith();
});
it("clears user-scoped stores and hosted auth when the API rejects the session", async () => {
(authClient.auth.getSession as jest.Mock).mockResolvedValueOnce({
data: { session: { user: { id: "me" } } },
});
jest.spyOn(apiClient, "get").mockRejectedValueOnce(new ApiError("Unauthorized", 401));
(authClient.auth.signOut as jest.Mock).mockResolvedValueOnce({ error: null });
await useAuthStore.getState().fetchUser();
expect(useAuthStore.getState().user).toBeNull();
expect(useAuthStore.getState().isAuthenticated).toBe(false);
expect(useAuthStore.getState().isAnonymous).toBe(false);
expect(useAuthStore.getState().isAnonymous).toBe(false);
expect(useAuthStore.getState().isLoading).toBe(false);
expect(useAuthStore.getState()).toMatchObject({
user: null,
isAuthenticated: false,
isAnonymous: false,
isLoading: false,
});
expect(useMatchStore.getState().matches).toEqual([]);
expect(usePacksStore.getState().enabledPackIds).toEqual([]);
expect(useMessageStore.getState().unreadCount).toBe(0);
expect(useSubscriptionStore.getState().subscription).toMatchObject({ isProUser: false });
expect(authClient.auth.signOut).toHaveBeenCalled();
});
});
@@ -1,485 +1,233 @@
import { useMatchStore } from '@/store/matchStore';
import { appDataApi } from '@/lib/appDataApi';
import { ApiError, apiClient } from '@/lib/apiClient';
import { useAuthStore } from '@/store/authStore';
import { supabase } from '@/lib/supabase';
import { useMatchStore } from '@/store/matchStore';
function createThenableQuery(result: any) {
const query: any = {
select: jest.fn(() => query),
eq: jest.fn(() => query),
neq: jest.fn(() => query),
is: jest.fn(() => query),
in: jest.fn(() => query),
order: jest.fn(() => query),
range: jest.fn(() => query),
update: jest.fn(() => query),
insert: jest.fn(() => query),
delete: jest.fn(() => query),
then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject),
};
return query;
}
jest.mock('@/lib/apiClient', () => {
const actual = jest.requireActual('@/lib/apiClient');
return { ...actual, apiClient: { get: jest.fn(), patch: jest.fn(), put: jest.fn() } };
});
jest.mock('@/lib/appDataApi', () => ({ appDataApi: { sendNudge: jest.fn(), nudgeStatus: jest.fn() } }));
describe('matchStore', () => {
const match = (id: string, is_new = true, unreadCount = 0) => ({
id, couple_id: 'couple', question_id: `q-${id}`, match_type: 'yes_yes', is_new,
created_at: '2026-01-01T00:00:00.000Z', unreadCount,
} as any);
const pending = (id: string) => ({ id, question: { id: `q-${id}` }, partnerAnsweredAt: 'now' } as any);
describe('matchStore API behavior', () => {
beforeEach(() => {
jest.clearAllMocks();
useMatchStore.setState({
matches: [],
newMatchesCount: 0,
totalCount: null,
isLoading: false,
isLoadingMore: false,
page: 0,
hasMore: true,
error: null,
archivedMatches: [],
archivedMatchIds: new Set<string>(),
showArchived: false,
isLoadingArchived: false,
});
useAuthStore.setState({ user: { id: 'me', couple_id: 'couple1' } } as any);
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-08-27T12:00:00Z'));
useAuthStore.setState({ user: { id: 'me', couple_id: 'couple' } } as any);
useMatchStore.getState().clearMatches();
(apiClient.patch as jest.Mock).mockResolvedValue({ success: true });
(apiClient.put as jest.Mock).mockResolvedValue({ success: true });
});
afterEach(() => jest.useRealTimers());
describe('fetchMatches', () => {
it('sorts matches with unread messages first', async () => {
const matches = [
{ id: 'a', question_id: 'q1', created_at: '2024-01-02T00:00:00.000Z', is_new: true },
{ id: 'b', question_id: 'q2', created_at: '2024-01-03T00:00:00.000Z', is_new: false },
];
const unreadMessages = [{ match_id: 'a' }, { match_id: 'a' }];
const archivesQuery = createThenableQuery({ data: [] });
const countQuery = createThenableQuery({ count: 2 });
const matchesQuery = createThenableQuery({ data: matches });
const responsesQuery = createThenableQuery({ data: [] });
const unreadQuery = createThenableQuery({ data: unreadMessages, error: null });
(supabase.from as jest.Mock)
.mockReturnValueOnce(archivesQuery) // archived matches
.mockReturnValueOnce(countQuery) // count
.mockReturnValueOnce(matchesQuery) // matches
.mockReturnValueOnce(responsesQuery) // responses
.mockReturnValueOnce(unreadQuery); // messages
it('loads the first API page and retains server ordering and counts', async () => {
(apiClient.get as jest.Mock).mockResolvedValue({ matches: [match('unread', false, 2), match('new')], totalCount: 2 });
await useMatchStore.getState().fetchMatches(true);
const state = useMatchStore.getState();
expect(state.matches[0].id).toBe('a');
expect(state.newMatchesCount).toBe(1);
expect(apiClient.get).toHaveBeenCalledWith('/v1/matches?page=0&limit=20');
expect(useMatchStore.getState()).toMatchObject({ totalCount: 2, newMatchesCount: 1, page: 1, hasMore: false, isLoading: false });
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['unread', 'new']);
});
it('sets empty state when user has no couple', async () => {
it('fails closed for an unpaired user', async () => {
useMatchStore.setState({ matches: [match('old')], newMatchesCount: 1 });
useAuthStore.setState({ user: { id: 'me', couple_id: null } } as any);
await useMatchStore.getState().fetchMatches(true);
const state = useMatchStore.getState();
expect(state.matches).toEqual([]);
expect(state.newMatchesCount).toBe(0);
expect(state.totalCount).toBe(0);
expect(supabase.from).not.toHaveBeenCalled();
expect(apiClient.get).not.toHaveBeenCalled();
expect(useMatchStore.getState()).toMatchObject({ matches: [], newMatchesCount: 0, totalCount: 0, isLoading: false });
});
it('prevents concurrent loading', async () => {
it('guards concurrent loads and exhausted pagination', async () => {
useMatchStore.setState({ isLoading: true });
await useMatchStore.getState().fetchMatches(true);
expect(supabase.from).not.toHaveBeenCalled();
});
it('filters out archived matches', async () => {
const matches = [
{ id: 'a', question_id: 'q1', created_at: '2024-01-02T00:00:00.000Z', is_new: false },
{ id: 'b', question_id: 'q2', created_at: '2024-01-03T00:00:00.000Z', is_new: false },
];
const archivesQuery = createThenableQuery({ data: [{ match_id: 'a' }] });
const countQuery = createThenableQuery({ count: 2 });
const matchesQuery = createThenableQuery({ data: matches });
const responsesQuery = createThenableQuery({ data: [] });
const unreadQuery = createThenableQuery({ data: [], error: null });
(supabase.from as jest.Mock)
.mockReturnValueOnce(archivesQuery)
.mockReturnValueOnce(countQuery)
.mockReturnValueOnce(matchesQuery)
.mockReturnValueOnce(responsesQuery)
.mockReturnValueOnce(unreadQuery);
await useMatchStore.getState().fetchMatches(true);
const state = useMatchStore.getState();
expect(state.matches).toHaveLength(1);
expect(state.matches[0].id).toBe('b');
// Total count should subtract archived
expect(state.totalCount).toBe(1);
});
it('handles empty matches correctly', async () => {
const archivesQuery = createThenableQuery({ data: [] });
const countQuery = createThenableQuery({ count: 0 });
const matchesQuery = createThenableQuery({ data: [] });
(supabase.from as jest.Mock)
.mockReturnValueOnce(archivesQuery)
.mockReturnValueOnce(countQuery)
.mockReturnValueOnce(matchesQuery);
await useMatchStore.getState().fetchMatches(true);
const state = useMatchStore.getState();
expect(state.matches).toEqual([]);
expect(state.hasMore).toBe(false);
expect(state.isLoading).toBe(false);
});
it('handles fetch error gracefully', async () => {
const archivesQuery = createThenableQuery({ data: [] });
const countQuery = createThenableQuery({ count: 0 });
// Use createThenableQuery with error data to simulate error response
const matchesQuery = createThenableQuery({ data: null, error: { message: 'Network error' } });
(supabase.from as jest.Mock)
.mockReturnValueOnce(archivesQuery)
.mockReturnValueOnce(countQuery)
.mockReturnValueOnce(matchesQuery);
await useMatchStore.getState().fetchMatches(true);
const state = useMatchStore.getState();
expect(state.error).toBe('Failed to load matches');
expect(state.isLoading).toBe(false);
});
it('paginates matches correctly', async () => {
useMatchStore.setState({
matches: [{ id: 'existing' }] as any,
page: 1,
hasMore: true,
});
const newMatches = [
{ id: 'c', question_id: 'q3', created_at: '2024-01-01T00:00:00.000Z', is_new: false },
];
const matchesQuery = createThenableQuery({ data: newMatches });
const responsesQuery = createThenableQuery({ data: [] });
const unreadQuery = createThenableQuery({ data: [] });
(supabase.from as jest.Mock)
.mockReturnValueOnce(matchesQuery)
.mockReturnValueOnce(responsesQuery)
.mockReturnValueOnce(unreadQuery);
useMatchStore.setState({ isLoading: false, hasMore: false });
await useMatchStore.getState().fetchMatches(false);
expect(apiClient.get).not.toHaveBeenCalled();
});
const state = useMatchStore.getState();
expect(state.matches).toHaveLength(2);
expect(state.page).toBe(2);
it('appends and deduplicates later pages', async () => {
useMatchStore.setState({ matches: [match('existing')], page: 1, hasMore: true, newMatchesCount: 1, totalCount: 25 });
const page = [match('existing'), ...Array.from({ length: 19 }, (_, index) => match(`m${index}`, false))];
(apiClient.get as jest.Mock).mockResolvedValue({ matches: page, totalCount: 25 });
await useMatchStore.getState().fetchMatches(false);
expect(apiClient.get).toHaveBeenCalledWith('/v1/matches?page=1&limit=20');
expect(useMatchStore.getState()).toMatchObject({ page: 2, hasMore: true, isLoadingMore: false, totalCount: 25 });
expect(new Set(useMatchStore.getState().matches.map(item => item.id)).size).toBe(20);
});
it('sets an empty terminal state on an empty refresh', async () => {
(apiClient.get as jest.Mock).mockResolvedValue({ matches: [], totalCount: 0 });
await useMatchStore.getState().fetchMatches(true);
expect(useMatchStore.getState()).toMatchObject({ matches: [], totalCount: 0, hasMore: false, isLoading: false });
});
it('records API errors and releases loading guards', async () => {
jest.spyOn(console, 'error').mockImplementation(() => undefined);
(apiClient.get as jest.Mock).mockRejectedValue(new Error('network'));
await useMatchStore.getState().fetchMatches(true);
expect(useMatchStore.getState()).toMatchObject({ error: 'Failed to load matches', isLoading: false, isLoadingMore: false });
});
});
describe('markAsSeen', () => {
it('marks a match as seen and updates count', async () => {
useMatchStore.setState({
matches: [
{ id: 'm1', is_new: true } as any,
{ id: 'm2', is_new: true } as any,
],
newMatchesCount: 2,
});
const updateQuery = createThenableQuery({});
(supabase.from as jest.Mock).mockReturnValue(updateQuery);
await useMatchStore.getState().markAsSeen('m1');
const state = useMatchStore.getState();
expect(state.matches[0].is_new).toBe(false);
expect(state.matches[1].is_new).toBe(true);
expect(state.newMatchesCount).toBe(1);
expect(updateQuery.update).toHaveBeenCalledWith({ is_new: false });
});
it('marks one or all new matches seen and skips an empty batch', async () => {
useMatchStore.setState({ matches: [match('m1'), match('m2'), match('old', false)], newMatchesCount: 2 });
await useMatchStore.getState().markAsSeen('m1');
expect(apiClient.patch).toHaveBeenLastCalledWith('/v1/matches/seen', { ids: ['m1'] });
expect(useMatchStore.getState().newMatchesCount).toBe(1);
await useMatchStore.getState().markAllAsSeen();
expect(apiClient.patch).toHaveBeenLastCalledWith('/v1/matches/seen', { ids: ['m2'] });
expect(useMatchStore.getState().newMatchesCount).toBe(0);
await useMatchStore.getState().markAllAsSeen();
expect(apiClient.patch).toHaveBeenCalledTimes(2);
});
describe('markAllAsSeen', () => {
it('marks all matches as seen', async () => {
useMatchStore.setState({
matches: [
{ id: 'm1', is_new: true } as any,
{ id: 'm2', is_new: true } as any,
],
newMatchesCount: 2,
});
const updateQuery = createThenableQuery({});
(supabase.from as jest.Mock).mockReturnValue(updateQuery);
await useMatchStore.getState().markAllAsSeen();
const state = useMatchStore.getState();
expect(state.matches.every(m => !m.is_new)).toBe(true);
expect(state.newMatchesCount).toBe(0);
});
it('does nothing when no new matches', async () => {
useMatchStore.setState({
matches: [{ id: 'm1', is_new: false } as any],
newMatchesCount: 0,
});
await useMatchStore.getState().markAllAsSeen();
expect(supabase.from).not.toHaveBeenCalled();
});
it('adds matches and clamps unread counts at zero', () => {
useMatchStore.setState({ matches: [match('m1', false, 1)], totalCount: null, newMatchesCount: 0 });
useMatchStore.getState().addMatch(match('m2'));
useMatchStore.getState().updateMatchUnreadCount('m1', -5);
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['m2', 'm1']);
expect(useMatchStore.getState()).toMatchObject({ totalCount: 1, newMatchesCount: 1 });
expect(useMatchStore.getState().matches[1]?.unreadCount).toBe(0);
});
describe('addMatch', () => {
it('adds a new match to the beginning', () => {
useMatchStore.setState({
matches: [{ id: 'm1' } as any],
newMatchesCount: 0,
totalCount: 1,
});
useMatchStore.getState().addMatch({ id: 'm2', is_new: true } as any);
const state = useMatchStore.getState();
expect(state.matches[0].id).toBe('m2');
expect(state.matches).toHaveLength(2);
expect(state.newMatchesCount).toBe(1);
expect(state.totalCount).toBe(2);
});
});
describe('updateMatchUnreadCount', () => {
it('increments unread count', () => {
useMatchStore.setState({
matches: [{ id: 'm1', unreadCount: 1 } as any],
});
useMatchStore.getState().updateMatchUnreadCount('m1', 2);
expect(useMatchStore.getState().matches[0].unreadCount).toBe(3);
});
it('decrements unread count but not below zero', () => {
useMatchStore.setState({
matches: [{ id: 'm1', unreadCount: 1 } as any],
});
useMatchStore.getState().updateMatchUnreadCount('m1', -5);
expect(useMatchStore.getState().matches[0].unreadCount).toBe(0);
});
it('handles missing unreadCount', () => {
useMatchStore.setState({
matches: [{ id: 'm1' } as any],
});
useMatchStore.getState().updateMatchUnreadCount('m1', 1);
expect(useMatchStore.getState().matches[0].unreadCount).toBe(1);
});
});
describe('clearMatches', () => {
it('resets all state to initial values', () => {
useMatchStore.setState({
matches: [{ id: 'm1' }] as any,
newMatchesCount: 5,
totalCount: 10,
isLoading: true,
error: 'some error',
page: 3,
hasMore: false,
isLoadingMore: true,
archivedMatches: [{ id: 'a1' }] as any,
archivedMatchIds: new Set(['a1']),
showArchived: true,
isLoadingArchived: true,
});
useMatchStore.getState().clearMatches();
const state = useMatchStore.getState();
expect(state.matches).toEqual([]);
expect(state.newMatchesCount).toBe(0);
expect(state.totalCount).toBeNull();
expect(state.isLoading).toBe(false);
expect(state.error).toBeNull();
expect(state.page).toBe(0);
expect(state.hasMore).toBe(true);
expect(state.isLoadingMore).toBe(false);
expect(state.archivedMatches).toEqual([]);
expect(state.archivedMatchIds.size).toBe(0);
expect(state.showArchived).toBe(false);
expect(state.isLoadingArchived).toBe(false);
});
});
describe('archiveMatch', () => {
it('archives a match with optimistic update', async () => {
useMatchStore.setState({
matches: [
{ id: 'm1' } as any,
{ id: 'm2' } as any,
],
archivedMatches: [],
archivedMatchIds: new Set(),
totalCount: 2,
});
const insertQuery = createThenableQuery({ error: null });
(supabase.from as jest.Mock).mockReturnValue(insertQuery);
describe('archives', () => {
it('archives and unarchives optimistically through token-scoped routes', async () => {
useMatchStore.setState({ matches: [match('m1'), match('m2')], archivedMatches: [], archivedMatchIds: new Set(), totalCount: 2 });
await useMatchStore.getState().archiveMatch('m1');
const state = useMatchStore.getState();
expect(state.matches).toHaveLength(1);
expect(state.matches[0].id).toBe('m2');
expect(state.archivedMatches).toHaveLength(1);
expect(state.archivedMatches[0].id).toBe('m1');
expect(state.archivedMatchIds.has('m1')).toBe(true);
expect(state.totalCount).toBe(1);
});
it('does nothing if user not authenticated', async () => {
useAuthStore.setState({ user: null } as any);
await useMatchStore.getState().archiveMatch('m1');
expect(supabase.from).not.toHaveBeenCalled();
});
it('reverts on error', async () => {
useMatchStore.setState({
matches: [{ id: 'm1' } as any],
archivedMatches: [],
archivedMatchIds: new Set(),
totalCount: 1,
});
const insertQuery = createThenableQuery({ error: new Error('DB error') });
(supabase.from as jest.Mock).mockReturnValue(insertQuery);
await useMatchStore.getState().archiveMatch('m1');
const state = useMatchStore.getState();
// Should revert
expect(state.matches).toHaveLength(1);
expect(state.archivedMatchIds.has('m1')).toBe(false);
});
});
describe('unarchiveMatch', () => {
it('unarchives a match with optimistic update', async () => {
useMatchStore.setState({
matches: [],
archivedMatches: [{ id: 'm1' } as any],
archivedMatchIds: new Set(['m1']),
totalCount: 0,
});
const deleteQuery = createThenableQuery({ error: null });
(supabase.from as jest.Mock).mockReturnValue(deleteQuery);
expect(apiClient.put).toHaveBeenLastCalledWith('/v1/matches/m1/archive', { archived: true });
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['m2']);
expect(useMatchStore.getState().archivedMatchIds.has('m1')).toBe(true);
await useMatchStore.getState().unarchiveMatch('m1');
const state = useMatchStore.getState();
expect(state.matches).toHaveLength(1);
expect(state.matches[0].id).toBe('m1');
expect(state.archivedMatches).toHaveLength(0);
expect(state.archivedMatchIds.has('m1')).toBe(false);
expect(state.totalCount).toBe(1);
expect(apiClient.put).toHaveBeenLastCalledWith('/v1/matches/m1/archive', { archived: false });
expect(useMatchStore.getState()).toMatchObject({ totalCount: 2, archivedMatches: [] });
});
it('does nothing if user not authenticated', async () => {
it('does not mutate or call the API without an authenticated user', async () => {
useMatchStore.setState({ matches: [match('m1')], archivedMatches: [match('m2')] });
useAuthStore.setState({ user: null } as any);
await useMatchStore.getState().unarchiveMatch('m1');
expect(supabase.from).not.toHaveBeenCalled();
await useMatchStore.getState().archiveMatch('m1');
await useMatchStore.getState().unarchiveMatch('m2');
expect(apiClient.put).not.toHaveBeenCalled();
expect(useMatchStore.getState().matches).toHaveLength(1);
});
});
describe('fetchArchivedMatches', () => {
it('fetches archived matches', async () => {
const archivesQuery = createThenableQuery({ data: [{ match_id: 'm1' }] });
const matchesQuery = createThenableQuery({
data: [{ id: 'm1', question_id: 'q1', created_at: '2024-01-01' }],
});
const unreadQuery = createThenableQuery({ data: [] });
(supabase.from as jest.Mock)
.mockReturnValueOnce(archivesQuery)
.mockReturnValueOnce(matchesQuery)
.mockReturnValueOnce(unreadQuery);
it('reverts archive and unarchive mutations on API errors', async () => {
jest.spyOn(console, 'error').mockImplementation(() => undefined);
(apiClient.put as jest.Mock).mockRejectedValue(new Error('forbidden'));
useMatchStore.setState({ matches: [match('m1')], archivedMatches: [], archivedMatchIds: new Set(), totalCount: 1 });
await useMatchStore.getState().archiveMatch('m1');
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['m1']);
expect(useMatchStore.getState().totalCount).toBe(1);
useMatchStore.setState({ matches: [], archivedMatches: [match('m2')], archivedMatchIds: new Set(['m2']), totalCount: 0 });
await useMatchStore.getState().unarchiveMatch('m2');
expect(useMatchStore.getState().archivedMatches.map(item => item.id)).toEqual(['m2']);
expect(useMatchStore.getState().totalCount).toBe(0);
});
it('loads archived matches, handles empty responses, and guards missing couple state', async () => {
(apiClient.get as jest.Mock).mockResolvedValueOnce({ matches: [match('m1')], totalCount: 1 });
await useMatchStore.getState().fetchArchivedMatches();
const state = useMatchStore.getState();
expect(state.archivedMatches).toHaveLength(1);
expect(state.archivedMatches[0].id).toBe('m1');
expect(state.isLoadingArchived).toBe(false);
});
it('does nothing if no couple', async () => {
expect(apiClient.get).toHaveBeenCalledWith('/v1/matches?archived=true&limit=100');
expect(useMatchStore.getState().archivedMatchIds.has('m1')).toBe(true);
(apiClient.get as jest.Mock).mockResolvedValueOnce({ matches: [], totalCount: 0 });
await useMatchStore.getState().fetchArchivedMatches();
expect(useMatchStore.getState()).toMatchObject({ archivedMatches: [], isLoadingArchived: false });
useAuthStore.setState({ user: { id: 'me', couple_id: null } } as any);
await useMatchStore.getState().fetchArchivedMatches();
expect(supabase.from).not.toHaveBeenCalled();
expect(apiClient.get).toHaveBeenCalledTimes(2);
});
it('handles empty archives', async () => {
const archivesQuery = createThenableQuery({ data: [] });
(supabase.from as jest.Mock).mockReturnValue(archivesQuery);
await useMatchStore.getState().fetchArchivedMatches();
const state = useMatchStore.getState();
expect(state.archivedMatches).toEqual([]);
expect(state.isLoadingArchived).toBe(false);
});
});
describe('toggleShowArchived', () => {
it('toggles showArchived state', () => {
expect(useMatchStore.getState().showArchived).toBe(false);
it('toggles archived visibility and triggers an initial fetch only', async () => {
(apiClient.get as jest.Mock).mockResolvedValue({ matches: [], totalCount: 0 });
useMatchStore.getState().toggleShowArchived();
await Promise.resolve();
expect(useMatchStore.getState().showArchived).toBe(true);
expect(apiClient.get).toHaveBeenCalledTimes(1);
useMatchStore.getState().toggleShowArchived();
expect(useMatchStore.getState().showArchived).toBe(false);
});
it('fetches archived matches when toggling to show', async () => {
const archivesQuery = createThenableQuery({ data: [] });
(supabase.from as jest.Mock).mockReturnValue(archivesQuery);
useMatchStore.getState().toggleShowArchived();
// Should trigger fetch
expect(supabase.from).toHaveBeenCalled();
});
});
describe('isMatchArchived', () => {
it('returns true for archived matches', () => {
useMatchStore.setState({
archivedMatchIds: new Set(['m1', 'm2']),
});
expect(useMatchStore.getState().isMatchArchived('m1')).toBe(true);
expect(useMatchStore.getState().isMatchArchived('m2')).toBe(true);
expect(useMatchStore.getState().isMatchArchived('m3')).toBe(false);
describe('turn views', () => {
it('loads partner and own pending questions and clears them when unpaired', async () => {
(apiClient.get as jest.Mock).mockResolvedValueOnce({ questions: [pending('partner')] }).mockResolvedValueOnce({ questions: [pending('mine')] });
await useMatchStore.getState().fetchPendingQuestions();
await useMatchStore.getState().fetchTheirTurnQuestions();
expect(apiClient.get).toHaveBeenNthCalledWith(1, '/v1/questions/pending?direction=partner');
expect(apiClient.get).toHaveBeenNthCalledWith(2, '/v1/questions/pending?direction=mine');
expect(useMatchStore.getState()).toMatchObject({ pendingQuestions: [pending('partner')], theirTurnQuestions: [pending('mine')] });
useAuthStore.setState({ user: null } as any);
await useMatchStore.getState().fetchPendingQuestions();
await useMatchStore.getState().fetchTheirTurnQuestions();
expect(useMatchStore.getState()).toMatchObject({ pendingQuestions: [], theirTurnQuestions: [] });
});
it('releases pending loading guards on errors', async () => {
jest.spyOn(console, 'error').mockImplementation(() => undefined);
(apiClient.get as jest.Mock).mockRejectedValue(new Error('network'));
await useMatchStore.getState().fetchPendingQuestions();
await useMatchStore.getState().fetchTheirTurnQuestions();
expect(useMatchStore.getState()).toMatchObject({ isLoadingPending: false, isLoadingTheirTurn: false });
});
it('updates current view and delegates an empty view fetch', () => {
const fetchArchivedMatches = jest.fn(async () => undefined);
useMatchStore.setState({ fetchArchivedMatches });
useMatchStore.getState().setCurrentView('archived');
expect(useMatchStore.getState()).toMatchObject({ currentView: 'archived', showArchived: true });
expect(fetchArchivedMatches).toHaveBeenCalled();
});
});
describe('nudges', () => {
it('sends a nudge and uses the server cooldown', async () => {
(appDataApi.sendNudge as jest.Mock).mockResolvedValue({ success: true, notification_sent: true, next_nudge_available_at: '2026-08-28T00:00:00Z' });
await expect(useMatchStore.getState().sendNudge()).resolves.toEqual({ success: true, notificationSent: true });
expect(useMatchStore.getState()).toMatchObject({ isNudging: false, nudgeCooldownUntil: new Date('2026-08-28T00:00:00Z') });
});
it('does not send during an active request or cooldown', async () => {
useMatchStore.setState({ isNudging: true });
await expect(useMatchStore.getState().sendNudge()).resolves.toEqual({ success: false, notificationSent: false });
useMatchStore.setState({ isNudging: false, nudgeCooldownUntil: new Date('2026-08-27T13:00:00Z') });
await useMatchStore.getState().sendNudge();
expect(appDataApi.sendNudge).not.toHaveBeenCalled();
});
it('records a 429 cooldown and recovers from generic errors', async () => {
(appDataApi.sendNudge as jest.Mock).mockRejectedValueOnce(new ApiError('rate limited', 429, { cooldown_remaining_seconds: 60 }));
await expect(useMatchStore.getState().sendNudge()).resolves.toEqual({ success: false, notificationSent: false });
expect(useMatchStore.getState().nudgeCooldownUntil).toEqual(new Date('2026-08-27T12:01:00Z'));
jest.spyOn(console, 'error').mockImplementation(() => undefined);
useMatchStore.setState({ nudgeCooldownUntil: null });
(appDataApi.sendNudge as jest.Mock).mockRejectedValueOnce(new Error('network'));
await useMatchStore.getState().sendNudge();
expect(useMatchStore.getState().isNudging).toBe(false);
});
it('derives, clears, and guards persisted cooldown status', async () => {
(appDataApi.nudgeStatus as jest.Mock).mockResolvedValueOnce({ last_nudge_sent_at: '2026-08-27T06:00:00Z' });
await useMatchStore.getState().checkNudgeCooldown();
expect(useMatchStore.getState().nudgeCooldownUntil).toEqual(new Date('2026-08-27T18:00:00Z'));
(appDataApi.nudgeStatus as jest.Mock).mockResolvedValueOnce({ last_nudge_sent_at: null });
await useMatchStore.getState().checkNudgeCooldown();
expect(useMatchStore.getState().nudgeCooldownUntil).toBeNull();
useAuthStore.setState({ user: null } as any);
await useMatchStore.getState().checkNudgeCooldown();
expect(appDataApi.nudgeStatus).toHaveBeenCalledTimes(2);
});
});
it('clearMatches resets all user-scoped state', () => {
useMatchStore.setState({ matches: [match('m1')], archivedMatches: [match('m2')], pendingQuestions: [pending('p')], theirTurnQuestions: [pending('t')], currentView: 'archived', showArchived: true, isNudging: true });
useMatchStore.getState().clearMatches();
expect(useMatchStore.getState()).toMatchObject({ matches: [], archivedMatches: [], pendingQuestions: [], theirTurnQuestions: [], currentView: 'pending', showArchived: false, isNudging: false, page: 0, hasMore: true });
});
});
@@ -1,59 +1,79 @@
import { useMessageStore } from '@/store/messageStore';
import { chatApi } from '@/lib/chatApi';
import { useAuthStore } from '@/store/authStore';
import { supabase } from '@/lib/supabase';
import { useMatchStore } from '@/store/matchStore';
import { useMessageStore } from '@/store/messageStore';
function createThenableQuery(result: any) {
const query: any = {
select: jest.fn(() => query),
neq: jest.fn(() => query),
is: jest.fn(() => query),
eq: jest.fn(() => query),
update: jest.fn(() => query),
then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject),
};
return query;
}
jest.mock('@/lib/chatApi', () => ({ chatApi: { unread: jest.fn(), markRead: jest.fn() } }));
describe('messageStore', () => {
describe('messageStore API state', () => {
beforeEach(() => {
jest.clearAllMocks();
useMessageStore.setState({ unreadCount: 0, lastMessage: null, activeMatchId: null } as any);
useMessageStore.getState().clearMessages();
useMatchStore.setState({ matches: [{ id: 'm1', unreadCount: 3 } as any] });
useAuthStore.setState({ user: { id: 'me' } } as any);
});
it('fetchUnreadCount sets unreadCount from Supabase count', async () => {
const countQuery = createThenableQuery({ count: 5 });
(supabase.from as jest.Mock).mockReturnValueOnce(countQuery);
it('fails closed and clears unread state without an authenticated user', async () => {
useMessageStore.setState({ unreadCount: 7 });
useAuthStore.setState({ user: null } as any);
await useMessageStore.getState().fetchUnreadCount();
expect(useMessageStore.getState().unreadCount).toBe(0);
expect(chatApi.unread).not.toHaveBeenCalled();
});
it('fetches the authenticated API unread aggregate', async () => {
(chatApi.unread as jest.Mock).mockResolvedValue({ total: 5, by_match: { m1: 5 } });
await useMessageStore.getState().fetchUnreadCount();
expect(chatApi.unread).toHaveBeenCalledTimes(1);
expect(useMessageStore.getState().unreadCount).toBe(5);
});
it('addMessage increments unreadCount when not in active chat', () => {
useMessageStore.getState().addMessage({ id: 'x', user_id: 'partner', match_id: 'm1' } as any);
expect(useMessageStore.getState().unreadCount).toBe(1);
it('propagates unread errors without replacing the last confirmed count', async () => {
useMessageStore.setState({ unreadCount: 4 });
const error = new Error('unauthorized');
(chatApi.unread as jest.Mock).mockRejectedValue(error);
await expect(useMessageStore.getState().fetchUnreadCount()).rejects.toBe(error);
expect(useMessageStore.getState().unreadCount).toBe(4);
});
it('addMessage does not increment when in active chat', () => {
it('notifies only for a partner message outside the active match', () => {
useMessageStore.getState().addMessage({ id: 'mine', user_id: 'me', match_id: 'm1' } as any);
expect(useMessageStore.getState().unreadCount).toBe(0);
useMessageStore.getState().setActiveMatchId('m1');
useMessageStore.getState().addMessage({ id: 'x', user_id: 'partner', match_id: 'm1' } as any);
useMessageStore.getState().addMessage({ id: 'active', user_id: 'partner', match_id: 'm1' } as any);
expect(useMessageStore.getState().unreadCount).toBe(0);
useMessageStore.getState().setActiveMatchId('m2');
useMessageStore.getState().addMessage({ id: 'new', user_id: 'partner', match_id: 'm1' } as any);
expect(useMessageStore.getState()).toMatchObject({ unreadCount: 1, lastMessage: { id: 'new' } });
});
it('markMatchMessagesAsRead updates Supabase and refetches unread count', async () => {
const unreadCountQuery = createThenableQuery({ count: 2 }); // count before marking
const updateQuery = createThenableQuery({});
const finalCountQuery = createThenableQuery({ count: 0 }); // count after marking
(supabase.from as jest.Mock)
.mockReturnValueOnce(unreadCountQuery) // count unread messages first
.mockReturnValueOnce(updateQuery) // update messages
.mockReturnValueOnce(finalCountQuery); // fetchUnreadCount
it('marks partner messages read then reconciles global and per-match counts', async () => {
(chatApi.markRead as jest.Mock).mockResolvedValue({ updated: 2, read_at: '2026-08-27T00:00:00.000Z' });
(chatApi.unread as jest.Mock).mockResolvedValue({ total: 1, by_match: { m2: 1 } });
await useMessageStore.getState().markMatchMessagesAsRead('m1');
expect(chatApi.markRead).toHaveBeenCalledWith('m1');
expect(useMessageStore.getState().unreadCount).toBe(1);
expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(1);
});
expect(updateQuery.update).toHaveBeenCalled();
expect(useMessageStore.getState().unreadCount).toBe(0);
it('does not mark read without an authenticated user', async () => {
useAuthStore.setState({ user: null } as any);
await useMessageStore.getState().markMatchMessagesAsRead('m1');
expect(chatApi.markRead).not.toHaveBeenCalled();
});
it('does not decrement local counts when the read API fails', async () => {
const error = new Error('foreign match');
(chatApi.markRead as jest.Mock).mockRejectedValue(error);
await expect(useMessageStore.getState().markMatchMessagesAsRead('m1')).rejects.toBe(error);
expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(3);
});
it('clears transient message state', () => {
useMessageStore.setState({ unreadCount: 3, lastMessage: { id: 'last' } as any, activeMatchId: 'm1' });
useMessageStore.getState().clearLastMessage();
expect(useMessageStore.getState().lastMessage).toBeNull();
useMessageStore.getState().clearMessages();
expect(useMessageStore.getState()).toMatchObject({ unreadCount: 0, lastMessage: null, activeMatchId: null });
});
});
@@ -0,0 +1,47 @@
import { profileSettingsApi } from '../../lib/profileSettingsApi';
import { useAuthStore } from '../../store/authStore';
import { useNotificationPreferencesStore } from '../../store/notificationPreferencesStore';
jest.mock('../../lib/profileSettingsApi', () => ({
profileSettingsApi: {
getNotificationPreferences: jest.fn(),
updateNotificationPreference: jest.fn(),
},
}));
const preferences = {
user_id: 'user-1',
matches_enabled: true,
messages_enabled: true,
partner_activity_enabled: true,
nudges_enabled: true,
pack_changes_enabled: true,
new_packs_enabled: true,
streak_milestones_enabled: true,
weekly_summary_enabled: true,
unpaired_reminders_enabled: true,
catchup_reminders_enabled: true,
created_at: '2026-08-27T10:00:00.000Z',
updated_at: '2026-08-27T10:00:00.000Z',
};
describe('notificationPreferencesStore', () => {
beforeEach(() => {
jest.clearAllMocks();
useAuthStore.setState({ user: { id: 'user-1' } } as any);
useNotificationPreferencesStore.getState().clearPreferences();
});
it('loads server defaults and persists an authenticated preference update', async () => {
(profileSettingsApi.getNotificationPreferences as jest.Mock).mockResolvedValue(preferences);
(profileSettingsApi.updateNotificationPreference as jest.Mock)
.mockResolvedValue({ ...preferences, messages_enabled: false });
await useNotificationPreferencesStore.getState().fetchPreferences();
await useNotificationPreferencesStore.getState().updatePreference('messages_enabled', false);
expect(profileSettingsApi.getNotificationPreferences).toHaveBeenCalledWith();
expect(profileSettingsApi.updateNotificationPreference).toHaveBeenCalledWith('messages_enabled', false);
expect(useNotificationPreferencesStore.getState().preferences?.messages_enabled).toBe(false);
});
});
@@ -1,64 +1,67 @@
import { usePacksStore } from '@/store/packsStore';
import { apiClient } from '@/lib/apiClient';
import { useAuthStore } from '@/store/authStore';
import { supabase } from '@/lib/supabase';
import { usePacksStore } from '@/store/packsStore';
function createThenableQuery(result: any) {
const query: any = {
select: jest.fn(() => query),
eq: jest.fn(() => query),
or: jest.fn(() => query),
order: jest.fn(() => query),
upsert: jest.fn(() => query),
then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject),
};
return query;
}
describe('packsStore', () => {
describe('packsStore standalone API integration', () => {
beforeEach(() => {
jest.clearAllMocks();
usePacksStore.setState({ packs: [], categories: [], enabledPackIds: [], packProgress: new Map(), isLoading: false } as any);
useAuthStore.setState({ user: { id: 'me', couple_id: 'c1', hide_nsfw: false } } as any);
jest.restoreAllMocks();
usePacksStore.setState({
packs: [], categories: [], enabledPackIds: [], packProgress: new Map(),
isLoading: false, showAllIntensities: false,
} as any);
useAuthStore.setState({ user: { id: 'me', couple_id: 'c1', hide_nsfw: true } } as any);
});
it('fetches categories/packs and filters by hide_nsfw', async () => {
// Set hide_nsfw to true - should filter intensity to max 2
useAuthStore.setState({ user: { id: 'me', couple_id: 'c1', hide_nsfw: true } } as any);
const categoriesQuery = createThenableQuery({ data: [{ id: 'cat1' }] });
const packsQuery = createThenableQuery({ data: [{ id: 'pack1', is_explicit: false }] });
const enabledQuery = createThenableQuery({ data: [{ pack_id: 'pack1' }] });
const responsesQuery = createThenableQuery({ data: [] });
(supabase.from as jest.Mock)
.mockReturnValueOnce(categoriesQuery)
.mockReturnValueOnce(packsQuery)
.mockReturnValueOnce(enabledQuery)
.mockReturnValueOnce(responsesQuery);
it('loads the server-filtered catalog, couple selection, and user progress', async () => {
const getSpy = jest.spyOn(apiClient, 'get')
.mockResolvedValueOnce({
categories: [{ id: 'cat1', is_public: true }],
packs: [{ id: 'pack1', questions: [{ count: 3 }] }],
})
.mockResolvedValueOnce({ enabledPackIds: ['pack1'] })
.mockResolvedValueOnce({
progress: [{ packId: 'pack1', totalQuestions: 3, answeredQuestions: 1 }],
});
await usePacksStore.getState().fetchPacks();
// When hide_nsfw=true, max_intensity=2, so or filter is called
expect(packsQuery.or).toHaveBeenCalledWith('max_intensity.is.null,max_intensity.lte.2');
// When hide_nsfw=true, explicit packs are filtered out
expect(packsQuery.eq).toHaveBeenCalledWith('is_explicit', false);
const state = usePacksStore.getState();
expect(state.categories).toEqual([{ id: 'cat1' }]);
expect(state.packs).toEqual([{ id: 'pack1', is_explicit: false }]);
expect(state.enabledPackIds).toEqual(['pack1']);
expect(state.isLoading).toBe(false);
expect(getSpy).toHaveBeenNthCalledWith(1, '/v1/packs?showAllIntensities=false');
expect(getSpy).toHaveBeenNthCalledWith(2, '/v1/me/enabled-packs');
expect(getSpy).toHaveBeenNthCalledWith(3, '/v1/me/pack-progress');
expect(usePacksStore.getState()).toMatchObject({ enabledPackIds: ['pack1'], isLoading: false });
expect(usePacksStore.getState().packProgress.get('pack1')).toEqual({
totalQuestions: 3, answeredQuestions: 1,
});
});
it('togglePack performs optimistic update and returns success on upsert', async () => {
it('sends an authenticated toggle and accepts the authoritative enabled list', async () => {
usePacksStore.setState({ enabledPackIds: [] } as any);
const putSpy = jest.spyOn(apiClient, 'put').mockResolvedValueOnce({ enabledPackIds: ['pack1'] });
const upsertQuery = createThenableQuery({ error: null });
(supabase.from as jest.Mock).mockReturnValueOnce(upsertQuery);
await expect(usePacksStore.getState().togglePack('pack1')).resolves.toEqual({ success: true });
const result = await usePacksStore.getState().togglePack('pack1');
expect(result.success).toBe(true);
expect(putSpy).toHaveBeenCalledWith('/v1/me/enabled-packs/pack1', { enabled: true });
expect(usePacksStore.getState().enabledPackIds).toEqual(['pack1']);
});
it('rolls back an optimistic toggle when the API rejects it', async () => {
usePacksStore.setState({ enabledPackIds: ['pack1'] } as any);
jest.spyOn(apiClient, 'put').mockRejectedValueOnce(new Error('offline'));
jest.spyOn(console, 'error').mockImplementation(() => undefined);
await expect(usePacksStore.getState().togglePack('pack1')).resolves.toEqual({
success: false, reason: 'error',
});
expect(usePacksStore.getState().enabledPackIds).toEqual(['pack1']);
});
it('does not issue couple requests when the user has no couple', async () => {
useAuthStore.setState({ user: { id: 'me', couple_id: null } } as any);
const getSpy = jest.spyOn(apiClient, 'get');
await usePacksStore.getState().fetchEnabledPacks();
expect(getSpy).not.toHaveBeenCalled();
expect(usePacksStore.getState().enabledPackIds).toEqual([]);
});
});
@@ -1,499 +1,332 @@
import { useResponsesStore, groupResponses, ResponseWithQuestion } from '@/store/responsesStore';
import { useAuthStore } from '@/store/authStore';
import { useMatchStore } from '@/store/matchStore';
import { supabase } from '@/lib/supabase';
// Mock authErrorHandler
jest.mock('@/lib/authErrorHandler', () => ({
invokeWithAuthRetry: jest.fn(),
}));
import { invokeWithAuthRetry } from '@/lib/authErrorHandler';
function createThenableQuery(result: any) {
const query: any = {
select: jest.fn(() => query),
eq: jest.fn(() => query),
neq: jest.fn(() => query),
is: jest.fn(() => query),
in: jest.fn(() => query),
order: jest.fn(() => query),
range: jest.fn(() => query),
then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject),
};
return query;
}
describe('responsesStore', () => {
beforeEach(() => {
jest.clearAllMocks();
useResponsesStore.setState({
responses: [],
isLoading: false,
isLoadingMore: false,
groupBy: 'date',
dateSortOrder: 'newest',
hasMore: true,
page: 0,
totalCount: null,
});
useAuthStore.setState({ user: { id: 'me', couple_id: 'couple1' } } as any);
useMatchStore.setState({ matches: [], fetchMatches: jest.fn() } as any);
});
describe('fetchResponses', () => {
it('fetches responses with questions and packs on refresh', async () => {
const responses = [
{
id: 'r1',
question_id: 'q1',
answer: 'yes',
created_at: '2024-01-01T00:00:00.000Z',
question: {
id: 'q1',
text: 'Question 1',
partner_text: null,
intensity: 2,
pack_id: 'p1',
pack: { id: 'p1', name: 'Pack 1', icon: '❤️' },
},
},
];
const countQuery = createThenableQuery({ count: 1 });
const responsesQuery = createThenableQuery({ data: responses });
const matchesQuery = createThenableQuery({ data: [{ id: 'm1', question_id: 'q1' }] });
const partnerQuery = createThenableQuery({ data: [{ question_id: 'q1' }] });
(supabase.from as jest.Mock)
.mockReturnValueOnce(countQuery)
.mockReturnValueOnce(responsesQuery)
.mockReturnValueOnce(matchesQuery)
.mockReturnValueOnce(partnerQuery);
await useResponsesStore.getState().fetchResponses(true);
const state = useResponsesStore.getState();
expect(state.responses).toHaveLength(1);
expect(state.responses[0].has_match).toBe(true);
expect(state.responses[0].match_id).toBe('m1');
expect(state.responses[0].partner_answered).toBe(true);
expect(state.totalCount).toBe(1);
expect(state.isLoading).toBe(false);
});
it('does nothing if user is not authenticated', async () => {
useAuthStore.setState({ user: null } as any);
await useResponsesStore.getState().fetchResponses(true);
expect(supabase.from).not.toHaveBeenCalled();
});
it('does nothing if couple_id is missing', async () => {
useAuthStore.setState({ user: { id: 'me', couple_id: null } } as any);
await useResponsesStore.getState().fetchResponses(true);
expect(supabase.from).not.toHaveBeenCalled();
});
it('prevents concurrent loading', async () => {
useResponsesStore.setState({ isLoading: true });
await useResponsesStore.getState().fetchResponses(true);
expect(supabase.from).not.toHaveBeenCalled();
});
it('handles empty responses correctly', async () => {
const countQuery = createThenableQuery({ count: 0 });
const responsesQuery = createThenableQuery({ data: [] });
(supabase.from as jest.Mock)
.mockReturnValueOnce(countQuery)
.mockReturnValueOnce(responsesQuery);
await useResponsesStore.getState().fetchResponses(true);
const state = useResponsesStore.getState();
expect(state.responses).toEqual([]);
expect(state.hasMore).toBe(false);
expect(state.totalCount).toBe(0);
});
it('paginates responses correctly', async () => {
// Set up initial state with some responses
useResponsesStore.setState({
page: 1,
hasMore: true,
responses: [{ id: 'r0' }] as any,
});
const responses = [
{
id: 'r1',
question_id: 'q1',
answer: 'yes',
created_at: '2024-01-01T00:00:00.000Z',
question: {
id: 'q1',
text: 'Question 1',
pack: { id: 'p1', name: 'Pack 1', icon: '❤️' },
},
},
];
const responsesQuery = createThenableQuery({ data: responses });
const matchesQuery = createThenableQuery({ data: [] });
const partnerQuery = createThenableQuery({ data: [] });
(supabase.from as jest.Mock)
.mockReturnValueOnce(responsesQuery)
.mockReturnValueOnce(matchesQuery)
.mockReturnValueOnce(partnerQuery);
await useResponsesStore.getState().fetchResponses(false);
const state = useResponsesStore.getState();
// Should append to existing responses
expect(state.responses).toHaveLength(2);
expect(state.page).toBe(2);
});
it('handles fetch errors gracefully', async () => {
const countQuery = createThenableQuery({ count: 1 });
const responsesQuery = createThenableQuery({ data: null, error: new Error('Network error') });
(supabase.from as jest.Mock)
.mockReturnValueOnce(countQuery)
.mockReturnValueOnce(responsesQuery);
await useResponsesStore.getState().fetchResponses(true);
const state = useResponsesStore.getState();
expect(state.isLoading).toBe(false);
expect(state.isLoadingMore).toBe(false);
});
});
describe('updateResponse', () => {
it('updates response and refreshes matches on success', async () => {
const fetchMatchesMock = jest.fn();
useMatchStore.setState({ fetchMatches: fetchMatchesMock } as any);
useResponsesStore.setState({
responses: [
{ question_id: 'q1', answer: 'maybe', has_match: false } as any,
],
});
(invokeWithAuthRetry as jest.Mock).mockResolvedValueOnce({
data: { success: true, new_match: { id: 'm1' } },
error: null,
});
const result = await useResponsesStore.getState().updateResponse('q1', 'yes');
expect(result.success).toBe(true);
expect(result.new_match).toEqual({ id: 'm1' });
const state = useResponsesStore.getState();
expect(state.responses[0].answer).toBe('yes');
expect(state.responses[0].has_match).toBe(true);
expect(state.responses[0].match_id).toBe('m1');
expect(fetchMatchesMock).toHaveBeenCalled();
});
it('handles match deletion correctly', async () => {
const fetchMatchesMock = jest.fn();
useMatchStore.setState({ fetchMatches: fetchMatchesMock } as any);
useResponsesStore.setState({
responses: [
{ question_id: 'q1', answer: 'yes', has_match: true, match_id: 'm1' } as any,
],
});
(invokeWithAuthRetry as jest.Mock).mockResolvedValueOnce({
data: { success: true, match_deleted: true },
error: null,
});
const result = await useResponsesStore.getState().updateResponse('q1', 'no', true);
expect(result.success).toBe(true);
expect(result.match_deleted).toBe(true);
const state = useResponsesStore.getState();
expect(state.responses[0].answer).toBe('no');
expect(state.responses[0].has_match).toBe(false);
expect(state.responses[0].match_id).toBeUndefined();
expect(fetchMatchesMock).toHaveBeenCalled();
});
it('returns requires_confirmation without updating local state', async () => {
useResponsesStore.setState({
responses: [
{ question_id: 'q1', answer: 'yes', has_match: true } as any,
],
});
(invokeWithAuthRetry as jest.Mock).mockResolvedValueOnce({
data: { success: false, requires_confirmation: true, message_count: 5 },
error: null,
});
const result = await useResponsesStore.getState().updateResponse('q1', 'no');
expect(result.requires_confirmation).toBe(true);
expect(result.message_count).toBe(5);
const state = useResponsesStore.getState();
// Should not update local state
expect(state.responses[0].answer).toBe('yes');
});
it('handles errors from edge function', async () => {
(invokeWithAuthRetry as jest.Mock).mockResolvedValueOnce({
data: null,
error: { message: 'Server error' },
});
const result = await useResponsesStore.getState().updateResponse('q1', 'yes');
expect(result.success).toBe(false);
expect(result.error).toBe('Server error');
});
it('refreshes matches when match_type_updated', async () => {
const fetchMatchesMock = jest.fn();
useMatchStore.setState({ fetchMatches: fetchMatchesMock } as any);
useResponsesStore.setState({
responses: [
{ question_id: 'q1', answer: 'maybe', has_match: true, match_id: 'm1' } as any,
],
});
(invokeWithAuthRetry as jest.Mock).mockResolvedValueOnce({
data: { success: true, match_type_updated: true },
error: null,
});
await useResponsesStore.getState().updateResponse('q1', 'yes');
expect(fetchMatchesMock).toHaveBeenCalled();
});
});
describe('setGroupBy', () => {
it('updates groupBy option', () => {
useResponsesStore.getState().setGroupBy('pack');
expect(useResponsesStore.getState().groupBy).toBe('pack');
useResponsesStore.getState().setGroupBy('answer');
expect(useResponsesStore.getState().groupBy).toBe('answer');
useResponsesStore.getState().setGroupBy('date');
expect(useResponsesStore.getState().groupBy).toBe('date');
});
});
describe('setDateSortOrder', () => {
it('updates date sort order', () => {
useResponsesStore.getState().setDateSortOrder('oldest');
expect(useResponsesStore.getState().dateSortOrder).toBe('oldest');
useResponsesStore.getState().setDateSortOrder('newest');
expect(useResponsesStore.getState().dateSortOrder).toBe('newest');
});
});
describe('toggleDateSortOrder', () => {
it('toggles between newest and oldest', () => {
expect(useResponsesStore.getState().dateSortOrder).toBe('newest');
useResponsesStore.getState().toggleDateSortOrder();
expect(useResponsesStore.getState().dateSortOrder).toBe('oldest');
useResponsesStore.getState().toggleDateSortOrder();
expect(useResponsesStore.getState().dateSortOrder).toBe('newest');
});
});
describe('clearResponses', () => {
it('resets store to initial state', () => {
useResponsesStore.setState({
responses: [{ id: 'r1' } as any],
isLoading: true,
groupBy: 'pack',
dateSortOrder: 'oldest',
page: 5,
hasMore: false,
isLoadingMore: true,
totalCount: 10,
});
useResponsesStore.getState().clearResponses();
const state = useResponsesStore.getState();
expect(state.responses).toEqual([]);
expect(state.isLoading).toBe(false);
expect(state.groupBy).toBe('date');
expect(state.dateSortOrder).toBe('newest');
expect(state.page).toBe(0);
expect(state.hasMore).toBe(true);
expect(state.isLoadingMore).toBe(false);
expect(state.totalCount).toBeNull();
});
});
});
describe('groupResponses', () => {
const mockResponses: ResponseWithQuestion[] = [
{
id: 'r1',
question_id: 'q1',
answer: 'yes',
created_at: '2024-01-15T10:00:00.000Z',
question: {
id: 'q1',
text: 'Question 1',
partner_text: null,
intensity: 2,
pack_id: 'p1',
created_at: '',
pack: { id: 'p1', name: 'Pack A', icon: '❤️' },
},
has_match: true,
partner_answered: true,
},
{
id: 'r2',
question_id: 'q2',
answer: 'maybe',
created_at: '2024-01-15T14:00:00.000Z',
question: {
id: 'q2',
text: 'Question 2',
partner_text: null,
intensity: 3,
pack_id: 'p2',
created_at: '',
pack: { id: 'p2', name: 'Pack B', icon: '💜' },
},
has_match: false,
partner_answered: false,
},
{
id: 'r3',
question_id: 'q3',
answer: 'no',
created_at: '2024-01-14T10:00:00.000Z',
question: {
id: 'q3',
text: 'Question 3',
partner_text: null,
intensity: 1,
pack_id: 'p1',
created_at: '',
pack: { id: 'p1', name: 'Pack A', icon: '❤️' },
},
has_match: false,
partner_answered: true,
},
{
id: 'r4',
question_id: 'q4',
answer: 'yes',
created_at: '2024-01-14T14:00:00.000Z',
question: {
id: 'q4',
text: 'Question 4',
partner_text: null,
intensity: 2,
pack_id: 'p2',
created_at: '',
pack: { id: 'p2', name: 'Pack B', icon: '💜' },
},
has_match: true,
partner_answered: true,
},
];
describe('groupBy pack', () => {
it('groups responses by pack name', () => {
const grouped = groupResponses(mockResponses, 'pack');
expect(grouped).toHaveLength(2);
const packA = grouped.find(g => g.title === 'Pack A');
const packB = grouped.find(g => g.title === 'Pack B');
expect(packA?.data).toHaveLength(2);
expect(packB?.data).toHaveLength(2);
});
});
describe('groupBy answer', () => {
it('groups responses by answer type in order: yes, maybe, no', () => {
const grouped = groupResponses(mockResponses, 'answer');
expect(grouped).toHaveLength(3);
expect(grouped[0].title).toBe('Yes');
expect(grouped[0].data).toHaveLength(2);
expect(grouped[1].title).toBe('Maybe');
expect(grouped[1].data).toHaveLength(1);
expect(grouped[2].title).toBe('No');
expect(grouped[2].data).toHaveLength(1);
});
it('excludes empty answer groups', () => {
const yesOnlyResponses = mockResponses.filter(r => r.answer === 'yes');
const grouped = groupResponses(yesOnlyResponses, 'answer');
expect(grouped).toHaveLength(1);
expect(grouped[0].title).toBe('Yes');
});
});
describe('groupBy date', () => {
it('groups responses by date with newest first', () => {
const grouped = groupResponses(mockResponses, 'date', 'newest');
expect(grouped).toHaveLength(2);
// January 15 should be first (newer)
expect(grouped[0].title).toContain('January 15');
expect(grouped[0].data).toHaveLength(2);
expect(grouped[1].title).toContain('January 14');
expect(grouped[1].data).toHaveLength(2);
});
it('groups responses by date with oldest first', () => {
const grouped = groupResponses(mockResponses, 'date', 'oldest');
expect(grouped).toHaveLength(2);
// January 14 should be first (older)
expect(grouped[0].title).toContain('January 14');
expect(grouped[1].title).toContain('January 15');
});
it('sorts responses within each date group', () => {
const grouped = groupResponses(mockResponses, 'date', 'newest');
// Within January 15, the response at 14:00 should come first (newest)
const jan15Group = grouped[0];
expect(new Date(jan15Group.data[0].created_at).getTime())
.toBeGreaterThan(new Date(jan15Group.data[1].created_at).getTime());
});
});
describe('default grouping', () => {
it('returns all responses in a single group for unknown groupBy', () => {
// @ts-expect-error Testing unknown groupBy value
const grouped = groupResponses(mockResponses, 'unknown');
expect(grouped).toHaveLength(1);
expect(grouped[0].title).toBe('All Responses');
expect(grouped[0].data).toHaveLength(4);
});
});
});
import { apiClient } from '@/lib/apiClient';
import { groupResponses, type ResponseWithQuestion, useResponsesStore } from '@/store/responsesStore';
import { useAuthStore } from '@/store/authStore';
import { useMatchStore } from '@/store/matchStore';
jest.mock('@/lib/apiClient', () => ({ apiClient: { get: jest.fn(), patch: jest.fn() } }));
const apiGet = apiClient.get as jest.Mock;
const apiPatch = apiClient.patch as jest.Mock;
function response(overrides: Partial<ResponseWithQuestion> = {}): ResponseWithQuestion {
return {
id: 'r1',
question_id: 'q1',
answer: 'yes',
response_data: null,
created_at: '2024-01-15T10:00:00.000Z',
question: {
id: 'q1', text: 'Question 1', partner_text: null, intensity: 2,
pack_id: 'p1', created_at: '',
pack: { id: 'p1', name: 'Pack A', icon: 'heart' },
},
has_match: true,
match_id: 'm1',
partner_answered: true,
...overrides,
} as ResponseWithQuestion;
}
function resetStore(): void {
useResponsesStore.setState({
responses: [], isLoading: false, isLoadingMore: false, groupBy: 'date',
dateSortOrder: 'newest', hasMore: true, page: 0, totalCount: null,
});
}
describe('responsesStore', () => {
beforeEach(() => {
jest.clearAllMocks();
resetStore();
useAuthStore.setState({ user: { id: 'me', couple_id: 'couple1' } } as any);
useMatchStore.setState({ matches: [], fetchMatches: jest.fn() } as any);
});
describe('fetchResponses', () => {
it('fetches server-composed response, question, match, and partner state on refresh', async () => {
const row = response();
apiGet.mockResolvedValue({ responses: [row], totalCount: 1 });
await useResponsesStore.getState().fetchResponses(true);
expect(apiGet).toHaveBeenCalledWith('/v1/me/responses?page=0&limit=20');
expect(useResponsesStore.getState()).toMatchObject({
responses: [row], totalCount: 1, page: 1, isLoading: false,
isLoadingMore: false, hasMore: false,
});
expect(useResponsesStore.getState().responses[0]).toMatchObject({
has_match: true, match_id: 'm1', partner_answered: true,
question: { pack: { name: 'Pack A' } },
});
});
it.each([
['no authenticated user', null],
['no couple', { id: 'me', couple_id: null }],
])('does not call the API with %s', async (_label, user) => {
useAuthStore.setState({ user } as any);
await useResponsesStore.getState().fetchResponses(true);
expect(apiGet).not.toHaveBeenCalled();
});
it('prevents a second request while refresh loading is active', async () => {
let resolve!: (value: unknown) => void;
apiGet.mockReturnValue(new Promise((done) => { resolve = done; }));
const first = useResponsesStore.getState().fetchResponses(true);
await useResponsesStore.getState().fetchResponses(true);
expect(apiGet).toHaveBeenCalledTimes(1);
resolve({ responses: [], totalCount: 0 });
await first;
});
it('prevents load-more overlap but permits an explicit refresh', async () => {
useResponsesStore.setState({ isLoadingMore: true });
await useResponsesStore.getState().fetchResponses(false);
expect(apiGet).not.toHaveBeenCalled();
apiGet.mockResolvedValue({ responses: [], totalCount: 0 });
await useResponsesStore.getState().fetchResponses(true);
expect(apiGet).toHaveBeenCalledTimes(1);
});
it('does not request another page after exhaustion', async () => {
useResponsesStore.setState({ hasMore: false });
await useResponsesStore.getState().fetchResponses(false);
expect(apiGet).not.toHaveBeenCalled();
});
it('clears stale rows and captures total count for an empty refresh', async () => {
useResponsesStore.setState({ responses: [response()], totalCount: 4 });
apiGet.mockResolvedValue({ responses: [], totalCount: 0 });
await useResponsesStore.getState().fetchResponses(true);
expect(useResponsesStore.getState()).toMatchObject({
responses: [], totalCount: 0, hasMore: false, isLoading: false,
});
});
it('marks load-more exhausted without clearing existing responses', async () => {
const existing = response();
useResponsesStore.setState({ responses: [existing], page: 1, totalCount: 1 });
apiGet.mockResolvedValue({ responses: [], totalCount: 1 });
await useResponsesStore.getState().fetchResponses(false);
expect(useResponsesStore.getState()).toMatchObject({
responses: [existing], page: 1, totalCount: 1,
hasMore: false, isLoadingMore: false,
});
});
it('appends the requested page and preserves refresh total count', async () => {
const existing = response();
const next = response({ id: 'r2', question_id: 'q2' });
useResponsesStore.setState({ responses: [existing], page: 1, totalCount: 21 });
apiGet.mockResolvedValue({ responses: [next], totalCount: 21 });
await useResponsesStore.getState().fetchResponses(false);
expect(apiGet).toHaveBeenCalledWith('/v1/me/responses?page=1&limit=20');
expect(useResponsesStore.getState()).toMatchObject({
responses: [existing, next], page: 2, totalCount: 21,
hasMore: false, isLoadingMore: false,
});
});
it('keeps hasMore true for a full page', async () => {
apiGet.mockResolvedValue({
responses: Array.from({ length: 20 }, (_, index) => response({ id: `r${index}` })),
totalCount: 40,
});
await useResponsesStore.getState().fetchResponses(true);
expect(useResponsesStore.getState().hasMore).toBe(true);
});
it('releases refresh loading after an API failure without destroying existing data', async () => {
const existing = response();
useResponsesStore.setState({ responses: [existing], totalCount: 1 });
apiGet.mockRejectedValue(new Error('Network error'));
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
await useResponsesStore.getState().fetchResponses(true);
expect(useResponsesStore.getState()).toMatchObject({
responses: [existing], isLoading: false, isLoadingMore: false, totalCount: 1,
});
expect(consoleSpy).toHaveBeenCalledWith('Error in fetchResponses:', expect.any(Error));
consoleSpy.mockRestore();
});
it('releases load-more loading after an API failure', async () => {
useResponsesStore.setState({ page: 1 });
apiGet.mockRejectedValue(new Error('Network error'));
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
await useResponsesStore.getState().fetchResponses(false);
expect(useResponsesStore.getState()).toMatchObject({ isLoading: false, isLoadingMore: false, page: 1 });
consoleSpy.mockRestore();
});
});
describe('updateResponse', () => {
it('creates a local match and refreshes the match store', async () => {
const fetchMatches = jest.fn();
useMatchStore.setState({ fetchMatches } as any);
useResponsesStore.setState({ responses: [response({ answer: 'maybe', has_match: false, match_id: undefined })] });
apiPatch.mockResolvedValue({ success: true, new_match: { id: 'm2' } });
const result = await useResponsesStore.getState().updateResponse('q1', 'yes');
expect(apiPatch).toHaveBeenCalledWith('/v1/responses/q1', {
new_answer: 'yes', confirm_delete_match: false, response_data: undefined,
});
expect(result).toMatchObject({ success: true, new_match: { id: 'm2' } });
expect(useResponsesStore.getState().responses[0]).toMatchObject({
answer: 'yes', has_match: true, match_id: 'm2',
});
expect(fetchMatches).toHaveBeenCalledTimes(1);
});
it('deletes a confirmed match and clears response data for a no answer', async () => {
const fetchMatches = jest.fn();
useMatchStore.setState({ fetchMatches } as any);
useResponsesStore.setState({ responses: [response({ response_data: { type: 'text_answer', text: 'old' } })] });
apiPatch.mockResolvedValue({ success: true, match_deleted: true });
await useResponsesStore.getState().updateResponse('q1', 'no', true);
expect(useResponsesStore.getState().responses[0]).toMatchObject({
answer: 'no', response_data: null, has_match: false,
});
expect(useResponsesStore.getState().responses[0].match_id).toBeUndefined();
expect(fetchMatches).toHaveBeenCalled();
});
it('returns confirmation details without mutating local state', async () => {
const original = response();
useResponsesStore.setState({ responses: [original] });
apiPatch.mockResolvedValue({ success: false, requires_confirmation: true, match_id: 'm1', message_count: 5 });
const result = await useResponsesStore.getState().updateResponse('q1', 'no');
expect(result).toMatchObject({ requires_confirmation: true, message_count: 5 });
expect(useResponsesStore.getState().responses[0]).toEqual(original);
});
it('preserves existing response data when omitted', async () => {
const data = { type: 'text_answer', text: 'existing' } as const;
useResponsesStore.setState({ responses: [response({ response_data: data })] });
apiPatch.mockResolvedValue({ success: true });
await useResponsesStore.getState().updateResponse('q1', 'yes');
expect(useResponsesStore.getState().responses[0].response_data).toEqual(data);
});
it('sets replacement response data when supplied', async () => {
const replacement = { type: 'text_answer', text: 'new' } as const;
useResponsesStore.setState({ responses: [response()] });
apiPatch.mockResolvedValue({ success: true });
await useResponsesStore.getState().updateResponse('q1', 'yes', false, replacement);
expect(apiPatch).toHaveBeenCalledWith('/v1/responses/q1', expect.objectContaining({ response_data: replacement }));
expect(useResponsesStore.getState().responses[0].response_data).toEqual(replacement);
});
it('accepts an explicit null response-data update', async () => {
useResponsesStore.setState({ responses: [response({ response_data: { type: 'text_answer', text: 'old' } })] });
apiPatch.mockResolvedValue({ success: true });
await useResponsesStore.getState().updateResponse('q1', 'yes', false, null);
expect(useResponsesStore.getState().responses[0].response_data).toBeNull();
});
it('refreshes matches when only the match type changed', async () => {
const fetchMatches = jest.fn();
useMatchStore.setState({ fetchMatches } as any);
useResponsesStore.setState({ responses: [response({ answer: 'maybe' })] });
apiPatch.mockResolvedValue({ success: true, match_type_updated: true });
await useResponsesStore.getState().updateResponse('q1', 'yes');
expect(fetchMatches).toHaveBeenCalledTimes(1);
});
it('does not refresh matches for a plain successful response edit', async () => {
const fetchMatches = jest.fn();
useMatchStore.setState({ fetchMatches } as any);
useResponsesStore.setState({ responses: [response()] });
apiPatch.mockResolvedValue({ success: true });
await useResponsesStore.getState().updateResponse('q1', 'maybe');
expect(fetchMatches).not.toHaveBeenCalled();
});
it('returns an Error message and leaves state unchanged on API rejection', async () => {
const original = response();
useResponsesStore.setState({ responses: [original] });
apiPatch.mockRejectedValue(new Error('Server error'));
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
const result = await useResponsesStore.getState().updateResponse('q1', 'no');
expect(result).toEqual({ success: false, error: 'Server error' });
expect(useResponsesStore.getState().responses[0]).toEqual(original);
consoleSpy.mockRestore();
});
it('normalizes non-Error rejection values', async () => {
apiPatch.mockRejectedValue('failed');
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined);
await expect(useResponsesStore.getState().updateResponse('q1', 'yes'))
.resolves.toEqual({ success: false, error: 'Failed to update response' });
consoleSpy.mockRestore();
});
});
describe('state controls', () => {
it('sets and toggles grouping options', () => {
useResponsesStore.getState().setGroupBy('pack');
expect(useResponsesStore.getState().groupBy).toBe('pack');
useResponsesStore.getState().setDateSortOrder('oldest');
expect(useResponsesStore.getState().dateSortOrder).toBe('oldest');
useResponsesStore.getState().toggleDateSortOrder();
expect(useResponsesStore.getState().dateSortOrder).toBe('newest');
});
it('clears all response-scoped state', () => {
useResponsesStore.setState({
responses: [response()], isLoading: true, isLoadingMore: true,
groupBy: 'pack', dateSortOrder: 'oldest', page: 5,
hasMore: false, totalCount: 10,
});
useResponsesStore.getState().clearResponses();
expect(useResponsesStore.getState()).toMatchObject({
responses: [], isLoading: false, isLoadingMore: false,
groupBy: 'date', dateSortOrder: 'newest', page: 0,
hasMore: true, totalCount: null,
});
});
});
});
describe('groupResponses', () => {
const rows: ResponseWithQuestion[] = [
response(),
response({ id: 'r2', question_id: 'q2', answer: 'maybe', created_at: '2024-01-15T14:00:00.000Z', question: { ...response().question, id: 'q2', pack_id: 'p2', pack: { id: 'p2', name: 'Pack B', icon: 'moon' } } }),
response({ id: 'r3', question_id: 'q3', answer: 'no', created_at: '2024-01-14T10:00:00.000Z' }),
response({ id: 'r4', question_id: 'q4', answer: 'yes', created_at: '2024-01-14T14:00:00.000Z', question: { ...response().question, id: 'q4', pack_id: 'p2', pack: { id: 'p2', name: 'Pack B', icon: 'moon' } } }),
];
it('groups by pack while preserving rows', () => {
const grouped = groupResponses(rows, 'pack');
expect(grouped.map((group) => [group.title, group.data.length])).toEqual([['Pack A', 2], ['Pack B', 2]]);
});
it('orders non-empty answer groups yes, maybe, no', () => {
expect(groupResponses(rows, 'answer').map((group) => [group.title, group.data.length]))
.toEqual([['Yes', 2], ['Maybe', 1], ['No', 1]]);
expect(groupResponses(rows.filter((row) => row.answer === 'yes'), 'answer').map((group) => group.title))
.toEqual(['Yes']);
});
it('groups dates newest-first and sorts within each day', () => {
const grouped = groupResponses(rows, 'date', 'newest');
expect(grouped[0].title).toContain('January 15');
expect(grouped[1].title).toContain('January 14');
expect(grouped[0].data.map((row) => row.id)).toEqual(['r2', 'r1']);
});
it('groups dates oldest-first and sorts within each day', () => {
const grouped = groupResponses(rows, 'date', 'oldest');
expect(grouped[0].title).toContain('January 14');
expect(grouped[1].title).toContain('January 15');
expect(grouped[0].data.map((row) => row.id)).toEqual(['r3', 'r4']);
});
it('falls back to one section for an unknown grouping option', () => {
// @ts-expect-error deliberately verifies defensive fallback behavior
expect(groupResponses(rows, 'unknown')).toEqual([{ title: 'All Responses', data: rows }]);
});
});
@@ -27,9 +27,13 @@ jest.mock('../../lib/revenuecat', () => ({
import { useSubscriptionStore } from '@/store/subscriptionStore';
import { useAuthStore } from '@/store/authStore';
import { supabase } from '@/lib/supabase';
import { accountOperationsApi } from '@/lib/accountOperationsApi';
import revenueCatService from '../../lib/revenuecat';
jest.mock('@/lib/accountOperationsApi', () => ({
accountOperationsApi: { syncSubscription: jest.fn(async () => ({ success: true, is_premium: true })) },
}));
describe('subscriptionStore', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -167,9 +171,34 @@ describe('subscriptionStore', () => {
const state = useSubscriptionStore.getState();
expect(state.subscription.isProUser).toBe(true);
expect(state.isPurchasing).toBe(false);
expect(accountOperationsApi.syncSubscription).toHaveBeenCalledTimes(1);
expect(useAuthStore.getState().fetchUser).toHaveBeenCalled();
});
it('keeps a completed purchase successful when server reconciliation fails', async () => {
const mockPackage = { identifier: 'monthly' };
(revenueCatService.parseCustomerInfo as jest.Mock).mockReturnValue({
isProUser: true,
activeSubscription: 'monthly',
expirationDate: '2025-01-01',
willRenew: true,
});
(accountOperationsApi.syncSubscription as jest.Mock).mockRejectedValueOnce(
new Error('Sync unavailable')
);
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
const result = await useSubscriptionStore.getState().purchasePackage(mockPackage as any);
expect(result.success).toBe(true);
expect(useSubscriptionStore.getState().subscription.isProUser).toBe(true);
expect(consoleSpy).toHaveBeenCalledWith(
'Error syncing subscription after purchase:',
expect.any(Error)
);
consoleSpy.mockRestore();
});
it('handles cancelled purchase', async () => {
const mockPackage = { identifier: 'monthly' };
(revenueCatService.purchasePackage as jest.Mock).mockRejectedValueOnce(
@@ -215,6 +244,8 @@ describe('subscriptionStore', () => {
const state = useSubscriptionStore.getState();
expect(state.subscription.isProUser).toBe(true);
expect(state.isPurchasing).toBe(false);
expect(accountOperationsApi.syncSubscription).toHaveBeenCalledTimes(1);
expect(useAuthStore.getState().fetchUser).toHaveBeenCalled();
});
it('returns false when no purchases to restore', async () => {
@@ -237,7 +268,7 @@ describe('subscriptionStore', () => {
});
describe('refreshSubscriptionStatus', () => {
it('refreshes subscription status', async () => {
it('refreshes and syncs a changed subscription status through the standalone API', async () => {
(revenueCatService.parseCustomerInfo as jest.Mock).mockReturnValue({
isProUser: true,
activeSubscription: 'monthly',
@@ -249,6 +280,17 @@ describe('subscriptionStore', () => {
const state = useSubscriptionStore.getState();
expect(state.subscription.isProUser).toBe(true);
expect(accountOperationsApi.syncSubscription).toHaveBeenCalled();
expect(useAuthStore.getState().fetchUser).toHaveBeenCalled();
});
it('skips refresh until RevenueCat is initialized', async () => {
(revenueCatService.isInitialized as jest.Mock).mockReturnValue(false);
await useSubscriptionStore.getState().refreshSubscriptionStatus();
expect(revenueCatService.getCustomerInfo).not.toHaveBeenCalled();
expect(accountOperationsApi.syncSubscription).not.toHaveBeenCalled();
});
it('handles refresh errors gracefully', async () => {
@@ -261,6 +303,32 @@ describe('subscriptionStore', () => {
});
});
describe('customer info updates', () => {
it('syncs the server when RevenueCat reports a changed entitlement', async () => {
let listener: ((customerInfo: unknown) => void) | undefined;
(revenueCatService.addCustomerInfoUpdateListener as jest.Mock).mockImplementationOnce((callback) => {
listener = callback;
return () => {};
});
await useSubscriptionStore.getState().initializeRevenueCat('user1');
jest.clearAllMocks();
(revenueCatService.parseCustomerInfo as jest.Mock).mockReturnValue({
isProUser: true,
activeSubscription: 'monthly',
expirationDate: '2025-01-01',
willRenew: true,
});
listener?.({ entitlements: { active: {} } });
await Promise.resolve();
await Promise.resolve();
expect(accountOperationsApi.syncSubscription).toHaveBeenCalledTimes(1);
expect(useAuthStore.getState().fetchUser).toHaveBeenCalled();
});
});
describe('clearSubscription', () => {
it('resets state and logs out', () => {
useSubscriptionStore.setState({
+13
View File
@@ -0,0 +1,13 @@
import React, { useEffect, useState } from 'react';
import { Image, type ImageProps } from 'expo-image';
import { getCachedSignedUrl } from '../lib/imageCache';
export function MediaImage({ reference, ...props }: Omit<ImageProps, 'source'> & { reference: string }) {
const [uri, setUri] = useState<string | null>(reference.startsWith('media:') ? null : reference);
useEffect(() => {
let active = true;
void getCachedSignedUrl(reference, 'avatar').then(value => { if (active) setUri(value); });
return () => { active = false; };
}, [reference]);
return <Image {...props} source={uri ? { uri } : undefined} />;
}
+2 -9
View File
@@ -18,7 +18,7 @@ import Animated, {
} from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import { supabase } from "../lib/supabase";
import { appDataApi } from "../lib/appDataApi";
import { getPackIconName } from "../lib/packIcons";
import { colors, gradients, spacing, radius, typography } from "../theme";
@@ -84,14 +84,7 @@ export function PackTeaser({
const fetchQuestions = async () => {
setIsLoading(true);
try {
const { data, error } = await supabase.rpc("get_pack_teaser_questions", {
target_pack_id: packId,
});
if (error) {
console.error("Error fetching teaser questions:", error);
return;
}
const { questions: data } = await appDataApi.packTeaser(packId);
// Take only the first 3 questions for the teaser
const previewQuestions = (data || [])
@@ -1,11 +1,11 @@
import React from 'react';
import { StyleSheet, View, Text, TouchableOpacity } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Image } from 'expo-image';
import { router } from 'expo-router';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { colors, spacing, typography, radius } from '../../theme';
import type { Profile, Couple } from '@/types';
import { MediaImage } from '../MediaImage';
interface CompactHeaderProps {
user: Profile | null;
@@ -46,8 +46,8 @@ export function CompactHeader({
partner ? (
<View style={styles.partnerBadge}>
{partner.avatar_url ? (
<Image
source={{ uri: partner.avatar_url }}
<MediaImage
reference={partner.avatar_url}
style={styles.partnerAvatar}
cachePolicy="disk"
contentFit="cover"
@@ -15,9 +15,8 @@ import {
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import * as ImagePicker from 'expo-image-picker';
import * as FileSystem from 'expo-file-system';
import { decode } from 'base64-arraybuffer';
import { supabase } from '../../lib/supabase';
import { uploadMedia } from '../../lib/mediaApi';
import { profileSettingsApi } from '../../lib/profileSettingsApi';
import { getDeviceInfo } from '../../lib/deviceInfo';
import { useAuthStore } from '../../store';
import { colors, gradients, spacing, radius, typography } from '../../theme';
@@ -72,44 +71,20 @@ export function FeedbackModal({ visible, onClose }: FeedbackModalProps) {
}
};
const uploadScreenshot = async (uri: string, userId: string): Promise<string> => {
let fileBody;
let ext = 'jpg';
const uploadScreenshot = async (uri: string, _userId: string): Promise<string> => {
let ext = 'jpg';
if (Platform.OS === 'web') {
const response = await fetch(uri);
const blob = await response.blob();
fileBody = blob;
if (blob.type === 'image/png') ext = 'png';
else if (blob.type === 'image/jpeg' || blob.type === 'image/jpg') ext = 'jpg';
else if (blob.type === 'image/gif') ext = 'gif';
else if (blob.type === 'image/webp') ext = 'webp';
} else {
const base64 = await FileSystem.readAsStringAsync(uri, {
encoding: FileSystem.EncodingType.Base64,
});
fileBody = decode(base64);
ext = uri.split('.').pop()?.toLowerCase() || 'jpg';
}
const fileName = `${userId}/${Date.now()}_${Math.random().toString(36).substring(7)}.${ext}`;
const { error } = await supabase.storage
.from('feedback-screenshots')
.upload(fileName, fileBody, {
contentType: `image/${ext === 'jpg' ? 'jpeg' : ext}`,
upsert: false,
});
if (error) throw error;
// Get signed URL (private bucket)
const { data } = await supabase.storage
.from('feedback-screenshots')
.createSignedUrl(fileName, 60 * 60 * 24 * 365); // 1 year expiry
return data?.signedUrl ?? '';
ext = uri.split('.').pop()?.toLowerCase() || 'jpg';
}
return (await uploadMedia(uri, { kind: 'feedback', mimeType: `image/${ext === 'jpg' ? 'jpeg' : ext}` })).media.id;
};
const handleSubmit = async () => {
@@ -132,24 +107,19 @@ export function FeedbackModal({ visible, onClose }: FeedbackModalProps) {
try {
const deviceInfo = getDeviceInfo();
let screenshotUrl: string | null = null;
let screenshotMediaId: string | null = null;
// Upload screenshot if provided
if (screenshotUri) {
screenshotUrl = await uploadScreenshot(screenshotUri, user.id);
}
// Insert feedback
const { error } = await supabase.from('feedback').insert({
user_id: user.id,
type: feedbackType,
title: title.trim(),
description: description.trim(),
screenshot_url: screenshotUrl,
device_info: deviceInfo,
});
if (error) throw error;
screenshotMediaId = await uploadScreenshot(screenshotUri, user.id);
}
await profileSettingsApi.submitFeedback({
type: feedbackType,
title: title.trim(),
description: description.trim(),
device_info: deviceInfo,
...(screenshotMediaId ? { screenshot_media_id: screenshotMediaId } : {}),
});
Alert.alert('Thank you!', 'Your feedback has been submitted successfully.', [
{ text: 'OK', onPress: handleClose },
@@ -11,7 +11,7 @@ import {
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { supabase } from '../../lib/supabase';
import { profileSettingsApi } from '../../lib/profileSettingsApi';
import { getDeviceInfo } from '../../lib/deviceInfo';
import { useAuthStore } from '../../store';
import { colors, gradients, spacing, radius, typography } from '../../theme';
@@ -70,8 +70,7 @@ export function QuestionFeedbackModal({
const deviceInfo = getDeviceInfo();
const reasonLabel = FEEDBACK_REASONS.find(r => r.id === selectedReason)?.label || selectedReason;
const { error } = await supabase.from('feedback').insert({
user_id: user.id,
await profileSettingsApi.submitFeedback({
type: 'question',
title: `Question Feedback: ${reasonLabel}`,
description: additionalInfo.trim() || `Reported as: ${reasonLabel}`,
@@ -79,8 +78,6 @@ export function QuestionFeedbackModal({
device_info: deviceInfo,
});
if (error) throw error;
Alert.alert('Thank you!', 'Your feedback has been submitted.', [
{ text: 'OK', onPress: handleClose },
]);
@@ -1,6 +1,6 @@
import { useState, useEffect } from "react";
import { View, Text, StyleSheet, Platform, TouchableOpacity } from "react-native";
import { Image } from "expo-image";
import { MediaImage } from "../MediaImage";
import { LinearGradient } from "expo-linear-gradient";
import Animated, {
useSharedValue,
@@ -324,8 +324,8 @@ function AvatarButton({
]}>
<Animated.View style={[styles.avatarBorder, borderAnimatedStyle]}>
{avatarUrl ? (
<Image
source={{ uri: avatarUrl }}
<MediaImage
reference={avatarUrl}
style={styles.avatarImage}
contentFit="cover"
cachePolicy="memory-disk"
+29 -58
View File
@@ -11,7 +11,9 @@ import { useMediaUpload } from "./hooks/useMediaUpload";
import { useMessageActions } from "./hooks/useMessageActions";
import type { ReportReason, Match } from "./types";
import { supabase } from "../../lib/supabase";
import { appDataApi } from "../../lib/appDataApi";
import { chatApi } from "../../lib/chatApi";
import { ApiError } from "../../lib/apiClient";
import { useAuthStore, useMessageStore, useMatchStore } from "../../store";
import { useAmbientOrbAnimation, useMediaPicker, useTypingIndicator, useMessageSubscription, useMediaSaver } from "../../hooks";
import { Database } from "../../types/supabase";
@@ -61,6 +63,7 @@ export const ChatScreen: React.FC = () => {
// Typing indicator hook
const { partnerTyping, sendTypingEvent, clearTypingIndicator } = useTypingIndicator({
channelName: `chat:${matchId}`,
matchId,
userId: user?.id,
isFocused,
});
@@ -147,26 +150,7 @@ export const ChatScreen: React.FC = () => {
if (!reportingMessage || !user?.id) return;
try {
// Insert the report
const { error: reportError } = await supabase
.from('message_reports')
.insert({
message_id: reportingMessage.id,
reporter_id: user.id,
reason: reason,
});
if (reportError) {
if (reportError.code === '23505') {
// Unique violation - already reported
Alert.alert('Already Reported', 'You have already reported this message.');
} else {
console.error('Report error:', reportError);
Alert.alert('Error', 'Failed to submit report');
}
setReportingMessage(null);
return;
}
await chatApi.report(reportingMessage.id, reason);
// Also delete the message for the reporter (so they don't see it anymore)
await deleteForSelf(reportingMessage.id);
@@ -178,7 +162,11 @@ export const ChatScreen: React.FC = () => {
setReportingMessage(null);
} catch (err) {
console.error('Report error:', err);
Alert.alert('Error', 'Failed to submit report');
if (err instanceof ApiError && err.status === 409) {
Alert.alert('Already Reported', 'You have already reported this message.');
} else {
Alert.alert('Error', 'Failed to submit report');
}
setReportingMessage(null);
}
}, [reportingMessage, user?.id, deleteForSelf, setMessages]);
@@ -204,20 +192,11 @@ export const ChatScreen: React.FC = () => {
const fetchMatch = async () => {
setMatch(null); // Clear previous match data immediately
const { data } = await supabase
.from("matches")
.select("*, question:questions(*)")
.eq("id", matchId)
.single();
if (data) {
const { data: responses } = await supabase
.from("responses")
.select("*, profiles(name)")
.eq("question_id", data.question_id)
.eq("couple_id", data.couple_id);
setMatch({ ...data, responses: responses || [] });
try {
const { match } = await appDataApi.matchContext(matchId);
setMatch(match);
} catch (error) {
console.error("Error fetching match context:", error);
}
};
fetchMatch();
@@ -239,26 +218,15 @@ export const ChatScreen: React.FC = () => {
setInputText("");
try {
// Send plaintext message (v1) - protected by TLS in transit and RLS policies
const { error } = await supabase.from("messages").insert({
match_id: matchId,
user_id: user.id,
content: content,
version: 1,
});
if (error) {
Alert.alert("Error", "Failed to send message");
setInputText(content);
} else {
Events.messageSent();
}
const { message } = await chatApi.sendText(matchId, content);
setMessages(previous => previous.some(item => item.id === message.id) ? previous : [message, ...previous]);
Events.messageSent();
} catch (err) {
console.error("Error sending message:", err);
Alert.alert("Delivery Failed", "Couldn't send message. Please check your connection.");
setInputText(content);
}
}, [inputText, user, matchId]);
}, [inputText, user, matchId, setMessages]);
const handlePickMedia = useCallback(async () => {
const result = await pickMedia();
@@ -297,13 +265,16 @@ export const ChatScreen: React.FC = () => {
} : m
));
await supabase
.from("messages")
.update({
media_viewed_at: now.toISOString(),
media_expires_at: expiresAt
})
.eq("id", messageId);
try {
const viewed = await appDataApi.markMediaViewed(messageId, expiresAt);
setMessages(prev => prev.map(m => m.id === messageId ? {
...m,
media_viewed_at: viewed.media_viewed_at,
media_expires_at: viewed.media_expires_at,
} : m));
} catch (error) {
console.error("Error marking media viewed:", error);
}
}, [messages, setMessages]);
return (
@@ -1,6 +1,5 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, { FadeIn } from 'react-native-reanimated';
@@ -8,6 +7,7 @@ import { EdgeInsets } from 'react-native-safe-area-context';
import { colors, gradients, spacing, typography } from '../../../theme';
import { Profile } from '../../../types';
import { MediaImage } from '../../../components/MediaImage';
// Premium color palette for Chat
const ACCENT = colors.premium.gold;
@@ -66,8 +66,8 @@ const ChatHeaderComponent: React.FC<ChatHeaderProps> = ({
<View style={styles.headerCenter}>
{partner?.avatar_url ? (
<View style={styles.headerAvatarContainer}>
<Image
source={{ uri: partner.avatar_url }}
<MediaImage
reference={partner.avatar_url}
style={styles.headerAvatar}
cachePolicy="disk"
transition={200}
@@ -1,10 +1,7 @@
import { useState } from 'react';
import { Alert, Platform } from 'react-native';
import * as FileSystem from 'expo-file-system';
import { Video as VideoCompressor } from 'react-native-compressor';
import { decode } from 'base64-arraybuffer';
import { supabase } from '../../../lib/supabase';
import { uploadMedia as uploadMediaFile } from '../../../lib/mediaApi';
import { Events } from '../../../lib/analytics';
import { UploadStatus } from '../types';
@@ -19,7 +16,6 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
try {
let fileUri = uri;
let fileBody;
let ext = mediaType === 'video' ? 'mp4' : 'jpg';
// Compress videos on native platforms before upload
@@ -46,8 +42,6 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
if (Platform.OS === 'web') {
const response = await fetch(fileUri);
const blob = await response.blob();
fileBody = blob;
// Detect extension from blob type
if (mediaType === 'video') {
if (blob.type === 'video/mp4') ext = 'mp4';
@@ -59,33 +53,14 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
else if (blob.type === 'image/gif') ext = 'gif';
else if (blob.type === 'image/webp') ext = 'webp';
}
} else {
const base64 = await FileSystem.readAsStringAsync(fileUri, { encoding: FileSystem.EncodingType.Base64 });
fileBody = decode(base64);
} else if (mediaType === 'image') {
const uriExt=fileUri.split('.').pop()?.toLowerCase();
if (uriExt && ['jpg','jpeg','png','gif','webp'].includes(uriExt)) ext=uriExt==='jpeg'?'jpg':uriExt;
}
const fileName = `${matchId}/${Date.now()}_${Math.random().toString(36).substring(7)}.${ext}`;
const contentType = mediaType === 'video' ? 'video/mp4' : 'image/jpeg';
const { error: uploadError } = await supabase.storage
.from("chat-media")
.upload(fileName, fileBody, {
contentType,
upsert: false
});
if (uploadError) throw uploadError;
// Store the message (plaintext v1)
await supabase.from("messages").insert({
match_id: matchId,
user_id: userId,
content: mediaType === 'video' ? 'Sent a video' : 'Sent an image',
media_path: fileName,
media_type: mediaType,
media_expired: false,
version: 1,
});
const contentType = mediaType === 'video'
? ({ mov: 'video/quicktime', webm: 'video/webm' }[ext] ?? 'video/mp4')
: ({ png: 'image/png', gif: 'image/gif', webp: 'image/webp' }[ext] ?? 'image/jpeg');
await uploadMediaFile(fileUri, { kind: 'chat', mimeType: contentType, matchId });
Events.mediaUploaded();
} catch (error) {
@@ -4,7 +4,7 @@
*/
import { Platform, ActionSheetIOS, Alert } from 'react-native';
import * as Haptics from 'expo-haptics';
import { supabase } from '../../../lib/supabase';
import { chatApi } from '../../../lib/chatApi';
import type { Message } from '../types';
interface UseMessageActionsConfig {
@@ -20,11 +20,9 @@ export function useMessageActions({ userId, onReport }: UseMessageActionsConfig)
const deleteForSelf = async (messageId: string) => {
if (!userId) return;
const { error } = await supabase
.from('message_deletions')
.insert({ message_id: messageId, user_id: userId });
if (error) {
try {
await chatApi.deleteForSelf(messageId);
} catch (error) {
Alert.alert('Error', 'Failed to delete message');
console.error('Delete for self error:', error);
}
@@ -35,12 +33,9 @@ export function useMessageActions({ userId, onReport }: UseMessageActionsConfig)
* Message will show as "deleted" to both users.
*/
const deleteForEveryone = async (messageId: string) => {
const { error } = await supabase
.from('messages')
.update({ deleted_at: new Date().toISOString() })
.eq('id', messageId);
if (error) {
try {
await chatApi.deleteForEveryone(messageId);
} catch (error) {
Alert.alert('Error', 'Failed to delete message');
console.error('Delete for everyone error:', error);
}
@@ -1,8 +1,7 @@
import { useEffect, useRef, useCallback } from 'react';
import { RealtimeChannel } from '@supabase/supabase-js';
import { supabase } from '../../../lib/supabase';
import type { StrokeSegment, StrokePoint, LiveDrawEvent } from '../types';
import { BROADCAST_BATCH_INTERVAL } from '../constants';
import { useCallback, useEffect, useRef } from 'react';
import { ApiError } from '../../../lib/apiClient';
import { appDataApi, type LiveDrawState } from '../../../lib/appDataApi';
import type { StrokeSegment, StrokePoint } from '../types';
import { updateWidget } from '../utils/widgetBridge';
interface UseDrawingSyncConfig {
@@ -27,202 +26,107 @@ interface UseDrawingSyncReturn {
persistStrokes: (strokes: StrokeSegment[]) => Promise<void>;
}
function conflictState(error: unknown): LiveDrawState | null {
if (!(error instanceof ApiError) || error.status !== 409 || typeof error.details !== 'object' || error.details === null) return null;
const state = (error.details as { current_state?: LiveDrawState }).current_state;
return state && Array.isArray(state.strokes) && Number.isInteger(state.revision) ? state : null;
}
export function mergeDrawingChanges(base: StrokeSegment[], desired: StrokeSegment[], current: StrokeSegment[]): StrokeSegment[] {
const desiredById = new Map(desired.map(stroke => [stroke.id, stroke]));
const desiredIds = new Set(desiredById.keys());
const removedIds = new Set(base.filter(stroke => !desiredIds.has(stroke.id)).map(stroke => stroke.id));
const currentIds = new Set(current.map(stroke => stroke.id));
const merged = current
.filter(stroke => !removedIds.has(stroke.id))
.map(stroke => desiredById.get(stroke.id) ?? stroke);
return [...merged, ...desired.filter(stroke => !currentIds.has(stroke.id))];
}
export function useDrawingSync(config: UseDrawingSyncConfig): UseDrawingSyncReturn {
const {
coupleId,
userId,
onStrokeStart,
onStrokeContinue,
onStrokeEnd,
onClearCanvas,
onUndo,
onRedo,
onLoadStrokes,
} = config;
const { coupleId, userId, onLoadStrokes } = config;
const revisionRef = useRef(0);
const baselineRef = useRef<StrokeSegment[]>([]);
const workingRef = useRef<StrokeSegment[]>([]);
const writeQueueRef = useRef<Promise<void>>(Promise.resolve());
const pendingWritesRef = useRef(0);
const throttleRef = useRef<NodeJS.Timeout | null>(null);
const onLoadStrokesRef = useRef(onLoadStrokes);
onLoadStrokesRef.current = onLoadStrokes;
const channelRef = useRef<RealtimeChannel | null>(null);
const batchRef = useRef<{ strokeId: string; points: StrokePoint[] } | null>(null);
const batchTimerRef = useRef<NodeJS.Timeout | null>(null);
const acceptState = useCallback((state: LiveDrawState) => {
revisionRef.current = state.revision;
baselineRef.current = state.strokes;
workingRef.current = state.strokes;
onLoadStrokesRef.current(state.strokes);
}, []);
const writeSnapshot = useCallback(async (desired: StrokeSegment[]) => {
const base = baselineRef.current;
try {
acceptState(await appDataApi.putLiveDraw(desired, revisionRef.current));
} catch (error) {
const current = conflictState(error);
if (!current) throw error;
const merged = mergeDrawingChanges(base, desired, current.strokes);
acceptState(await appDataApi.putLiveDraw(merged, current.revision));
}
updateWidget(workingRef.current);
}, [acceptState]);
const enqueueWrite = useCallback((strokes: StrokeSegment[]): Promise<void> => {
workingRef.current = strokes;
pendingWritesRef.current += 1;
const next = writeQueueRef.current.then(() => writeSnapshot(strokes));
writeQueueRef.current = next.catch(error => { console.error('Failed to persist live drawing:', error); });
return next.finally(() => { pendingWritesRef.current -= 1; });
}, [writeSnapshot]);
const scheduleWrite = useCallback((strokes: StrokeSegment[]) => {
workingRef.current = strokes;
if (!throttleRef.current) {
throttleRef.current = setTimeout(() => {
throttleRef.current = null;
void enqueueWrite(workingRef.current);
}, 250);
}
}, [enqueueWrite]);
// Load existing strokes from DB on mount
useEffect(() => {
if (!coupleId) return;
onLoadStrokesRef.current([]);
void appDataApi.getLiveDraw().then(acceptState).catch(error => console.error('Failed to load live drawing:', error));
}, [coupleId, acceptState]);
// Clear any stale strokes from a previous couple before loading
onLoadStrokes([]);
const loadExisting = async () => {
const { data } = await supabase
.from('live_draw_sessions')
.select('strokes')
.eq('couple_id', coupleId)
.maybeSingle();
if (data?.strokes) {
onLoadStrokes(data.strokes as StrokeSegment[]);
}
};
loadExisting();
}, [coupleId]);
// Subscribe to broadcast channel
useEffect(() => {
if (!coupleId || !userId) return;
const channel = supabase.channel(`livedraw:${coupleId}`);
channelRef.current = channel;
channel.on('broadcast', { event: 'draw' }, (payload) => {
const event = payload.payload as LiveDrawEvent;
// Ignore own events
if ('stroke' in event && event.stroke?.userId === userId) return;
if ('userId' in event && event.userId === userId) return;
switch (event.type) {
case 'stroke_start':
onStrokeStart(event.stroke);
break;
case 'stroke_continue':
onStrokeContinue(event.strokeId, event.points);
break;
case 'stroke_end':
onStrokeEnd(event.strokeId);
break;
case 'clear_canvas':
onClearCanvas();
break;
case 'undo':
onUndo(event.strokeId);
break;
case 'redo':
// For redo, we'd need the full stroke - handled via DB sync
break;
}
});
channel.subscribe();
return () => {
channelRef.current = null;
supabase.removeChannel(channel);
if (batchTimerRef.current) {
clearInterval(batchTimerRef.current);
}
let cancelled = false;
const poll = async () => {
if (pendingWritesRef.current > 0) return;
try {
const state = await appDataApi.getLiveDraw();
if (!cancelled && state.revision > revisionRef.current) acceptState(state);
} catch (error) { console.error('Failed to poll live drawing:', error); }
};
}, [coupleId, userId]);
const timer = setInterval(() => void poll(), 750);
return () => { cancelled = true; clearInterval(timer); if (throttleRef.current) clearTimeout(throttleRef.current); };
}, [coupleId, userId, acceptState]);
const send = useCallback(
(event: LiveDrawEvent) => {
channelRef.current?.send({
type: 'broadcast',
event: 'draw',
payload: event,
});
},
[]
);
const broadcastStrokeStart = useCallback((stroke: StrokeSegment) => {
scheduleWrite([...workingRef.current.filter(item => item.id !== stroke.id), stroke]);
}, [scheduleWrite]);
const broadcastStrokeContinue = useCallback((strokeId: string, points: StrokePoint[]) => {
scheduleWrite(workingRef.current.map(stroke => stroke.id === strokeId ? { ...stroke, points: [...stroke.points, ...points] } : stroke));
}, [scheduleWrite]);
const broadcastStrokeEnd = useCallback((_strokeId: string) => undefined, []);
const broadcastClearCanvas = useCallback(() => { workingRef.current = []; }, []);
const broadcastUndo = useCallback((strokeId: string) => { workingRef.current = workingRef.current.filter(stroke => stroke.id !== strokeId); }, []);
const broadcastRedo = useCallback((stroke: StrokeSegment) => { workingRef.current = [...workingRef.current.filter(item => item.id !== stroke.id), stroke]; }, []);
const persistStrokes = useCallback((strokes: StrokeSegment[]) => {
if (throttleRef.current) clearTimeout(throttleRef.current);
throttleRef.current = null;
return enqueueWrite(strokes);
}, [enqueueWrite]);
const broadcastStrokeStart = useCallback(
(stroke: StrokeSegment) => {
send({ type: 'stroke_start', stroke });
},
[send]
);
const broadcastStrokeContinue = useCallback(
(strokeId: string, points: StrokePoint[]) => {
// Batch points to reduce traffic
if (!batchRef.current || batchRef.current.strokeId !== strokeId) {
batchRef.current = { strokeId, points: [...points] };
} else {
batchRef.current.points.push(...points);
}
if (!batchTimerRef.current) {
batchTimerRef.current = setInterval(() => {
if (batchRef.current && batchRef.current.points.length > 0) {
send({
type: 'stroke_continue',
strokeId: batchRef.current.strokeId,
points: batchRef.current.points,
});
batchRef.current.points = [];
}
}, BROADCAST_BATCH_INTERVAL);
}
},
[send]
);
const broadcastStrokeEnd = useCallback(
(strokeId: string) => {
// Flush remaining batch
if (batchRef.current && batchRef.current.points.length > 0) {
send({
type: 'stroke_continue',
strokeId: batchRef.current.strokeId,
points: batchRef.current.points,
});
}
batchRef.current = null;
if (batchTimerRef.current) {
clearInterval(batchTimerRef.current);
batchTimerRef.current = null;
}
send({ type: 'stroke_end', strokeId });
},
[send]
);
const broadcastClearCanvas = useCallback(() => {
if (!userId) return;
send({ type: 'clear_canvas', userId });
}, [send, userId]);
const broadcastUndo = useCallback(
(strokeId: string) => {
if (!userId) return;
send({ type: 'undo', userId, strokeId });
},
[send, userId]
);
const broadcastRedo = useCallback(
(stroke: StrokeSegment) => {
// Send full stroke so partner can restore it
send({ type: 'stroke_start', stroke });
},
[send]
);
const persistStrokes = useCallback(
async (strokes: StrokeSegment[]) => {
await supabase
.from('live_draw_sessions')
.upsert(
{
couple_id: coupleId,
strokes: strokes as any,
updated_at: new Date().toISOString(),
},
{ onConflict: 'couple_id' }
);
// Update home screen widget with latest drawing
updateWidget(strokes);
},
[coupleId]
);
return {
broadcastStrokeStart,
broadcastStrokeContinue,
broadcastStrokeEnd,
broadcastClearCanvas,
broadcastUndo,
broadcastRedo,
persistStrokes,
};
return { broadcastStrokeStart, broadcastStrokeContinue, broadcastStrokeEnd, broadcastClearCanvas, broadcastUndo, broadcastRedo, persistStrokes };
}
@@ -5,16 +5,16 @@ import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import Constants from 'expo-constants';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { GradientBackground } from '../../components/ui';
import { Paywall } from '../../components/paywall';
import { CompactHeader } from '../../components/discovery';
import { MediaImage } from '../../components/MediaImage';
import { colors, featureColors, spacing, typography, radius } from '../../theme';
import { useAuthStore, useSubscriptionStore } from '../../store';
import { resetMatchesTutorial } from '../../lib/matchesTutorialSeen';
import { supabase } from '../../lib/supabase';
import { profileSettingsApi } from '../../lib/profileSettingsApi';
// Components
import { SettingsSection, MenuItem, SubscriptionCard } from './components';
@@ -105,8 +105,8 @@ export function ProfileScreen() {
end={{ x: 1, y: 1 }}
>
{user?.avatar_url ? (
<Image
source={{ uri: user.avatar_url }}
<MediaImage
reference={user.avatar_url}
style={styles.avatar}
cachePolicy="disk"
transition={200}
@@ -235,11 +235,7 @@ export function ProfileScreen() {
onPress={async () => {
if (!user?.id) return;
try {
const { error } = await supabase
.from('profiles')
.update({ onboarding_completed: false })
.eq('id', user.id);
if (error) throw error;
await profileSettingsApi.updateProfile({ onboarding_completed: false });
await fetchUser();
Alert.alert("Success", "Onboarding has been reset.");
} catch (error) {
@@ -1,11 +1,11 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { SettingsSection } from './SettingsSection';
import { colors, featureColors, spacing, typography, radius } from '../../../theme';
import { Profile, Couple } from '../../../types';
import { MediaImage } from '../../../components/MediaImage';
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
@@ -59,8 +59,8 @@ export const CoupleStatus: React.FC<CoupleStatusProps> = ({
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Image
source={{ uri: partner.avatar_url }}
<MediaImage
reference={partner.avatar_url}
style={styles.partnerAvatar}
cachePolicy="disk"
transition={200}
@@ -1,12 +1,12 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity, TextInput, ActivityIndicator } 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 { GlassCard } from '../../../components/ui';
import { colors, featureColors, spacing, typography } from '../../../theme';
import { Profile } from '../../../types';
import { MediaImage } from '../../../components/MediaImage';
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
@@ -55,8 +55,8 @@ export const ProfileHeader: React.FC<ProfileHeaderProps> = ({
end={{ x: 1, y: 1 }}
>
{user?.avatar_url ? (
<Image
source={{ uri: user.avatar_url }}
<MediaImage
reference={user.avatar_url}
style={styles.avatarImage}
cachePolicy="disk"
transition={200}
@@ -2,7 +2,8 @@ import { useState } from 'react';
import { Alert } from 'react-native';
import { useRouter } from 'expo-router';
import { useAuthStore, useMatchStore, useMessageStore, usePacksStore } from '../../../store';
import { supabase } from '../../../lib/supabase';
import { accountOperationsApi } from '../../../lib/accountOperationsApi';
import { coupleApi } from '../../../lib/coupleApi';
import { Events } from '../../../lib/analytics';
export function useCoupleManagement() {
@@ -12,7 +13,7 @@ export function useCoupleManagement() {
const { clearMessages } = useMessageStore();
const { clearPacks } = usePacksStore();
const [isResettingProgress, setIsResettingProgress] = useState(false);
const [isResettingProgress] = useState(false);
const [isDeletingAccount, setIsDeletingAccount] = useState(false);
const handleUnpair = async () => {
@@ -26,13 +27,11 @@ export function useCoupleManagement() {
style: "destructive",
onPress: async () => {
try {
await supabase.functions.invoke("manage-couple", {
method: "DELETE",
});
await coupleApi.cancel();
await fetchCouple();
// Refresh user to update UI state
await fetchUser();
} catch (error) {
} catch {
Alert.alert("Error", "Failed to unpair");
}
},
@@ -43,11 +42,7 @@ export function useCoupleManagement() {
const handleDeleteRelationship = async () => {
try {
const { error } = await supabase.functions.invoke("delete-relationship", {
method: "DELETE",
});
if (error) throw error;
await accountOperationsApi.deleteRelationship();
// Clear local stores
clearMatches();
@@ -68,11 +63,7 @@ export function useCoupleManagement() {
const handleResetProgress = async () => {
try {
const { error } = await supabase.functions.invoke("reset-couple-progress", {
method: "DELETE",
});
if (error) throw error;
await accountOperationsApi.resetProgress();
// Clear local stores
clearMatches();
@@ -103,11 +94,7 @@ export function useCoupleManagement() {
const handleDeleteAccount = async () => {
setIsDeletingAccount(true);
try {
const { error } = await supabase.functions.invoke("delete-account", {
method: "DELETE",
});
if (error) throw error;
await accountOperationsApi.deleteAccount();
// Clear all local data
clearMatches();
@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { Alert, Linking, Platform } from 'react-native';
import { useAuthStore, useSubscriptionStore, usePacksStore } from '../../../store';
import { supabase } from '../../../lib/supabase';
import { profileSettingsApi } from '../../../lib/profileSettingsApi';
import { Events } from '../../../lib/analytics';
import { useAvatarPicker } from '../../../hooks/useAvatarPicker';
import {
@@ -33,20 +33,10 @@ export function useProfileSettings() {
const [isUpdatingName, setIsUpdatingName] = useState(false);
// Avatar - using shared hook with immediate upload on select
const handleAvatarUploadComplete = useCallback(async (publicUrl: string) => {
const handleAvatarUploadComplete = useCallback(async (_mediaReference: string) => {
if (!user?.id) return;
try {
const { error: updateError } = await supabase
.from('profiles')
.update({
avatar_url: publicUrl,
updated_at: new Date().toISOString(),
})
.eq('id', user.id);
if (updateError) throw updateError;
await fetchUser();
Events.avatarUploaded();
} catch (error) {
@@ -131,15 +121,7 @@ export function useProfileSettings() {
setIsUpdatingName(true);
try {
const { error } = await supabase
.from('profiles')
.update({
name: trimmedName,
updated_at: new Date().toISOString(),
})
.eq('id', user.id);
if (error) throw error;
await profileSettingsApi.updateProfile({ name: trimmedName });
await fetchUser();
setIsEditingName(false);
@@ -241,17 +223,11 @@ export function useProfileSettings() {
const showExplicitContent = !value;
try {
const { error } = await supabase
.from('profiles')
.update({
hide_nsfw: value,
max_intensity: maxIntensity,
show_explicit_content: showExplicitContent,
updated_at: new Date().toISOString(),
})
.eq('id', user.id);
if (error) throw error;
await profileSettingsApi.updateProfile({
hide_nsfw: value,
max_intensity: maxIntensity,
show_explicit_content: showExplicitContent,
});
// Refresh user data and packs to reflect the change
await fetchUser();
@@ -11,15 +11,16 @@ import {
Alert,
TouchableOpacity,
} from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { Ionicons } from '@expo/vector-icons';
import { MediaImage } from '../../../components/MediaImage';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { GradientBackground } from '../../../components/ui';
import { colors, featureColors, spacing, typography, radius } from '../../../theme';
import { useAuthStore } from '../../../store';
import { supabase } from '../../../lib/supabase';
import { authClient } from '../../../lib/authClient';
import { profileSettingsApi } from '../../../lib/profileSettingsApi';
import { Events } from '../../../lib/analytics';
import { useProfileSettings } from '../hooks';
import { ScreenHeader, SettingsSection } from '../components';
@@ -31,7 +32,7 @@ function useAuthEmail() {
useEffect(() => {
const getEmail = async () => {
const { data: { user } } = await supabase.auth.getUser();
const { data: { user } } = await authClient.auth.getUser();
setEmail(user?.email ?? null);
};
getEmail();
@@ -83,9 +84,7 @@ export function ProfileSettingsScreen() {
setIsSaving(true);
try {
const updates: Record<string, any> = {
updated_at: new Date().toISOString(),
};
const updates: { name?: string; gender?: Gender | null } = {};
const changedFields: string[] = [];
@@ -104,12 +103,7 @@ export function ProfileSettingsScreen() {
return;
}
const { error } = await supabase
.from('profiles')
.update(updates)
.eq('id', user.id);
if (error) throw error;
await profileSettingsApi.updateProfile(updates);
await fetchUser();
Events.profileUpdated(changedFields);
@@ -166,8 +160,8 @@ export function ProfileSettingsScreen() {
end={{ x: 1, y: 1 }}
>
{user?.avatar_url ? (
<Image
source={{ uri: user.avatar_url }}
<MediaImage
reference={user.avatar_url}
style={styles.avatarImage}
cachePolicy="disk"
transition={200}
@@ -40,7 +40,7 @@ if (Platform.OS === "android") {
import { GradientBackground, GlassButton, GlassCard, GlassInput } from "../../../components/ui";
import { colors, spacing, radius, typography } from "../../../theme";
import { supabase } from "../../../lib/supabase";
import { authClient } from "../../../lib/authClient";
import { getAuthError } from "../../../lib/errors";
import { useAuthStore } from "../../../store";
import { ScreenHeader } from "../components";
@@ -84,18 +84,6 @@ export function SaveAccountScreen() {
Alert.alert("Success", message);
}, []);
const syncProfileEmail = useCallback(async (userId: string, nextEmail: string | null | undefined) => {
if (!nextEmail) return;
try {
await supabase
.from("profiles")
.update({ email: nextEmail })
.eq("id", userId);
} catch {
// Non-blocking (some envs may restrict this by RLS)
}
}, []);
const handleSaveWithEmail = useCallback(async () => {
if (!email.trim()) {
showError("Please enter your email");
@@ -107,7 +95,7 @@ export function SaveAccountScreen() {
}
setIsSubmitting(true);
const { error } = await supabase.auth.updateUser(
const { error } = await authClient.auth.updateUser(
{ email: email.trim() },
{ emailRedirectTo } as any
);
@@ -134,7 +122,7 @@ export function SaveAccountScreen() {
const handleCheckVerification = useCallback(async () => {
setIsChecking(true);
const { data: { user }, error } = await supabase.auth.getUser();
const { data: { user }, error } = await authClient.auth.getUser();
setIsChecking(false);
if (error || !user) {
@@ -151,7 +139,7 @@ export function SaveAccountScreen() {
// If user chose password mode, set password after email is verified
if (pendingPassword) {
setIsSubmitting(true);
const { error: passwordError } = await supabase.auth.updateUser({ password: pendingPassword });
const { error: passwordError } = await authClient.auth.updateUser({ password: pendingPassword });
setIsSubmitting(false);
if (passwordError) {
@@ -160,12 +148,11 @@ export function SaveAccountScreen() {
}
}
await syncProfileEmail(user.id, user.email);
await fetchUser();
showSuccess("Account saved. You can now recover your account on any device.");
router.navigate("/(app)/profile" as any);
}, [pendingPassword, fetchUser, showError, showSuccess, router, syncProfileEmail]);
}, [pendingPassword, fetchUser, showError, showSuccess, router]);
const handleLinkApple = useCallback(async () => {
if (!AppleAuthentication) return;
@@ -184,7 +171,7 @@ export function SaveAccountScreen() {
}
setIsSubmitting(true);
const { error } = await (supabase.auth.linkIdentity as any)({
const { error } = await (authClient.auth.linkIdentity as any)({
provider: "apple",
token: credential.identityToken,
});
@@ -205,7 +192,7 @@ export function SaveAccountScreen() {
const fullName = nameParts.join(" ");
if (fullName) {
await supabase.auth.updateUser({
await authClient.auth.updateUser({
data: {
full_name: fullName,
given_name: credential.fullName.givenName,
@@ -216,10 +203,6 @@ export function SaveAccountScreen() {
}
// Refresh user/profile
const { data: { user } } = await supabase.auth.getUser();
if (user) {
await syncProfileEmail(user.id, user.email);
}
await fetchUser();
showSuccess("Account saved with Apple.");
@@ -228,7 +211,7 @@ export function SaveAccountScreen() {
if (e?.code === "ERR_REQUEST_CANCELED") return;
showError("Apple sign in failed. Please try again.");
}
}, [fetchUser, router, showError, showSuccess, syncProfileEmail]);
}, [fetchUser, router, showError, showSuccess]);
const handleLinkGoogle = useCallback(async () => {
if (!GoogleSignin) return;
@@ -242,7 +225,7 @@ export function SaveAccountScreen() {
}
setIsSubmitting(true);
const { error } = await (supabase.auth.linkIdentity as any)({
const { error } = await (authClient.auth.linkIdentity as any)({
provider: "google",
token: response.data.idToken,
});
@@ -253,10 +236,6 @@ export function SaveAccountScreen() {
return;
}
const { data: { user } } = await supabase.auth.getUser();
if (user) {
await syncProfileEmail(user.id, user.email);
}
await fetchUser();
showSuccess("Account saved with Google.");
@@ -264,7 +243,7 @@ export function SaveAccountScreen() {
} catch {
showError("Google sign in failed. Please try again.");
}
}, [fetchUser, router, showError, showSuccess, syncProfileEmail]);
}, [fetchUser, router, showError, showSuccess]);
const showSocial = Platform.select({ ios: true, android: true, default: false });
@@ -3,7 +3,7 @@ import { useFocusEffect, useLocalSearchParams } from "expo-router";
import { useAuthStore, usePacksStore } from "../../../store";
import { skipQuestion, getSkippedQuestionIds } from "../../../lib/skippedQuestions";
import { invokeWithAuthRetry } from "../../../lib/authErrorHandler";
import { apiClient } from "../../../lib/apiClient";
import { Events } from "../../../lib/analytics";
import type { AnswerType } from "../../../types";
import type { DailyLimitInfo, PackInfo, ResponseData } from "../types";
@@ -307,20 +307,12 @@ export const useSwipeScreen = () => {
uploadResponseMedia,
});
const { data, error } = await invokeWithAuthRetry("submit-response", {
body: {
question_id: questionId,
answer,
response_data: finalResponseData,
},
const data = await apiClient.post<{ match: unknown | null }>("/v1/responses", {
question_id: questionId,
answer,
response_data: finalResponseData,
});
if (error) {
console.error("Submit response error:", error);
setCurrentIndex(prev => prev + 1);
return;
}
Events.questionAnswered(answer, filteredQuestions[currentIndex]?.pack_id);
const hasMatch = data?.match != null;
@@ -1,34 +1,17 @@
import { Platform } from "react-native";
import * as FileSystem from "expo-file-system";
import { decode } from "base64-arraybuffer";
import { supabase } from "../../../lib/supabase";
import { uploadMedia } from "../../../lib/mediaApi";
import { apiClient } from "../../../lib/apiClient";
import { appDataApi } from "../../../lib/appDataApi";
import type { DailyLimitInfo } from "../types";
export const fetchPackContext = async (packId: string) => {
const { data, error } = await supabase
.from('question_packs')
.select('name, icon')
.eq('id', packId)
.single();
if (error) {
throw error;
}
return data ? { name: data.name, icon: data.icon || 'layers' } : null;
const { pack } = await appDataApi.packContext(packId);
return { name: pack.name, icon: pack.icon || 'layers' };
};
export const fetchRecommendedQuestions = async (packId?: string) => {
const { data, error } = await supabase.rpc("get_recommended_questions", {
target_pack_id: packId || null,
});
if (error) {
throw error;
}
return data ?? [];
const query = packId ? `?packId=${encodeURIComponent(packId)}` : "";
const data = await apiClient.get<{ questions: any[] }>(`/v1/questions/recommended${query}`);
return data.questions;
};
export const fetchPendingQuestions = async ({
@@ -40,89 +23,18 @@ export const fetchPendingQuestions = async ({
coupleId: string;
startQuestionId?: string;
}) => {
const { data: coupleProfiles } = await supabase
.from("profiles")
.select("id")
.eq("couple_id", coupleId)
.neq("id", userId);
const partnerId = coupleProfiles?.[0]?.id;
if (!partnerId) {
return [];
}
const { data: userResponses } = await supabase
.from("responses")
.select("question_id")
.eq("user_id", userId)
.eq("couple_id", coupleId);
const answeredQuestionIds = new Set(userResponses?.map(r => r.question_id) ?? []);
const { data: partnerResponses, error } = await supabase
.from("responses")
.select(`
id,
question_id,
created_at,
question:questions(
*,
pack:question_packs(id, name, icon)
)
`)
.eq("user_id", partnerId)
.eq("couple_id", coupleId)
.order("created_at", { ascending: true });
if (error) {
throw error;
}
const pendingQuestions = (partnerResponses ?? [])
.filter(r => {
const question = r.question as any;
return !answeredQuestionIds.has(r.question_id) && question && !question.deleted_at;
})
.map(r => {
const question = r.question as any;
return {
...question,
pack_id: question.pack?.id,
pack_name: question.pack?.name,
pack_icon: question.pack?.icon,
};
});
if (startQuestionId && pendingQuestions.length > 0) {
const startIndex = pendingQuestions.findIndex(q => q.id === startQuestionId);
if (startIndex !== -1) {
const tappedQuestion = pendingQuestions[startIndex];
const rest = pendingQuestions.filter((_, i) => i !== startIndex);
return [tappedQuestion, ...rest];
}
}
return pendingQuestions;
void userId; void coupleId;
const query = startQuestionId ? `?startQuestionId=${encodeURIComponent(startQuestionId)}` : "";
const data = await apiClient.get<{ questions: Array<{ question: any }> }>(`/v1/questions/pending${query}`);
return data.questions.map(({ question }) => ({ ...question, pack_name: question.pack?.name, pack_icon: question.pack?.icon }));
};
export const fetchAnswerGapStatus = async () => {
const { data, error } = await supabase.rpc('get_answer_gap_status');
if (error) {
throw error;
}
return data?.[0] ?? null;
return apiClient.get<{ unanswered_by_partner: number; threshold: number; is_blocked: boolean }>('/v1/me/answer-gap');
};
export const fetchDailyLimitStatus = async () => {
const { data, error } = await supabase.rpc('get_daily_response_limit_status');
if (error) {
throw error;
}
return data?.[0] as DailyLimitInfo | undefined;
return apiClient.get<DailyLimitInfo>('/v1/me/daily-limit');
};
export const uploadResponseMedia = async (
@@ -132,36 +44,14 @@ export const uploadResponseMedia = async (
userId: string
): Promise<string | null> => {
try {
const timestamp = Date.now();
const extMatch = localUri.match(/\.(\w+)$/);
const ext = extMatch ? extMatch[1] : (mediaType === 'photo' ? 'jpg' : 'm4a');
const fileName = `${userId}/${questionId}_${timestamp}.${ext}`;
const contentType = mediaType === 'photo'
? `image/${ext === 'jpg' ? 'jpeg' : ext}`
: `audio/${ext}`;
let fileBody: ArrayBuffer | Blob;
if (Platform.OS === 'web') {
const response = await fetch(localUri);
fileBody = await response.blob();
} else {
const base64 = await FileSystem.readAsStringAsync(localUri, {
encoding: FileSystem.EncodingType.Base64,
});
fileBody = decode(base64);
}
const { error } = await supabase.storage
.from('response-media')
.upload(fileName, fileBody, { contentType });
if (error) {
console.error('Media upload error:', error);
return null;
}
return fileName;
void userId;
return (await uploadMedia(localUri, { kind: 'response', mimeType: contentType, questionId })).reference;
} catch (error) {
console.error('Media upload failed:', error);
return null;
+9 -32
View File
@@ -2,8 +2,7 @@ import { useState, useCallback, useRef } from 'react';
import { Alert, Platform, ActionSheetIOS } from 'react-native';
import * as ImagePicker from 'expo-image-picker';
import * as FileSystem from 'expo-file-system';
import { decode } from 'base64-arraybuffer';
import { supabase } from '../lib/supabase';
import { uploadMedia } from '../lib/mediaApi';
export interface UseAvatarPickerOptions {
/** User ID for storage path. Required for upload. */
@@ -35,7 +34,7 @@ export interface UseAvatarPickerReturn {
/**
* Hook for avatar image picking and uploading.
* Handles cross-platform image selection (camera/library) and Supabase storage upload.
* Handles cross-platform image selection and authenticated API upload.
*/
export function useAvatarPicker(options?: UseAvatarPickerOptions): UseAvatarPickerReturn {
const [avatarUri, setAvatarUri] = useState<string | null>(null);
@@ -55,46 +54,24 @@ export function useAvatarPicker(options?: UseAvatarPickerOptions): UseAvatarPick
setIsUploading(true);
try {
let fileBody;
let ext = 'jpg';
let ext = 'jpg';
if (Platform.OS === 'web') {
const response = await fetch(uri);
const blob = await response.blob();
fileBody = blob;
if (blob.type === 'image/png') ext = 'png';
if (blob.type === 'image/png') ext = 'png';
else if (blob.type === 'image/webp') ext = 'webp';
} else {
const base64 = await FileSystem.readAsStringAsync(uri, {
encoding: FileSystem.EncodingType.Base64
});
fileBody = decode(base64);
const uriExt = uri.split('.').pop()?.toLowerCase();
const uriExt = uri.split('.').pop()?.toLowerCase();
if (uriExt && ['jpg', 'jpeg', 'png', 'webp'].includes(uriExt)) {
ext = uriExt === 'jpeg' ? 'jpg' : uriExt;
}
}
const fileName = `${currentOptions.userId}/${Date.now()}.${ext}`;
const contentType = `image/${ext === 'jpg' ? 'jpeg' : ext}`;
const { error: uploadError } = await supabase.storage
.from('avatars')
.upload(fileName, fileBody, {
contentType,
upsert: true,
});
if (uploadError) throw uploadError;
const { data: { publicUrl } } = supabase.storage
.from('avatars')
.getPublicUrl(fileName);
currentOptions?.onUploadComplete?.(publicUrl);
return publicUrl;
const contentType = `image/${ext === 'jpg' ? 'jpeg' : ext}`;
const result = await uploadMedia(uri, { kind: 'avatar', mimeType: contentType });
currentOptions?.onUploadComplete?.(result.reference);
return result.reference;
} catch (error) {
console.error('Error uploading avatar:', error);
currentOptions?.onUploadError?.(error as Error);
+43 -41
View File
@@ -1,6 +1,11 @@
import { useState, useEffect, useCallback } from "react";
import { supabase } from "../lib/supabase";
import { useAuthStore } from "../store";
import { useState, useEffect, useCallback } from "react";
import { apiClient } from "../lib/apiClient";
import { useAuthStore } from "../store";
interface FeatureInterestResponse {
feature: string;
interested: boolean;
}
export function useFeatureInterest(featureName: string) {
const [isInterested, setIsInterested] = useState(false);
@@ -9,25 +14,33 @@ export function useFeatureInterest(featureName: string) {
const user = useAuthStore((state) => state.user);
// Fetch current interest status
useEffect(() => {
async function fetchInterest() {
if (!user?.id) {
setIsLoading(false);
return;
}
const { data } = await supabase
.from("feature_interests")
.select("id")
.eq("user_id", user.id)
.eq("feature_name", featureName)
.maybeSingle();
setIsInterested(!!data);
setIsLoading(false);
}
fetchInterest();
useEffect(() => {
let cancelled = false;
async function fetchInterest() {
if (!user?.id) {
setIsLoading(false);
return;
}
try {
const result = await apiClient.get<FeatureInterestResponse>(
`/v1/me/feature-interests/${encodeURIComponent(featureName)}`,
);
if (!cancelled) setIsInterested(result.interested);
} catch (error) {
console.error("Error fetching feature interest:", error);
if (!cancelled) setIsInterested(false);
} finally {
if (!cancelled) setIsLoading(false);
}
}
fetchInterest();
return () => {
cancelled = true;
};
}, [user?.id, featureName]);
const toggleInterest = useCallback(async () => {
@@ -40,25 +53,14 @@ export function useFeatureInterest(featureName: string) {
setIsInterested(!wasInterested);
try {
if (wasInterested) {
// Remove interest
const { error } = await supabase
.from("feature_interests")
.delete()
.eq("user_id", user.id)
.eq("feature_name", featureName);
if (error) throw error;
} else {
// Add interest
const { error } = await supabase
.from("feature_interests")
.insert({
user_id: user.id,
feature_name: featureName,
});
if (error) throw error;
if (wasInterested) {
await apiClient.delete<FeatureInterestResponse>(
`/v1/me/feature-interests/${encodeURIComponent(featureName)}`,
);
} else {
await apiClient.put<FeatureInterestResponse>(
`/v1/me/feature-interests/${encodeURIComponent(featureName)}`,
);
}
} catch (error) {
console.error("Error toggling feature interest:", error);
+32 -210
View File
@@ -1,235 +1,57 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { supabase } from '../lib/supabase';
import { Database } from '../types/supabase';
type Message = Database['public']['Tables']['messages']['Row'];
import { useCallback, useEffect, useRef, useState } from 'react';
import { chatApi } from '../lib/chatApi';
import type { Message } from '../features/chat/types';
export interface UseMessageSubscriptionConfig {
/** Match ID to subscribe to */
matchId: string | undefined;
/** Current user's ID */
userId: string | undefined;
/** Callback when a new message is received (e.g., to clear typing indicator) */
onNewMessage?: () => void;
pollInterval?: number;
}
export interface UseMessageSubscriptionReturn {
/** Array of messages, sorted newest first (filtered by user's deletions) */
messages: Message[];
/** Set messages externally (e.g., for optimistic updates) */
setMessages: React.Dispatch<React.SetStateAction<Message[]>>;
/** Whether the initial fetch is in progress */
loading: boolean;
/** Ref to track if screen is focused (for read receipts) */
isFocusedRef: React.MutableRefObject<boolean>;
}
/**
* Hook for managing message subscriptions and real-time updates.
* Handles fetching messages, subscribing to new messages, and managing read receipts.
*
* @param config - Configuration including match ID and user ID
* @returns Message state and subscription utilities
*/
export const useMessageSubscription = (
config: UseMessageSubscriptionConfig
): UseMessageSubscriptionReturn => {
const { matchId, userId, onNewMessage } = config;
const [allMessages, setAllMessages] = useState<Message[]>([]);
const [deletedMessageIds, setDeletedMessageIds] = useState<Set<string>>(new Set());
/** Authenticated API polling replaces Supabase Realtime for self-hosted chat. */
export const useMessageSubscription = ({ matchId, userId, onNewMessage, pollInterval = 1_500 }: UseMessageSubscriptionConfig): UseMessageSubscriptionReturn => {
const [messages, setMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(true);
const isFocusedRef = useRef(false);
const knownIdsRef = useRef<Set<string>>(new Set());
const fetchingRef = useRef(false);
// Track messages that arrive via real-time during initial fetch to avoid race condition
const pendingRealtimeMessagesRef = useRef<Message[]>([]);
const isFetchingRef = useRef(false);
const refresh = useCallback(async (initial = false) => {
if (!matchId || !userId || fetchingRef.current) return;
fetchingRef.current = true;
try {
const response = await chatApi.listMessages(matchId);
const partnerArrived = !initial && response.messages.some(message => message.user_id !== userId && !knownIdsRef.current.has(message.id));
knownIdsRef.current = new Set(response.messages.map(message => message.id));
setMessages(response.messages);
if (isFocusedRef.current) await chatApi.markRead(matchId);
if (partnerArrived) onNewMessage?.();
} finally {
fetchingRef.current = false;
if (initial) setLoading(false);
}
}, [matchId, userId, onNewMessage]);
// Filter out messages that the user has deleted for themselves
const messages = useMemo(() => {
return allMessages.filter(m => !deletedMessageIds.has(m.id));
}, [allMessages, deletedMessageIds]);
// Wrapper to update allMessages (for external use like optimistic updates)
const setMessages: React.Dispatch<React.SetStateAction<Message[]>> = useCallback((updater) => {
setAllMessages(updater);
}, []);
// Fetch user's deleted message IDs
useEffect(() => {
if (!userId) return;
const fetchDeletions = async () => {
const { data } = await supabase
.from('message_deletions')
.select('message_id')
.eq('user_id', userId);
if (data) {
setDeletedMessageIds(new Set(data.map(d => d.message_id)));
}
};
fetchDeletions();
}, [userId]);
// Subscribe to user's deletions for real-time sync across devices
useEffect(() => {
if (!userId) return;
const channel = supabase
.channel(`deletions:${userId}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'message_deletions',
filter: `user_id=eq.${userId}`,
},
(payload) => {
const deletion = payload.new as { message_id: string };
setDeletedMessageIds(prev => new Set([...prev, deletion.message_id]));
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [userId]);
// Fetch messages and mark as read
useEffect(() => {
if (!matchId || !userId) {
setMessages([]);
setLoading(false);
return;
}
setLoading(true);
knownIdsRef.current = new Set();
void refresh(true).catch(() => undefined);
const timer = setInterval(() => void refresh(false).catch(() => undefined), pollInterval);
return () => clearInterval(timer);
}, [matchId, userId, pollInterval, refresh]);
const fetchMessagesAndMarkRead = async () => {
setLoading(true);
isFetchingRef.current = true;
pendingRealtimeMessagesRef.current = [];
setAllMessages([]); // Clear previous chat's messages immediately
const { data } = await supabase
.from('messages')
.select('*')
.eq('match_id', matchId)
.order('created_at', { ascending: false });
if (data) {
// Merge any messages that arrived via real-time during the fetch
const pendingMessages = pendingRealtimeMessagesRef.current;
const fetchedIds = new Set(data.map(m => m.id));
const newRealtimeMessages = pendingMessages.filter(m => !fetchedIds.has(m.id));
// Combine: new real-time messages first (newest), then fetched data
const mergedMessages = [...newRealtimeMessages, ...data];
setAllMessages(mergedMessages);
const now = new Date().toISOString();
// Get partner messages that need to be marked as delivered or read
const partnerMessages = data.filter(m => m.user_id !== userId);
const undeliveredIds = partnerMessages.filter(m => !m.delivered_at).map(m => m.id);
const unreadIds = partnerMessages.filter(m => !m.read_at).map(m => m.id);
// Mark as both delivered and read (since user is viewing the chat)
if (unreadIds.length > 0) {
await supabase
.from('messages')
.update({ delivered_at: now, read_at: now })
.in('id', unreadIds);
setAllMessages(prev => prev.map(m =>
unreadIds.includes(m.id) ? { ...m, delivered_at: now, read_at: now } : m
));
} else if (undeliveredIds.length > 0) {
// Only mark as delivered if already read but not delivered (edge case)
await supabase
.from('messages')
.update({ delivered_at: now })
.in('id', undeliveredIds);
setAllMessages(prev => prev.map(m =>
undeliveredIds.includes(m.id) ? { ...m, delivered_at: now } : m
));
}
}
isFetchingRef.current = false;
pendingRealtimeMessagesRef.current = [];
setLoading(false);
};
fetchMessagesAndMarkRead();
}, [matchId, userId]);
// Subscribe to real-time message updates
useEffect(() => {
if (!matchId || !userId) return;
const channel = supabase
.channel(`messages:${matchId}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: `match_id=eq.${matchId}`,
},
async (payload) => {
const newMessage = payload.new as Message;
const now = new Date().toISOString();
if (newMessage.user_id !== userId && isFocusedRef.current) {
// Mark as both delivered and read (only if screen is focused)
await supabase
.from('messages')
.update({ delivered_at: now, read_at: now })
.eq('id', newMessage.id);
newMessage.delivered_at = now;
newMessage.read_at = now;
}
// If initial fetch is in progress, queue the message to avoid race condition
// where setAllMessages(data) would overwrite this message
if (isFetchingRef.current) {
pendingRealtimeMessagesRef.current.push(newMessage);
} else {
setAllMessages(prev => [newMessage, ...prev]);
}
onNewMessage?.();
}
)
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'messages',
filter: `match_id=eq.${matchId}`,
},
(payload) => {
const updatedMessage = payload.new as Message;
setAllMessages(prev =>
prev.map(m => m.id === updatedMessage.id ? updatedMessage : m)
);
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [matchId, userId, onNewMessage]);
return {
messages,
setMessages,
loading,
isFocusedRef,
};
return { messages, setMessages, loading, isFocusedRef };
};
+11 -59
View File
@@ -1,9 +1,6 @@
import { useState, useCallback, useRef } from 'react';
import { Platform } from 'react-native';
import * as FileSystem from 'expo-file-system';
import { decode } from 'base64-arraybuffer';
import { supabase } from '../lib/supabase';
import { useState, useCallback, useRef } from 'react';
import { Platform } from 'react-native';
import { uploadMedia } from '../lib/mediaApi';
export interface UseResponseMediaUploadOptions {
userId: string;
@@ -27,8 +24,7 @@ export interface UseResponseMediaUploadReturn {
}
/**
* Hook for uploading response media (photos and audio) to Supabase storage.
* Uploads to the `response-media` bucket with path format: {userId}/{questionId}_{timestamp}.{ext}
* Upload response media through the authenticated Sauci API.
*/
export function useResponseMediaUpload(
options: UseResponseMediaUploadOptions
@@ -49,24 +45,16 @@ export function useResponseMediaUpload(
setUploading(true);
try {
let fileBody;
let ext = 'jpg';
let ext = 'jpg';
if (Platform.OS === 'web') {
const response = await fetch(localUri);
const blob = await response.blob();
fileBody = blob;
// Detect extension from blob type
if (blob.type === 'image/png') ext = 'png';
else if (blob.type === 'image/webp') ext = 'webp';
else if (blob.type === 'image/gif') ext = 'gif';
} else {
const base64 = await FileSystem.readAsStringAsync(localUri, {
encoding: FileSystem.EncodingType.Base64,
});
fileBody = decode(base64);
// Try to get extension from URI
const uriExt = localUri.split('.').pop()?.toLowerCase();
if (uriExt && ['jpg', 'jpeg', 'png', 'webp', 'gif'].includes(uriExt)) {
@@ -74,23 +62,9 @@ export function useResponseMediaUpload(
}
}
const timestamp = Date.now();
const mediaPath = `${userId}/${questionId}_${timestamp}.${ext}`;
const contentType = `image/${ext === 'jpg' ? 'jpeg' : ext}`;
const { error: uploadError } = await supabase.storage
.from('response-media')
.upload(mediaPath, fileBody, {
contentType,
upsert: false,
});
if (uploadError) {
console.error('Error uploading photo:', uploadError);
return { success: false, error: uploadError.message };
}
return { success: true, mediaPath };
const contentType = `image/${ext === 'jpg' ? 'jpeg' : ext}`;
const result = await uploadMedia(localUri, { kind: 'response', mimeType: contentType, questionId });
return { success: true, mediaPath: result.reference };
} catch (error) {
console.error('Error uploading photo:', error);
return {
@@ -113,15 +87,12 @@ export function useResponseMediaUpload(
setUploading(true);
try {
let fileBody;
let ext = 'm4a';
let ext = 'm4a';
let contentType = 'audio/m4a';
if (Platform.OS === 'web') {
const response = await fetch(localUri);
const blob = await response.blob();
fileBody = blob;
// Detect extension from blob type
if (blob.type === 'audio/mpeg' || blob.type === 'audio/mp3') {
ext = 'mp3';
@@ -137,11 +108,6 @@ export function useResponseMediaUpload(
contentType = 'audio/m4a';
}
} else {
const base64 = await FileSystem.readAsStringAsync(localUri, {
encoding: FileSystem.EncodingType.Base64,
});
fileBody = decode(base64);
// Try to get extension from URI
const uriExt = localUri.split('.').pop()?.toLowerCase();
if (uriExt && ['m4a', 'mp3', 'wav', 'aac', 'caf'].includes(uriExt)) {
@@ -154,22 +120,8 @@ export function useResponseMediaUpload(
}
}
const timestamp = Date.now();
const mediaPath = `${userId}/${questionId}_${timestamp}.${ext}`;
const { error: uploadError } = await supabase.storage
.from('response-media')
.upload(mediaPath, fileBody, {
contentType,
upsert: false,
});
if (uploadError) {
console.error('Error uploading audio:', uploadError);
return { success: false, error: uploadError.message };
}
return { success: true, mediaPath };
const result = await uploadMedia(localUri, { kind: 'response', mimeType: contentType, questionId });
return { success: true, mediaPath: result.reference };
} catch (error) {
console.error('Error uploading audio:', error);
return {
+25 -115
View File
@@ -1,146 +1,56 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { RealtimeChannel } from '@supabase/supabase-js';
import { supabase } from '../lib/supabase';
import { useCallback, useEffect, useRef, useState } from 'react';
import { chatApi } from '../lib/chatApi';
export interface UseTypingIndicatorConfig {
/** Channel name for the chat/conversation */
channelName: string;
/** Current user's ID */
userId: string | undefined;
/** Timeout in ms before partner typing indicator disappears. Default: 3000 */
matchId?: string;
typingTimeout?: number;
/** Whether the screen is currently focused */
isFocused?: boolean;
/** Throttle interval in ms for sending typing events. Default: 2000 */
throttleInterval?: number;
pollInterval?: number;
}
export interface UseTypingIndicatorReturn {
/** Whether the partner is currently typing */
partnerTyping: boolean;
/** Call this when user types to broadcast typing event */
sendTypingEvent: () => void;
/** Clear the typing indicator (e.g., when message received) */
clearTypingIndicator: () => void;
}
const DEFAULT_TYPING_TIMEOUT = 3000;
const DEFAULT_THROTTLE_INTERVAL = 2000;
/**
* Hook for managing typing indicator state and broadcast events.
* Handles both sending typing events and listening for partner's typing.
*
* @param config - Configuration including channel name and user ID
* @returns Typing state and event handlers
*/
export const useTypingIndicator = (
config: UseTypingIndicatorConfig
): UseTypingIndicatorReturn => {
const {
channelName,
userId,
typingTimeout = DEFAULT_TYPING_TIMEOUT,
isFocused = true,
throttleInterval = DEFAULT_THROTTLE_INTERVAL,
} = config;
export const useTypingIndicator = ({ channelName, userId, matchId = channelName.replace(/^chat:/, ''), isFocused = true, throttleInterval = 2_000, pollInterval = 1_500 }: UseTypingIndicatorConfig): UseTypingIndicatorReturn => {
const [partnerTyping, setPartnerTyping] = useState(false);
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isFocusedRef = useRef(isFocused);
const lastTypingEventRef = useRef<number>(0);
const channelRef = useRef<RealtimeChannel | null>(null);
const lastTypingEventRef = useRef(0);
// Update ref when focus changes and clear state if blurred
useEffect(() => {
isFocusedRef.current = isFocused;
if (!isFocused) {
if (!matchId || !userId || !isFocused) {
setPartnerTyping(false);
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
typingTimeoutRef.current = null;
}
return;
}
}, [isFocused]);
// Clear timeout on cleanup
useEffect(() => {
return () => {
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
let active = true;
const poll = async () => {
try {
const state = await chatApi.getTyping(matchId);
if (active) setPartnerTyping(state.typing);
} catch {
if (active) setPartnerTyping(false);
}
};
}, []);
// Subscribe to typing broadcast events
useEffect(() => {
if (!channelName || !userId) return;
const channel = supabase.channel(channelName);
channelRef.current = channel;
channel.on('broadcast', { event: 'typing' }, (payload) => {
// Only process events if the screen is focused
if (!isFocusedRef.current) return;
if (payload.payload.userId !== userId) {
setPartnerTyping(true);
// Clear existing timeout
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
// Auto-clear typing indicator after timeout
typingTimeoutRef.current = setTimeout(() => {
setPartnerTyping(false);
}, typingTimeout);
}
});
channel.subscribe();
void poll();
const timer = setInterval(() => void poll(), pollInterval);
return () => {
channelRef.current = null;
supabase.removeChannel(channel);
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
}
active = false;
clearInterval(timer);
};
}, [channelName, userId, typingTimeout]);
}, [matchId, userId, isFocused, pollInterval]);
/**
* Send a typing event to the partner (throttled to reduce network traffic)
*/
const sendTypingEvent = useCallback(() => {
if (!channelRef.current || !userId) return;
// Throttle typing events to reduce network traffic
if (!matchId || !userId) return;
const now = Date.now();
if (now - lastTypingEventRef.current < throttleInterval) return;
lastTypingEventRef.current = now;
void chatApi.setTyping(matchId);
}, [matchId, userId, throttleInterval]);
channelRef.current.send({
type: 'broadcast',
event: 'typing',
payload: { userId },
});
}, [userId, throttleInterval]);
/**
* Manually clear the typing indicator (e.g., when a message is received)
*/
const clearTypingIndicator = useCallback(() => {
setPartnerTyping(false);
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current);
typingTimeoutRef.current = null;
}
}, []);
return {
partnerTyping,
sendTypingEvent,
clearTypingIndicator,
};
const clearTypingIndicator = useCallback(() => setPartnerTyping(false), []);
return { partnerTyping, sendTypingEvent, clearTypingIndicator };
};
@@ -0,0 +1,19 @@
import { apiClient } from './apiClient';
export interface AccountOperationResponse {
success: true;
message: string;
}
export interface SubscriptionSyncResponse {
success: true;
is_premium: boolean;
}
export const accountOperationsApi = {
deleteRelationship: () => apiClient.delete<AccountOperationResponse>('/v1/couple/data'),
resetProgress: () => apiClient.delete<AccountOperationResponse>('/v1/couple/progress'),
deleteAccount: () => apiClient.delete<AccountOperationResponse>('/v1/me'),
syncSubscription: () => apiClient.post<SubscriptionSyncResponse>('/v1/me/subscription/sync'),
};
+101
View File
@@ -0,0 +1,101 @@
import { authClient } from "./authClient";
const isTestEnv = process.env.NODE_ENV === "test" || process.env.JEST_WORKER_ID !== undefined;
const configuredApiUrl = process.env.EXPO_PUBLIC_API_URL;
if (!isTestEnv && !configuredApiUrl) {
throw new Error("Missing required environment variable: EXPO_PUBLIC_API_URL. App cannot start.");
}
export const apiUrl = (configuredApiUrl || "http://127.0.0.1:3003").replace(/\/$/, "");
export class ApiError extends Error {
constructor(
message: string,
readonly status: number,
readonly details?: unknown,
) {
super(message);
this.name = "ApiError";
}
}
type ApiRequestOptions = Omit<RequestInit, "body"> & {
body?: unknown;
};
async function parseResponse(response: Response): Promise<unknown> {
if (response.status === 204) return undefined;
const text = await response.text();
if (!text) return undefined;
try {
return JSON.parse(text);
} catch {
return text;
}
}
export async function apiRequest<T>(path: string, options: ApiRequestOptions = {}): Promise<T> {
const { data, error } = await authClient.auth.getSession();
const accessToken = data.session?.access_token;
if (error || !accessToken) {
throw new ApiError("An authenticated session is required", 401, error);
}
const headers = new Headers(options.headers);
headers.set("Accept", "application/json");
headers.set("Authorization", `Bearer ${accessToken}`);
let body: BodyInit | undefined;
if (options.body !== undefined) {
headers.set("Content-Type", "application/json");
body = JSON.stringify(options.body);
}
const response = await fetch(`${apiUrl}${path.startsWith("/") ? path : `/${path}`}`, {
...options,
headers,
body,
});
const responseBody = await parseResponse(response);
if (!response.ok) {
let message = `API request failed with status ${response.status}`;
if (typeof responseBody === "object" && responseBody !== null && "error" in responseBody) {
const apiError = (responseBody as { error: unknown }).error;
if (typeof apiError === "string") {
message = apiError;
} else if (
typeof apiError === "object" &&
apiError !== null &&
"message" in apiError &&
typeof (apiError as { message: unknown }).message === "string"
) {
message = (apiError as { message: string }).message;
}
}
throw new ApiError(message, response.status, responseBody);
}
return responseBody as T;
}
export async function authenticatedFetch(path: string, init: RequestInit = {}): Promise<Response> {
const { data, error } = await authClient.auth.getSession();
const accessToken = data.session?.access_token;
if (error || !accessToken) throw new ApiError("An authenticated session is required", 401, error);
const headers = new Headers(init.headers);
headers.set("Authorization", `Bearer ${accessToken}`);
return fetch(`${apiUrl}${path.startsWith("/") ? path : `/${path}`}`, { ...init, headers });
}
export const apiClient = {
get: <T>(path: string) => apiRequest<T>(path),
post: <T>(path: string, body?: unknown) => apiRequest<T>(path, { method: "POST", body }),
put: <T>(path: string, body?: unknown) => apiRequest<T>(path, { method: "PUT", body }),
patch: <T>(path: string, body?: unknown) => apiRequest<T>(path, { method: "PATCH", body }),
delete: <T>(path: string) => apiRequest<T>(path, { method: "DELETE" }),
};
+23
View File
@@ -0,0 +1,23 @@
import { apiClient } from './apiClient';
import type { Question } from '../types';
import type { Match } from '../features/chat/types';
import type { StrokeSegment } from '../features/live-draw/types';
export interface LiveDrawState {
strokes: StrokeSegment[];
revision: number;
updated_at: string | null;
updated_by: string | null;
}
export const appDataApi = {
packContext: (packId: string) => apiClient.get<{ pack: { id: string; name: string; icon: string | null } }>(`/v1/packs/${encodeURIComponent(packId)}/context`),
packQuestions: (packId: string) => apiClient.get<{ questions: Question[] }>(`/v1/packs/${encodeURIComponent(packId)}/questions`),
packTeaser: (packId: string) => apiClient.get<{ questions: Array<{ id: string; text: string; intensity: number }> }>(`/v1/packs/${encodeURIComponent(packId)}/teaser`),
matchContext: (matchId: string) => apiClient.get<{ match: Match }>(`/v1/matches/${encodeURIComponent(matchId)}/context`),
markMediaViewed: (messageId: string, expiresAt: string | null) => apiClient.patch<{ media_viewed_at: string; media_expires_at: string | null }>(`/v1/messages/${encodeURIComponent(messageId)}/media-viewed`, { expires_at: expiresAt }),
nudgeStatus: () => apiClient.get<{ last_nudge_sent_at: string | null }>('/v1/me/nudge-status'),
sendNudge: () => apiClient.post<{ success: true; notification_sent: boolean; reason?: string; next_nudge_available_at: string }>('/v1/me/nudge'),
getLiveDraw: () => apiClient.get<LiveDrawState>('/v1/live-draw'),
putLiveDraw: (strokes: StrokeSegment[], baseRevision: number) => apiClient.put<LiveDrawState>('/v1/live-draw', { strokes, base_revision: baseRevision }),
};
+67 -77
View File
@@ -1,77 +1,67 @@
import "react-native-url-polyfill/auto";
import { createClient } from "@supabase/supabase-js";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { Platform } from "react-native";
// In-memory storage fallback for SSR/Node.js environments
const memoryStorage: Record<string, string> = {};
// AsyncStorage adapter for Supabase auth
// Note: We use AsyncStorage instead of SecureStore because Supabase JWTs
// can exceed SecureStore's 2048 byte limit
const ExpoStorageAdapter = {
getItem: async (key: string): Promise<string | null> => {
if (Platform.OS === "web") {
// Check if we're in a browser environment with localStorage
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
return window.localStorage.getItem(key);
}
// Fallback for SSR/Node.js
return memoryStorage[key] ?? null;
}
return AsyncStorage.getItem(key);
},
setItem: async (key: string, value: string): Promise<void> => {
if (Platform.OS === "web") {
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
window.localStorage.setItem(key, value);
return;
}
// Fallback for SSR/Node.js
memoryStorage[key] = value;
return;
}
await AsyncStorage.setItem(key, value);
},
removeItem: async (key: string): Promise<void> => {
if (Platform.OS === "web") {
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
window.localStorage.removeItem(key);
return;
}
// Fallback for SSR/Node.js
delete memoryStorage[key];
return;
}
await AsyncStorage.removeItem(key);
},
};
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY;
// Validate environment variables at startup (skip in test environment where mocks handle this)
const isTestEnv = process.env.NODE_ENV === 'test' || process.env.JEST_WORKER_ID !== undefined;
if (!isTestEnv && (!supabaseUrl || !supabaseAnonKey)) {
const missing = [];
if (!supabaseUrl) missing.push('EXPO_PUBLIC_SUPABASE_URL');
if (!supabaseAnonKey) missing.push('EXPO_PUBLIC_SUPABASE_ANON_KEY');
const errorMsg = `Missing required environment variables: ${missing.join(', ')}. App cannot start.`;
console.error('[Supabase]', errorMsg);
// In production, this will be caught by Crashlytics before crashing
throw new Error(errorMsg);
}
// Use dummy values in test environment (supabase-js is mocked anyway)
const finalUrl = supabaseUrl || (isTestEnv ? 'https://test.supabase.co' : '');
const finalKey = supabaseAnonKey || (isTestEnv ? 'test-key' : '');
export const supabase = createClient(finalUrl, finalKey, {
auth: {
storage: ExpoStorageAdapter,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: Platform.OS === "web",
},
});
import "react-native-url-polyfill/auto";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { createClient } from "@supabase/supabase-js";
import { Platform } from "react-native";
// Supabase is retained as Sauci's identity provider only. Product data should
// Product data uses apiClient. This client is intentionally Auth-only.
const memoryStorage: Record<string, string> = {};
const ExpoStorageAdapter = {
getItem: async (key: string): Promise<string | null> => {
if (Platform.OS === "web") {
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
return window.localStorage.getItem(key);
}
return memoryStorage[key] ?? null;
}
return AsyncStorage.getItem(key);
},
setItem: async (key: string, value: string): Promise<void> => {
if (Platform.OS === "web") {
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
window.localStorage.setItem(key, value);
return;
}
memoryStorage[key] = value;
return;
}
await AsyncStorage.setItem(key, value);
},
removeItem: async (key: string): Promise<void> => {
if (Platform.OS === "web") {
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
window.localStorage.removeItem(key);
return;
}
delete memoryStorage[key];
return;
}
await AsyncStorage.removeItem(key);
},
};
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY;
const isTestEnv = process.env.NODE_ENV === "test" || process.env.JEST_WORKER_ID !== undefined;
if (!isTestEnv && (!supabaseUrl || !supabaseAnonKey)) {
const missing = [];
if (!supabaseUrl) missing.push("EXPO_PUBLIC_SUPABASE_URL");
if (!supabaseAnonKey) missing.push("EXPO_PUBLIC_SUPABASE_ANON_KEY");
const message = `Missing required environment variables: ${missing.join(", ")}. App cannot start.`;
console.error("[Auth]", message);
throw new Error(message);
}
const finalUrl = supabaseUrl || (isTestEnv ? "https://test.supabase.co" : "");
const finalKey = supabaseAnonKey || (isTestEnv ? "test-key" : "");
export const authClient = createClient(finalUrl, finalKey, {
auth: {
storage: ExpoStorageAdapter,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: Platform.OS === "web",
},
});
-108
View File
@@ -1,108 +0,0 @@
import { router } from "expo-router";
import { supabase } from "./supabase";
// Import directly from authStore to avoid circular dependency with store/index.ts
import { useAuthStore } from "../store/authStore";
/**
* Handles authentication errors from edge function calls.
*
* When a 401 error occurs, this function:
* 1. Attempts to refresh the session
* 2. If refresh fails (session was deleted server-side), signs the user out and navigates to login
*
* @returns true if the error was handled and user should retry, false if signed out
*/
export async function handleAuthError(error: any): Promise<boolean> {
// Check if this is a 401 authentication error
const is401 = error?.status === 401 ||
error?.message?.includes("401") ||
error?.message?.includes("session") ||
error?.message?.includes("unauthorized");
if (!is401) {
return false;
}
console.log("[AuthErrorHandler] Detected auth error, attempting session refresh");
try {
// Try to refresh the session
const { data, error: refreshError } = await supabase.auth.refreshSession();
if (refreshError || !data.session) {
console.log("[AuthErrorHandler] Session refresh failed, signing out:", refreshError?.message);
// Session is invalid - sign the user out and navigate to login
await useAuthStore.getState().signOut();
// Explicitly navigate to login to ensure user doesn't get stuck
router.replace("/(auth)/login");
return false;
}
console.log("[AuthErrorHandler] Session refreshed successfully");
return true;
} catch (e) {
console.error("[AuthErrorHandler] Error during refresh:", e);
await useAuthStore.getState().signOut();
router.replace("/(auth)/login");
return false;
}
}
/**
* Wrapper for edge function invocation with automatic auth error handling.
*
* @param functionName - Name of the edge function to invoke
* @param options - Options including body and headers
* @param retryOnAuthError - Whether to retry after refreshing auth (default: true)
* @returns The result of the function invocation
*/
export async function invokeWithAuthRetry(
functionName: string,
options: { body?: any; headers?: Record<string, string> } = {},
retryOnAuthError = true
): Promise<{ data: any; error: any }> {
// Get current session
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) {
console.error("[AuthErrorHandler] No valid session for function call");
return { data: null, error: { message: "No valid session" } };
}
// Make the request
const result = await supabase.functions.invoke(functionName, {
body: options.body,
headers: {
...options.headers,
Authorization: `Bearer ${session.access_token}`,
},
});
// Check for auth error
if (result.error) {
const is401 = result.error?.status === 401 ||
result.error?.message?.includes("401") ||
result.error?.context?.status === 401;
if (is401 && retryOnAuthError) {
console.log("[AuthErrorHandler] Got 401, attempting refresh and retry");
const canRetry = await handleAuthError(result.error);
if (canRetry) {
// Get new session and retry
const { data: { session: newSession } } = await supabase.auth.getSession();
if (newSession?.access_token) {
return supabase.functions.invoke(functionName, {
body: options.body,
headers: {
...options.headers,
Authorization: `Bearer ${newSession.access_token}`,
},
});
}
}
}
}
return result;
}
+15
View File
@@ -0,0 +1,15 @@
import { apiClient } from './apiClient';
import type { Message, ReportReason } from '../features/chat/types';
export const chatApi = {
listMessages: (matchId: string) => apiClient.get<{ messages: Message[] }>(`/v1/matches/${encodeURIComponent(matchId)}/messages`),
sendText: (matchId: string, content: string) => apiClient.post<{ message: Message }>(`/v1/matches/${encodeURIComponent(matchId)}/messages`, { content }),
unread: () => apiClient.get<{ total: number; by_match: Record<string, number> }>('/v1/chat/unread'),
markRead: (matchId: string) => apiClient.post<{ updated: number; read_at: string }>(`/v1/matches/${encodeURIComponent(matchId)}/read`),
markDelivered: (messageId: string) => apiClient.post<{ message: Message }>(`/v1/messages/${encodeURIComponent(messageId)}/delivered`),
deleteForSelf: (messageId: string) => apiClient.delete<{ deleted: true }>(`/v1/messages/${encodeURIComponent(messageId)}?scope=self`),
deleteForEveryone: (messageId: string) => apiClient.delete<{ deleted: true; message: Message }>(`/v1/messages/${encodeURIComponent(messageId)}?scope=everyone`),
report: (messageId: string, reason: ReportReason) => apiClient.post<{ reported: true }>(`/v1/messages/${encodeURIComponent(messageId)}/reports`, { reason }),
setTyping: (matchId: string) => apiClient.put<{ typing: true }>(`/v1/matches/${encodeURIComponent(matchId)}/typing`),
getTyping: (matchId: string) => apiClient.get<{ typing: boolean; expires_at: string | null }>(`/v1/matches/${encodeURIComponent(matchId)}/typing`),
};
+23
View File
@@ -0,0 +1,23 @@
import type { Couple, Profile } from "@/types";
import { apiRequest } from "./apiClient";
export interface CoupleStateResponse {
couple: Couple | null;
partner: Profile | null;
}
export interface CoupleMutationResponse {
success: true;
couple_id: string | null;
invite_code?: string;
}
export const coupleApi = {
getState: () => apiRequest<CoupleStateResponse>("/v1/couple"),
create: () => apiRequest<CoupleMutationResponse>("/v1/couple", { method: "POST", body: {} }),
join: (inviteCode: string) => apiRequest<CoupleMutationResponse>("/v1/couple", {
method: "POST",
body: { invite_code: inviteCode },
}),
cancel: () => apiRequest<CoupleMutationResponse>("/v1/couple", { method: "DELETE" }),
};
+7 -13
View File
@@ -1,4 +1,4 @@
import { supabase } from './supabase';
import { getMediaUrl, mediaId } from './mediaApi';
import * as FileSystem from 'expo-file-system';
import { Platform } from 'react-native';
@@ -38,23 +38,17 @@ export async function getCachedSignedUrl(
return cached.url;
}
// Generate new signed URL
const { data, error } = await supabase.storage
.from(bucket)
.createSignedUrl(storagePath, SIGNED_URL_EXPIRY_SECONDS);
if (error || !data?.signedUrl) {
console.error('Failed to get signed URL:', error);
return null;
}
if (!mediaId(storagePath)) return storagePath;
const data = await getMediaUrl(storagePath).catch((error) => { console.error('Failed to get signed URL:', error); return null; });
if (!data?.url) return null;
// Cache the new URL
signedUrlCache.set(cacheKey, {
url: data.signedUrl,
expiresAt: now + SIGNED_URL_EXPIRY_SECONDS * 1000,
url: data.url,
expiresAt: new Date(data.expires_at).getTime(),
});
return data.signedUrl;
return data.url;
}
/**
+36
View File
@@ -0,0 +1,36 @@
import { Platform } from 'react-native';
import * as FileSystem from 'expo-file-system';
import { decode } from 'base64-arraybuffer';
import { ApiError, apiClient, authenticatedFetch } from './apiClient';
export type MediaKind = 'avatar' | 'response' | 'chat' | 'feedback';
export interface UploadMediaOptions { kind: MediaKind; mimeType: string; questionId?: string; matchId?: string }
export interface MediaUploadResponse { reference: string; media: { id: string; mime_type: string; byte_size: number }; message?: Record<string, unknown> }
export async function readMediaBody(uri: string): Promise<ArrayBuffer | Blob> {
if (Platform.OS === 'web') return (await fetch(uri)).blob();
return decode(await FileSystem.readAsStringAsync(uri, { encoding: FileSystem.EncodingType.Base64 }));
}
export async function uploadMedia(uri: string, options: UploadMediaOptions): Promise<MediaUploadResponse> {
const query = new URLSearchParams();
if (options.questionId) query.set('questionId', options.questionId);
if (options.matchId) query.set('matchId', options.matchId);
const suffix = query.toString() ? `?${query}` : '';
const response = await authenticatedFetch(`/v1/media/${options.kind}${suffix}`, {
method: 'POST', headers: { 'Content-Type': options.mimeType }, body: await readMediaBody(uri),
});
const body = await response.json().catch(() => null) as any;
if (!response.ok) throw new ApiError(body?.error?.message ?? 'Media upload failed', response.status, body);
return body as MediaUploadResponse;
}
export function mediaId(reference: string): string | null {
const value = reference.startsWith('media:') ? reference.slice(6) : reference;
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value) ? value : null;
}
export const getMediaUrl = (reference: string) => {
const id = mediaId(reference);
return id ? apiClient.get<{ url: string; expires_at: string }>(`/v1/media/${id}/url`) : Promise.resolve({ url: reference, expires_at: new Date(Date.now()+3600_000).toISOString() });
};
+20 -26
View File
@@ -2,7 +2,9 @@ import * as Notifications from "expo-notifications";
import Constants from "expo-constants";
import { Platform } from "react-native";
import { router } from "expo-router";
import { supabase } from "./supabase";
import { apiClient } from "./apiClient";
import { profileSettingsApi } from "./profileSettingsApi";
import type { Profile } from "../types";
import { captureError } from "./crashlytics";
import { Events } from "./analytics";
import { useMatchStore } from "../store/matchStore";
@@ -76,32 +78,28 @@ export async function registerForPushNotificationsAsync(): Promise<string | null
}
/**
* Save push token to user profile in Supabase.
* Save push token to the standalone profile API.
*/
export async function savePushToken(userId: string, token: string): Promise<void> {
const { error } = await supabase
.from("profiles")
.update({ push_token: token })
.eq("id", userId);
if (error) {
console.error("Failed to save push token:", error);
captureError(new Error("Failed to save push token"), { userId, error: error.message });
}
export async function savePushToken(userId: string, token: string): Promise<void> {
try {
await profileSettingsApi.updateProfile({ push_token: token });
} catch (error) {
console.error("Failed to save push token:", error);
captureError(error as Error, { userId });
throw error;
}
}
/**
* Clear push token from user profile on sign out.
*/
export async function clearPushToken(userId: string): Promise<void> {
const { error } = await supabase
.from("profiles")
.update({ push_token: null })
.eq("id", userId);
if (error) {
console.error("Failed to clear push token:", error);
}
export async function clearPushToken(userId: string): Promise<void> {
try {
await profileSettingsApi.updateProfile({ push_token: null });
} catch (error) {
console.error("Failed to clear push token:", error);
throw error;
}
}
/**
@@ -194,11 +192,7 @@ export async function checkAndRegisterPushToken(userId: string): Promise<boolean
}
// Fetch the stored token from the database
const { data: profile } = await supabase
.from("profiles")
.select("push_token")
.eq("id", userId)
.single();
const { profile } = await apiClient.get<{ profile: Profile }>("/v1/me");
console.log(`[Push] Stored token: ${profile?.push_token}`);
+34
View File
@@ -0,0 +1,34 @@
import { apiClient } from './apiClient';
import type { Gender } from '../types';
import type { NotificationPreferences } from '../store/notificationPreferencesStore';
export interface ProfileUpdate {
name?: string;
gender?: Gender | null;
usage_reason?: 'improve_communication' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship' | null;
max_intensity?: 1 | 2 | 3 | 4 | 5;
show_explicit_content?: boolean;
hide_nsfw?: boolean;
onboarding_completed?: boolean;
onboarding_version?: number;
public_key_jwk?: Record<string, unknown> | null;
push_token?: string | null;
}
type PreferenceKey = keyof Omit<NotificationPreferences, 'user_id' | 'created_at' | 'updated_at'>;
export const profileSettingsApi = {
updateProfile: (update: ProfileUpdate) => apiClient.patch<{ updated: true }>('/v1/me/profile', update),
updateLastActive: () => apiClient.post<void>('/v1/me/activity'),
getNotificationPreferences: () => apiClient.get<NotificationPreferences>('/v1/me/notification-preferences'),
updateNotificationPreference: (key: PreferenceKey, value: boolean) =>
apiClient.patch<NotificationPreferences>('/v1/me/notification-preferences', { key, value }),
submitFeedback: (feedback: {
type: 'bug' | 'feature_request' | 'general' | 'question';
title: string;
description: string;
question_id?: string;
device_info?: object;
screenshot_media_id?: string;
}) => apiClient.post<{ id: string; created_at: string }>('/v1/feedback', feedback),
};
+39 -65
View File
@@ -1,5 +1,8 @@
import { create } from "zustand";
import { supabase } from "../lib/supabase";
import { create } from "zustand";
import { ApiError, apiClient } from "../lib/apiClient";
import { authClient } from "../lib/authClient";
import { coupleApi } from "../lib/coupleApi";
import { profileSettingsApi } from "../lib/profileSettingsApi";
import { Events } from "../lib/analytics";
import type { Profile, Couple } from "@/types";
@@ -45,42 +48,18 @@ export const useAuthStore = create<AuthState>((set, get) => ({
fetchUser: async () => {
try {
// First check if we have a session in storage
const { data: { session } } = await supabase.auth.getSession();
const { data: { session } } = await authClient.auth.getSession();
if (!session?.user) {
set({ user: null, isAuthenticated: false, isAnonymous: false, isLoading: false });
return;
}
// Validate the session by fetching the user from the server
// This will fail if the session was deleted server-side
const { data: { user: authUser }, error: authError } = await supabase.auth.getUser();
if (authError || !authUser) {
console.log("[Auth] Session invalid, signing out:", authError?.message);
// Session is invalid - clear everything
set({ user: null, couple: null, partner: null, isAuthenticated: false, isAnonymous: false, isLoading: false });
// Clear other stores
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks();
useMessageStore.getState().clearMessages();
useSubscriptionStore.getState().clearSubscription();
useNotificationPreferencesStore.getState().clearPreferences();
useStreakStore.getState().clearStreak();
// Sign out from Supabase (clears storage)
await supabase.auth.signOut();
return;
}
const { data: profile } = await supabase
.from("profiles")
.select("*")
.eq("id", authUser.id)
.maybeSingle();
const isAnonymous = !!(authUser as any).is_anonymous;
// This endpoint verifies the Supabase access token and creates the
// product profile on first use, keeping profile data out of Supabase.
const { profile } = await apiClient.get<{ profile: Profile }>("/v1/me");
const isAnonymous = !!(session.user as { is_anonymous?: boolean }).is_anonymous;
// Only update user if data actually changed to avoid unnecessary re-renders
const currentUser = get().user;
@@ -103,31 +82,34 @@ export const useAuthStore = create<AuthState>((set, get) => ({
}
set({ isLoading: false });
} catch (error) {
console.error("Error fetching user:", error);
set({ isLoading: false });
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
console.log("[Auth] Session rejected by API, signing out");
set({ user: null, couple: null, partner: null, isAuthenticated: false, isAnonymous: false, isLoading: false });
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks();
useMessageStore.getState().clearMessages();
useSubscriptionStore.getState().clearSubscription();
useNotificationPreferencesStore.getState().clearPreferences();
useStreakStore.getState().clearStreak();
await authClient.auth.signOut();
return;
}
console.error("Error fetching user:", error);
set({ isLoading: false });
}
},
fetchCouple: async () => {
const user = get().user;
if (!user?.couple_id) return;
const { data: couple } = await supabase
.from("couples")
.select("*")
.eq("id", user.couple_id)
.maybeSingle();
// Fetch partner
const { data: partner } = await supabase
.from("profiles")
.select("*")
.eq("couple_id", user.couple_id)
.neq("id", user.id)
.maybeSingle();
set({ couple, partner });
fetchCouple: async () => {
const user = get().user;
if (!user?.couple_id) {
set({ couple: null, partner: null });
return;
}
const { couple, partner } = await coupleApi.getState();
set({ couple, partner });
},
/**
@@ -138,12 +120,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
const user = get().user;
if (!user?.couple_id) return null;
const { data: partner } = await supabase
.from("profiles")
.select("*")
.eq("couple_id", user.couple_id)
.neq("id", user.id)
.maybeSingle();
const { partner } = await coupleApi.getState();
if (partner) {
set({ partner });
@@ -178,7 +155,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
// Then try to sign out from Supabase (don't block on this)
try {
await supabase.auth.signOut();
await authClient.auth.signOut();
} catch (error) {
console.error("Supabase signOut error:", error);
}
@@ -218,10 +195,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
if (!userId) return;
try {
await supabase
.from("profiles")
.update({ last_active_at: new Date().toISOString() })
.eq("id", userId);
await profileSettingsApi.updateLastActive();
} catch (error) {
// Silently fail - this is not critical
console.error("Failed to update last_active_at:", error);
+32 -309
View File
@@ -1,5 +1,6 @@
import { create } from "zustand";
import { supabase } from "../lib/supabase";
import { ApiError, apiClient } from "../lib/apiClient";
import { appDataApi } from "../lib/appDataApi";
import type { Match, Question, QuestionPack, AnswerType } from "@/types";
import { useAuthStore } from "./authStore";
@@ -119,48 +120,10 @@ export const useMatchStore = create<MatchState>((set, get) => ({
try {
const currentPage = refresh ? 0 : state.page;
const from = currentPage * BATCH_SIZE;
const to = from + BATCH_SIZE - 1;
// Fetch archived match IDs for this user on refresh
let archivedMatchIds = state.archivedMatchIds;
if (refresh && userId) {
const { data: archives } = await supabase
.from("match_archives")
.select("match_id")
.eq("user_id", userId);
archivedMatchIds = new Set(archives?.map(a => a.match_id) ?? []);
}
// Fetch total count on refresh (excluding archived)
let totalCount = state.totalCount;
if (refresh) {
const { count, error: countError } = await supabase
.from("matches")
.select("*", { count: "exact", head: true })
.eq("couple_id", coupleId);
if (!countError) {
// Subtract archived count from total
totalCount = (count ?? 0) - archivedMatchIds.size;
}
}
const { data: matches, error: matchError } = await supabase
.from("matches")
.select(`
*,
question:questions(*)
`)
.eq("couple_id", coupleId)
.order("created_at", { ascending: false })
.range(from, to);
if (matchError) throw matchError;
// Filter out archived matches
const nonArchivedMatches = matches?.filter(m => !archivedMatchIds.has(m.id)) ?? [];
const result = await apiClient.get<{ matches: Match[]; totalCount: number }>(`/v1/matches?page=${currentPage}&limit=${BATCH_SIZE}`);
const nonArchivedMatches = result.matches;
const totalCount = result.totalCount;
const archivedMatchIds = state.archivedMatchIds;
if (nonArchivedMatches.length === 0) {
if (refresh) {
@@ -171,58 +134,7 @@ export const useMatchStore = create<MatchState>((set, get) => ({
return;
}
// Fetch all responses for these questions to determine who answered first
const questionIds = nonArchivedMatches.map(m => m.question_id);
let responses: any[] = [];
if (questionIds.length > 0) {
const { data } = await supabase
.from("responses")
.select("*")
.in("question_id", questionIds);
responses = data ?? [];
}
// Fetch unread message counts per match
const matchIds = nonArchivedMatches.map(m => m.id);
let unreadCounts: Record<string, number> = {};
if (userId && matchIds.length > 0) {
const { data: unreadMessages, error } = await supabase
.from("messages")
.select("match_id")
.in("match_id", matchIds)
.neq("user_id", userId)
.is("read_at", null);
if (error) {
console.error("Error fetching unread messages:", error);
}
// Count unread messages per match
unreadMessages?.forEach(msg => {
unreadCounts[msg.match_id] = (unreadCounts[msg.match_id] || 0) + 1;
});
}
const data = nonArchivedMatches.map(match => ({
...match,
responses: responses.filter(r => r.question_id === match.question_id),
unreadCount: unreadCounts[match.id] || 0
}));
// Sort: unread messages first, then by most recent
// Note: Since we fetch by created_at desc, and then sort by unread in memory,
// pagination might be slightly inconsistent if unread messages are scattered.
// However, implementing complex sort in Supabase with computed columns is harder.
// For now, we accept that the sort is applied within the fetched batch.
const sortedData = data.sort((a, b) => {
// First priority: matches with unread messages
if (a.unreadCount > 0 && b.unreadCount === 0) return -1;
if (a.unreadCount === 0 && b.unreadCount > 0) return 1;
// Second priority: most recent first
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
});
const sortedData = nonArchivedMatches;
// Calculate new matches count - this is tricky with pagination
// Ideally we should have a separate query for count, but we'll just count in current batch
@@ -257,10 +169,7 @@ export const useMatchStore = create<MatchState>((set, get) => ({
},
markAsSeen: async (matchId) => {
await supabase
.from("matches")
.update({ is_new: false })
.eq("id", matchId);
await apiClient.patch("/v1/matches/seen", { ids: [matchId] });
const matches = get().matches.map((m) =>
m.id === matchId ? { ...m, is_new: false } : m
@@ -274,10 +183,7 @@ export const useMatchStore = create<MatchState>((set, get) => ({
if (newMatches.length === 0) return;
const newMatchIds = newMatches.map((m) => m.id);
await supabase
.from("matches")
.update({ is_new: false })
.in("id", newMatchIds);
await apiClient.patch("/v1/matches/seen", { ids: newMatchIds });
const matches = get().matches.map((m) =>
m.is_new ? { ...m, is_new: false } : m
@@ -348,12 +254,9 @@ clearMatches: () => {
});
}
// Insert archive record
const { error } = await supabase
.from("match_archives")
.insert({ match_id: matchId, user_id: userId });
if (error) {
try {
await apiClient.put(`/v1/matches/${matchId}/archive`, { archived: true });
} catch (error) {
console.error("Error archiving match:", error);
// Revert on error
if (matchToArchive) {
@@ -390,14 +293,9 @@ clearMatches: () => {
});
}
// Delete archive record
const { error } = await supabase
.from("match_archives")
.delete()
.eq("match_id", matchId)
.eq("user_id", userId);
if (error) {
try {
await apiClient.put(`/v1/matches/${matchId}/archive`, { archived: false });
} catch (error) {
console.error("Error unarchiving match:", error);
// Revert on error
if (matchToUnarchive) {
@@ -426,56 +324,14 @@ clearMatches: () => {
set({ isLoadingArchived: true });
try {
// Get archived match IDs for this user
const { data: archives, error: archiveError } = await supabase
.from("match_archives")
.select("match_id")
.eq("user_id", userId);
if (archiveError) throw archiveError;
const archivedIds = archives?.map(a => a.match_id) ?? [];
if (archivedIds.length === 0) {
const result = await apiClient.get<{matches:Match[];totalCount:number}>(`/v1/matches?archived=true&limit=100`);
const archivedMatches = result.matches;
const archivedIds = archivedMatches.map(match => match.id);
if (archivedMatches.length === 0) {
set({ archivedMatches: [], isLoadingArchived: false });
return;
}
// Fetch the actual match data
const { data: matches, error: matchError } = await supabase
.from("matches")
.select(`
*,
question:questions(*)
`)
.in("id", archivedIds)
.eq("couple_id", coupleId)
.order("created_at", { ascending: false });
if (matchError) throw matchError;
// Fetch unread counts for archived matches
const matchIds = matches?.map(m => m.id) ?? [];
let unreadCounts: Record<string, number> = {};
if (matchIds.length > 0) {
const { data: unreadMessages } = await supabase
.from("messages")
.select("match_id")
.in("match_id", matchIds)
.neq("user_id", userId)
.is("read_at", null);
unreadMessages?.forEach(msg => {
unreadCounts[msg.match_id] = (unreadCounts[msg.match_id] || 0) + 1;
});
}
const archivedMatches = (matches ?? []).map(match => ({
...match,
unreadCount: unreadCounts[match.id] || 0
}));
set({
archivedMatches,
isLoadingArchived: false,
@@ -519,59 +375,7 @@ clearMatches: () => {
set({ isLoadingPending: true });
try {
// Get partner's user_id
const { data: coupleProfiles } = await supabase
.from("profiles")
.select("id")
.eq("couple_id", coupleId)
.neq("id", userId);
const partnerId = coupleProfiles?.[0]?.id;
if (!partnerId) {
set({ pendingQuestions: [], isLoadingPending: false });
return;
}
// Get questions the current user has already answered
const { data: userResponses } = await supabase
.from("responses")
.select("question_id")
.eq("user_id", userId)
.eq("couple_id", coupleId);
const answeredQuestionIds = new Set(userResponses?.map(r => r.question_id) ?? []);
// Get partner's responses (questions they answered that user hasn't)
const { data: partnerResponses, error } = await supabase
.from("responses")
.select(`
id,
question_id,
created_at,
question:questions(
*,
pack:question_packs(id, name, icon)
)
`)
.eq("user_id", partnerId)
.eq("couple_id", coupleId)
.order("created_at", { ascending: false });
if (error) throw error;
// Filter to only questions user hasn't answered, excluding deleted questions
// Note: Supabase returns single-object relations as arrays in type inference, but they're actually objects at runtime
const pendingQuestions: PendingQuestion[] = (partnerResponses ?? [])
.filter(r => {
const question = r.question as unknown as PendingQuestion['question'] | null;
return !answeredQuestionIds.has(r.question_id) && question && !(question as any).deleted_at;
})
.map(r => ({
id: r.id,
question: r.question as unknown as PendingQuestion['question'],
partnerAnsweredAt: r.created_at,
}));
const { questions: pendingQuestions } = await apiClient.get<{questions:PendingQuestion[]}>("/v1/questions/pending?direction=partner");
set({ pendingQuestions, isLoadingPending: false });
} catch (err) {
console.error("Error fetching pending questions:", err);
@@ -595,58 +399,7 @@ clearMatches: () => {
set({ isLoadingTheirTurn: true });
try {
// Get partner's user_id
const { data: coupleProfiles } = await supabase
.from("profiles")
.select("id")
.eq("couple_id", coupleId)
.neq("id", userId);
const partnerId = coupleProfiles?.[0]?.id;
if (!partnerId) {
set({ theirTurnQuestions: [], isLoadingTheirTurn: false });
return;
}
// Get questions the partner has already answered
const { data: partnerResponses } = await supabase
.from("responses")
.select("question_id")
.eq("user_id", partnerId)
.eq("couple_id", coupleId);
const partnerAnsweredQuestionIds = new Set(partnerResponses?.map(r => r.question_id) ?? []);
// Get user's responses (questions they answered that partner hasn't)
const { data: userResponses, error } = await supabase
.from("responses")
.select(`
id,
question_id,
created_at,
question:questions(
*,
pack:question_packs(id, name, icon)
)
`)
.eq("user_id", userId)
.eq("couple_id", coupleId)
.order("created_at", { ascending: false });
if (error) throw error;
// Filter to only questions partner hasn't answered, excluding deleted questions
const theirTurnQuestions: PendingQuestion[] = (userResponses ?? [])
.filter(r => {
const question = r.question as unknown as PendingQuestion['question'] | null;
return !partnerAnsweredQuestionIds.has(r.question_id) && question && !(question as any).deleted_at;
})
.map(r => ({
id: r.id,
question: r.question as unknown as PendingQuestion['question'],
partnerAnsweredAt: r.created_at, // Using user's answer time here
}));
const { questions: theirTurnQuestions } = await apiClient.get<{questions:PendingQuestion[]}>("/v1/questions/pending?direction=mine");
set({ theirTurnQuestions, isLoadingTheirTurn: false });
} catch (err) {
console.error("Error fetching their turn questions:", err);
@@ -686,41 +439,7 @@ clearMatches: () => {
set({ isNudging: true });
try {
const { data: sessionData } = await supabase.auth.getSession();
const token = sessionData?.session?.access_token;
if (!token) {
set({ isNudging: false });
return { success: false, notificationSent: false };
}
const response = await fetch(
`${process.env.EXPO_PUBLIC_SUPABASE_URL}/functions/v1/send-nudge-notification`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
}
);
const data: NudgeResponse = await response.json();
if (response.status === 429) {
// Rate limited - update cooldown
const cooldownUntil = data.next_nudge_available_at
? new Date(data.next_nudge_available_at)
: new Date(Date.now() + (data.cooldown_remaining_seconds || 0) * 1000);
set({ nudgeCooldownUntil: cooldownUntil, isNudging: false });
return { success: false, notificationSent: false };
}
if (!response.ok) {
console.error("Nudge error:", data.error);
set({ isNudging: false });
return { success: false, notificationSent: false };
}
const data = await appDataApi.sendNudge();
// Success - update cooldown
const cooldownUntil = data.next_nudge_available_at
@@ -734,6 +453,14 @@ clearMatches: () => {
notificationSent: data.notification_sent ?? false,
};
} catch (err) {
if (err instanceof ApiError && err.status === 429) {
const details = err.details as NudgeResponse | undefined;
const cooldownUntil = details?.next_nudge_available_at
? new Date(details.next_nudge_available_at)
: new Date(Date.now() + (details?.cooldown_remaining_seconds || 0) * 1000);
set({ nudgeCooldownUntil: cooldownUntil, isNudging: false });
return { success: false, notificationSent: false };
}
console.error("Error sending nudge:", err);
set({ isNudging: false });
return { success: false, notificationSent: false };
@@ -745,13 +472,9 @@ clearMatches: () => {
if (!userId) return;
try {
const { data: profile, error } = await supabase
.from("profiles")
.select("last_nudge_sent_at")
.eq("id", userId)
.maybeSingle();
const profile = await appDataApi.nudgeStatus();
if (error || !profile?.last_nudge_sent_at) {
if (!profile.last_nudge_sent_at) {
set({ nudgeCooldownUntil: null });
return;
}
+5 -23
View File
@@ -1,5 +1,5 @@
import { create } from "zustand";
import { supabase } from "../lib/supabase";
import { chatApi } from "../lib/chatApi";
import type { Database } from "@/types/supabase";
import { useAuthStore } from "./authStore";
import { useMatchStore } from "./matchStore";
@@ -39,13 +39,8 @@ export const useMessageStore = create<MessageState>((set, get) => ({
return;
}
const { count } = await supabase
.from("messages")
.select("*", { count: "exact", head: true })
.neq("user_id", userId)
.is("read_at", null);
set({ unreadCount: count || 0 });
const { total } = await chatApi.unread();
set({ unreadCount: total });
},
addMessage: (message) => {
@@ -73,26 +68,13 @@ export const useMessageStore = create<MessageState>((set, get) => ({
const userId = useAuthStore.getState().user?.id;
if (!userId) return;
// Get count of unread messages before marking them read
const { count: unreadInMatch } = await supabase
.from("messages")
.select("*", { count: "exact", head: true })
.eq("match_id", matchId)
.neq("user_id", userId)
.is("read_at", null);
await supabase
.from("messages")
.update({ read_at: new Date().toISOString() })
.eq("match_id", matchId)
.neq("user_id", userId)
.is("read_at", null);
const { updated: unreadInMatch } = await chatApi.markRead(matchId);
// Refetch global unread count
await get().fetchUnreadCount();
// Sync per-match unread count in matchStore
if (unreadInMatch && unreadInMatch > 0) {
if (unreadInMatch > 0) {
useMatchStore.getState().updateMatchUnreadCount(matchId, -unreadInMatch);
}
},
@@ -1,5 +1,5 @@
import { create } from "zustand";
import { supabase } from "../lib/supabase";
import { profileSettingsApi } from "../lib/profileSettingsApi";
import { useAuthStore } from "./authStore";
export interface NotificationPreferences {
@@ -46,13 +46,8 @@ export const useNotificationPreferencesStore = create<NotificationPreferencesSta
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 });
const preferences = await profileSettingsApi.getNotificationPreferences();
set({ preferences, isLoading: false });
} catch (error) {
console.error('Error fetching notification preferences:', error);
set({ error: 'Failed to load notification preferences', isLoading: false });
@@ -74,14 +69,8 @@ export const useNotificationPreferencesStore = create<NotificationPreferencesSta
}
try {
const { error } = await supabase
.from('notification_preferences')
.update({ [key]: value })
.eq('user_id', userId);
if (error) throw error;
set({ isUpdating: false });
const preferences = await profileSettingsApi.updateNotificationPreference(key, value);
set({ preferences, isUpdating: false });
} catch (error) {
console.error('Error updating notification preference:', error);
// Revert optimistic update
+29 -123
View File
@@ -1,19 +1,9 @@
import { create } from "zustand";
import { supabase } from "../lib/supabase";
import { apiClient } from "../lib/apiClient";
import { Events } from "../lib/analytics";
import type { QuestionPack, Category } from "@/types";
import { useAuthStore } from "./authStore";
// Intensity thresholds derived from hide_nsfw for backwards compatibility
// When hide_nsfw=true, max_intensity=2 (mild content only)
// When hide_nsfw=false, max_intensity=5 (all content)
const NSFW_OFF_INTENSITY = 2;
const NSFW_ON_INTENSITY = 5;
const getMaxIntensityFromHideNsfw = (hideNsfw: boolean) => {
return hideNsfw ? NSFW_OFF_INTENSITY : NSFW_ON_INTENSITY;
};
// Progress data for a pack
export interface PackProgressData {
totalQuestions: number;
@@ -47,60 +37,17 @@ export const usePacksStore = create<PacksState>((set, get) => ({
fetchPacks: async () => {
set({ isLoading: true });
// Get hide_nsfw preference and derive max_intensity from it
const hideNsfw = useAuthStore.getState().user?.hide_nsfw ?? false;
const maxIntensity = getMaxIntensityFromHideNsfw(hideNsfw);
const showAllIntensities = get().showAllIntensities;
// Fetch categories
const { data: categories } = await supabase
.from("categories")
.select("*")
.order("sort_order");
// Hide categories explicitly marked non-public, but keep null for compatibility.
const visibleCategories = (categories ?? []).filter(category => category.is_public !== false);
const visibleCategoryIds = new Set(visibleCategories.map(category => category.id));
// Fetch packs with category info and question count
let query = supabase
.from("question_packs")
.select("*, category:categories(*), questions(count)")
.eq("is_public", true)
.order("sort_order");
// Filter out packs above user's intensity preference (unless showing all)
if (maxIntensity < 5 && !showAllIntensities) {
query = query.or(`max_intensity.is.null,max_intensity.lte.${maxIntensity}`);
try {
const showAllIntensities = get().showAllIntensities;
const catalog = await apiClient.get<{ categories: Category[]; packs: QuestionPack[] }>(
`/v1/packs?showAllIntensities=${showAllIntensities}`,
);
set({ packs: catalog.packs, categories: catalog.categories });
await get().fetchEnabledPacks();
await get().fetchPackProgress();
} finally {
set({ isLoading: false });
}
// Filter out explicit packs when user has hide_nsfw enabled
if (hideNsfw) {
query = query.eq("is_explicit", false);
}
const { data: packs } = await query;
const visiblePacks = (packs ?? []).filter(pack => {
if (pack.category?.is_public === false) return false;
if (pack.category_id && visibleCategoryIds.size > 0 && !visibleCategoryIds.has(pack.category_id)) {
return false;
}
return true;
});
// Also fetch enabled packs if logged in
await get().fetchEnabledPacks();
set({
packs: visiblePacks,
categories: visibleCategories,
isLoading: false
});
// Fetch progress after packs are loaded
get().fetchPackProgress();
},
fetchEnabledPacks: async () => {
@@ -110,14 +57,8 @@ export const usePacksStore = create<PacksState>((set, get) => ({
return;
}
const { data: couplePacks } = await supabase
.from("couple_packs")
.select("pack_id")
.eq("couple_id", coupleId)
.eq("enabled", true);
const ids = couplePacks?.map(cp => cp.pack_id) || [];
set({ enabledPackIds: ids });
const result = await apiClient.get<{ enabledPackIds: string[] }>("/v1/me/enabled-packs");
set({ enabledPackIds: result.enabledPackIds });
},
ensureEnabledPacksLoaded: async () => {
@@ -146,18 +87,17 @@ export const usePacksStore = create<PacksState>((set, get) => ({
set({ enabledPackIds: newIds });
const { error } = await supabase
.from("couple_packs")
.upsert({
couple_id: coupleId,
pack_id: packId,
enabled: newValue
});
if (error) {
try {
const result = await apiClient.put<{ enabledPackIds: string[] }>(
`/v1/me/enabled-packs/${packId}`,
{ enabled: newValue },
);
set({ enabledPackIds: result.enabledPackIds });
} catch (error) {
console.error("Error toggling pack:", error);
// Revert by refetching
get().fetchEnabledPacks();
set({ enabledPackIds: isEnabled
? [...new Set([...get().enabledPackIds, packId])]
: get().enabledPackIds.filter(id => id !== packId) });
return { success: false, reason: "error" };
}
@@ -178,50 +118,16 @@ export const usePacksStore = create<PacksState>((set, get) => ({
},
fetchPackProgress: async () => {
const userId = useAuthStore.getState().user?.id;
if (!userId) {
if (!useAuthStore.getState().user?.id) {
set({ packProgress: new Map() });
return;
}
const packs = get().packs;
if (packs.length === 0) return;
// Fetch user's response counts grouped by pack
const { data: responseCounts, error } = await supabase
.from("responses")
.select("question:questions!inner(pack_id)")
.eq("user_id", userId);
if (error) {
console.error("Error fetching pack progress:", error);
return;
}
// Debug: log response counts
console.log("[PackProgress] userId:", userId);
console.log("[PackProgress] total responses:", responseCounts?.length ?? 0);
// Count responses per pack
const responsesByPack = new Map<string, number>();
responseCounts?.forEach((r) => {
const packId = (r.question as any)?.pack_id;
if (packId) {
responsesByPack.set(packId, (responsesByPack.get(packId) || 0) + 1);
}
});
// Debug: log responses by pack
if (responsesByPack.size > 0) {
console.log("[PackProgress] responses by pack:", Object.fromEntries(responsesByPack));
}
// Build progress map
const result = await apiClient.get<{ progress: Array<PackProgressData & { packId: string }> }>(
"/v1/me/pack-progress",
);
const progressMap = new Map<string, PackProgressData>();
packs.forEach((pack) => {
const totalQuestions = pack.questions?.[0]?.count ?? 0;
const answeredQuestions = responsesByPack.get(pack.id) || 0;
progressMap.set(pack.id, { totalQuestions, answeredQuestions });
result.progress.forEach(({ packId, totalQuestions, answeredQuestions }) => {
progressMap.set(packId, { totalQuestions, answeredQuestions });
});
set({ packProgress: progressMap });
+13 -125
View File
@@ -1,6 +1,5 @@
import { create } from "zustand";
import { supabase } from "../lib/supabase";
import { invokeWithAuthRetry } from "../lib/authErrorHandler";
import { apiClient } from "../lib/apiClient";
import type { Question, QuestionPack, AnswerType, ResponseData } from "@/types";
import { useAuthStore } from "./authStore";
import { useMatchStore } from "./matchStore";
@@ -87,51 +86,9 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
try {
const currentPage = refresh ? 0 : state.page;
const from = currentPage * BATCH_SIZE;
const to = from + BATCH_SIZE - 1;
// Fetch total count on refresh
let totalCount = state.totalCount;
if (refresh) {
const { count, error: countError } = await supabase
.from("responses")
.select("*, question:questions!inner(pack:question_packs!inner(id))", { count: "exact", head: true })
.eq("user_id", userId);
if (!countError) {
totalCount = count ?? 0;
}
}
// Fetch all user's responses with questions and packs
const { data: responses, error: responsesError } = await supabase
.from("responses")
.select(`
id,
question_id,
answer,
response_data,
created_at,
question:questions!inner(
id,
text,
partner_text,
intensity,
pack_id,
question_type,
config,
pack:question_packs!inner(id, name, icon)
)
`)
.eq("user_id", userId)
.order("created_at", { ascending: false })
.range(from, to);
if (responsesError) {
console.error("Error fetching responses:", responsesError);
set({ isLoading: false, isLoadingMore: false });
return;
}
const result = await apiClient.get<{responses: ResponseWithQuestion[];totalCount:number}>(`/v1/me/responses?page=${currentPage}&limit=${BATCH_SIZE}`);
const responses = result.responses;
const totalCount = result.totalCount;
if (!responses || responses.length === 0) {
if (refresh) {
@@ -142,71 +99,7 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
return;
}
// Get question IDs for match lookup
const questionIds = responses.map((r) => r.question_id);
// Fetch matches for these questions
const { data: matches } = await supabase
.from("matches")
.select("id, question_id")
.eq("couple_id", coupleId)
.in("question_id", questionIds);
// Create a map of question_id -> match_id
const matchMap = new Map<string, string>();
matches?.forEach((m) => matchMap.set(m.question_id, m.id));
// Fetch partner's responses to check if they've answered
const { data: partnerResponses } = await supabase
.from("responses")
.select("question_id")
.eq("couple_id", coupleId)
.neq("user_id", userId)
.in("question_id", questionIds);
// Create a set of question IDs that partner has answered
const partnerAnsweredSet = new Set<string>(
partnerResponses?.map((r) => r.question_id) || []
);
// Transform responses with match and partner info
const transformedResponses: ResponseWithQuestion[] = responses
.filter((r) => {
// Supabase returns single relations as objects, but TypeScript may infer arrays
const question = r.question as any;
return question && question.pack;
})
.map((r) => {
// Cast to any to handle Supabase's nested select typing
const question = r.question as any;
const pack = question.pack as any;
return {
id: r.id,
question_id: r.question_id,
answer: r.answer as AnswerType,
response_data: (r as any).response_data as ResponseData | null,
created_at: r.created_at,
question: {
id: question.id,
text: question.text,
partner_text: question.partner_text,
intensity: question.intensity,
pack_id: question.pack_id,
question_type: question.question_type,
config: question.config ?? null,
created_at: question.created_at || "",
pack: {
id: pack.id,
name: pack.name,
icon: pack.icon,
},
},
has_match: matchMap.has(r.question_id),
match_id: matchMap.get(r.question_id),
partner_answered: partnerAnsweredSet.has(r.question_id),
};
});
const transformedResponses = responses;
set((state) => ({
responses: refresh
@@ -230,21 +123,12 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
confirmDelete = false,
responseData?: ResponseData | null
): Promise<UpdateResponseResult> => {
const { data, error } = await invokeWithAuthRetry("update-response", {
body: {
question_id: questionId,
try {
const result = await apiClient.patch<UpdateResponseResult>(`/v1/responses/${questionId}`, {
new_answer: newAnswer,
confirm_delete_match: confirmDelete,
response_data: responseData,
},
});
if (error) {
console.error("Error updating response:", error);
return { success: false, error: error.message || "Failed to update response" };
}
const result = data as UpdateResponseResult;
});
// If update was successful (not just requiring confirmation), update local state
if (result.success && !result.requires_confirmation) {
@@ -285,7 +169,11 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
}
}
return result;
return result;
} catch (error) {
console.error("Error updating response:", error);
return { success: false, error: error instanceof Error ? error.message : "Failed to update response" };
}
},
setGroupBy: (groupBy: GroupByOption) => {
+3 -10
View File
@@ -1,5 +1,5 @@
import { create } from "zustand";
import { supabase } from "../lib/supabase";
import { apiClient } from "../lib/apiClient";
import { useAuthStore } from "./authStore";
export interface CoupleStreak {
@@ -39,15 +39,8 @@ export const useStreakStore = create<StreakState>((set) => ({
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 });
const { streak } = await apiClient.get<{streak: CoupleStreak | null}>("/v1/me/streak");
set({ streak, isLoading: false });
} catch (error) {
console.error("Error fetching streak:", error);
set({ error: "Failed to load streak", isLoading: false });
+43 -22
View File
@@ -1,5 +1,5 @@
import { create } from "zustand";
import { supabase } from "../lib/supabase";
import { accountOperationsApi } from "../lib/accountOperationsApi";
import revenueCatService, {
SubscriptionState,
PurchasesPackage,
@@ -37,6 +37,28 @@ const defaultSubscription: SubscriptionState = {
willRenew: false,
};
let subscriptionSyncInFlight: Promise<void> | null = null;
async function syncSubscriptionWithServer(): Promise<void> {
if (!useAuthStore.getState().user) return;
if (!subscriptionSyncInFlight) {
subscriptionSyncInFlight = (async () => {
await accountOperationsApi.syncSubscription();
await useAuthStore.getState().fetchUser();
})();
}
const activeSync = subscriptionSyncInFlight;
try {
await activeSync;
} finally {
if (subscriptionSyncInFlight === activeSync) {
subscriptionSyncInFlight = null;
}
}
}
export const useSubscriptionStore = create<SubscriptionStoreState>((set, get) => ({
subscription: defaultSubscription,
offerings: null,
@@ -64,7 +86,9 @@ export const useSubscriptionStore = create<SubscriptionStoreState>((set, get) =>
// Update user's is_premium in auth store if changed
const currentUser = useAuthStore.getState().user;
if (currentUser && currentUser.is_premium !== subscription.isProUser) {
useAuthStore.getState().fetchUser();
void syncSubscriptionWithServer().catch((error) => {
console.error("Error syncing updated subscription:", error);
});
}
}
);
@@ -135,8 +159,13 @@ export const useSubscriptionStore = create<SubscriptionStoreState>((set, get) =>
const subscription = revenueCatService.parseCustomerInfo(customerInfo);
set({ subscription, isPurchasing: false });
// Refresh user profile to get updated is_premium
await useAuthStore.getState().fetchUser();
// The purchase succeeded even if reconciliation is temporarily unavailable.
// Never report a failed purchase here, which could encourage a duplicate charge.
try {
await syncSubscriptionWithServer();
} catch (error) {
console.error("Error syncing subscription after purchase:", error);
}
return { success: true };
} catch (error: any) {
@@ -166,8 +195,11 @@ export const useSubscriptionStore = create<SubscriptionStoreState>((set, get) =>
const subscription = revenueCatService.parseCustomerInfo(customerInfo);
set({ subscription, isPurchasing: false });
// Refresh user profile
await useAuthStore.getState().fetchUser();
try {
await syncSubscriptionWithServer();
} catch (error) {
console.error("Error syncing subscription after restore:", error);
}
return subscription.isProUser;
} catch (error) {
@@ -177,6 +209,8 @@ export const useSubscriptionStore = create<SubscriptionStoreState>((set, get) =>
},
refreshSubscriptionStatus: async () => {
if (!revenueCatService.isInitialized()) return;
try {
const customerInfo = await revenueCatService.getCustomerInfo();
const subscription = revenueCatService.parseCustomerInfo(customerInfo);
@@ -187,23 +221,10 @@ export const useSubscriptionStore = create<SubscriptionStoreState>((set, get) =>
if (currentUser && currentUser.is_premium !== subscription.isProUser) {
console.log("Syncing subscription status with server...");
// Get the session token to pass to the function
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) {
console.error("No session available for sync");
return;
}
const { error } = await supabase.functions.invoke("sync-subscription", {
headers: {
Authorization: `Bearer ${session.access_token}`,
},
});
if (error) {
try {
await syncSubscriptionWithServer();
} catch (error) {
console.error("Error syncing subscription:", error);
} else {
// Refresh user to get updated is_premium
await useAuthStore.getState().fetchUser();
}
}
} catch (error) {
+2 -1
View File
@@ -3,5 +3,6 @@
// env vars are available when supabase.ts is imported
process.env.EXPO_PUBLIC_SUPABASE_URL = 'https://example.supabase.co';
process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY = 'test-anon-key';
process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY = 'test-anon-key';
process.env.EXPO_PUBLIC_API_URL = 'http://127.0.0.1:3003';
process.env.EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID = 'pro';