mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
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:
1 parent
01bb8225ae
commit
006761d3b0
294 files changed
+16259
-6138
No files matched your search
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user