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

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