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

@@ -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),
};