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

+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;