mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-02 02: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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user