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

+13
View File
@@ -0,0 +1,13 @@
import React, { useEffect, useState } from 'react';
import { Image, type ImageProps } from 'expo-image';
import { getCachedSignedUrl } from '../lib/imageCache';
export function MediaImage({ reference, ...props }: Omit<ImageProps, 'source'> & { reference: string }) {
const [uri, setUri] = useState<string | null>(reference.startsWith('media:') ? null : reference);
useEffect(() => {
let active = true;
void getCachedSignedUrl(reference, 'avatar').then(value => { if (active) setUri(value); });
return () => { active = false; };
}, [reference]);
return <Image {...props} source={uri ? { uri } : undefined} />;
}
+2 -9
View File
@@ -18,7 +18,7 @@ import Animated, {
} from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import { supabase } from "../lib/supabase";
import { appDataApi } from "../lib/appDataApi";
import { getPackIconName } from "../lib/packIcons";
import { colors, gradients, spacing, radius, typography } from "../theme";
@@ -84,14 +84,7 @@ export function PackTeaser({
const fetchQuestions = async () => {
setIsLoading(true);
try {
const { data, error } = await supabase.rpc("get_pack_teaser_questions", {
target_pack_id: packId,
});
if (error) {
console.error("Error fetching teaser questions:", error);
return;
}
const { questions: data } = await appDataApi.packTeaser(packId);
// Take only the first 3 questions for the teaser
const previewQuestions = (data || [])
@@ -1,11 +1,11 @@
import React from 'react';
import { StyleSheet, View, Text, TouchableOpacity } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Image } from 'expo-image';
import { router } from 'expo-router';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { colors, spacing, typography, radius } from '../../theme';
import type { Profile, Couple } from '@/types';
import { MediaImage } from '../MediaImage';
interface CompactHeaderProps {
user: Profile | null;
@@ -46,8 +46,8 @@ export function CompactHeader({
partner ? (
<View style={styles.partnerBadge}>
{partner.avatar_url ? (
<Image
source={{ uri: partner.avatar_url }}
<MediaImage
reference={partner.avatar_url}
style={styles.partnerAvatar}
cachePolicy="disk"
contentFit="cover"
@@ -15,9 +15,8 @@ import {
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import * as ImagePicker from 'expo-image-picker';
import * as FileSystem from 'expo-file-system';
import { decode } from 'base64-arraybuffer';
import { supabase } from '../../lib/supabase';
import { uploadMedia } from '../../lib/mediaApi';
import { profileSettingsApi } from '../../lib/profileSettingsApi';
import { getDeviceInfo } from '../../lib/deviceInfo';
import { useAuthStore } from '../../store';
import { colors, gradients, spacing, radius, typography } from '../../theme';
@@ -72,44 +71,20 @@ export function FeedbackModal({ visible, onClose }: FeedbackModalProps) {
}
};
const uploadScreenshot = async (uri: string, userId: string): Promise<string> => {
let fileBody;
let ext = 'jpg';
const uploadScreenshot = async (uri: string, _userId: string): Promise<string> => {
let ext = 'jpg';
if (Platform.OS === 'web') {
const response = await fetch(uri);
const blob = await response.blob();
fileBody = blob;
if (blob.type === 'image/png') ext = 'png';
else if (blob.type === 'image/jpeg' || blob.type === 'image/jpg') ext = 'jpg';
else if (blob.type === 'image/gif') ext = 'gif';
else if (blob.type === 'image/webp') ext = 'webp';
} else {
const base64 = await FileSystem.readAsStringAsync(uri, {
encoding: FileSystem.EncodingType.Base64,
});
fileBody = decode(base64);
ext = uri.split('.').pop()?.toLowerCase() || 'jpg';
}
const fileName = `${userId}/${Date.now()}_${Math.random().toString(36).substring(7)}.${ext}`;
const { error } = await supabase.storage
.from('feedback-screenshots')
.upload(fileName, fileBody, {
contentType: `image/${ext === 'jpg' ? 'jpeg' : ext}`,
upsert: false,
});
if (error) throw error;
// Get signed URL (private bucket)
const { data } = await supabase.storage
.from('feedback-screenshots')
.createSignedUrl(fileName, 60 * 60 * 24 * 365); // 1 year expiry
return data?.signedUrl ?? '';
ext = uri.split('.').pop()?.toLowerCase() || 'jpg';
}
return (await uploadMedia(uri, { kind: 'feedback', mimeType: `image/${ext === 'jpg' ? 'jpeg' : ext}` })).media.id;
};
const handleSubmit = async () => {
@@ -132,24 +107,19 @@ export function FeedbackModal({ visible, onClose }: FeedbackModalProps) {
try {
const deviceInfo = getDeviceInfo();
let screenshotUrl: string | null = null;
let screenshotMediaId: string | null = null;
// Upload screenshot if provided
if (screenshotUri) {
screenshotUrl = await uploadScreenshot(screenshotUri, user.id);
}
// Insert feedback
const { error } = await supabase.from('feedback').insert({
user_id: user.id,
type: feedbackType,
title: title.trim(),
description: description.trim(),
screenshot_url: screenshotUrl,
device_info: deviceInfo,
});
if (error) throw error;
screenshotMediaId = await uploadScreenshot(screenshotUri, user.id);
}
await profileSettingsApi.submitFeedback({
type: feedbackType,
title: title.trim(),
description: description.trim(),
device_info: deviceInfo,
...(screenshotMediaId ? { screenshot_media_id: screenshotMediaId } : {}),
});
Alert.alert('Thank you!', 'Your feedback has been submitted successfully.', [
{ text: 'OK', onPress: handleClose },
@@ -11,7 +11,7 @@ import {
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { supabase } from '../../lib/supabase';
import { profileSettingsApi } from '../../lib/profileSettingsApi';
import { getDeviceInfo } from '../../lib/deviceInfo';
import { useAuthStore } from '../../store';
import { colors, gradients, spacing, radius, typography } from '../../theme';
@@ -70,8 +70,7 @@ export function QuestionFeedbackModal({
const deviceInfo = getDeviceInfo();
const reasonLabel = FEEDBACK_REASONS.find(r => r.id === selectedReason)?.label || selectedReason;
const { error } = await supabase.from('feedback').insert({
user_id: user.id,
await profileSettingsApi.submitFeedback({
type: 'question',
title: `Question Feedback: ${reasonLabel}`,
description: additionalInfo.trim() || `Reported as: ${reasonLabel}`,
@@ -79,8 +78,6 @@ export function QuestionFeedbackModal({
device_info: deviceInfo,
});
if (error) throw error;
Alert.alert('Thank you!', 'Your feedback has been submitted.', [
{ text: 'OK', onPress: handleClose },
]);
@@ -1,6 +1,6 @@
import { useState, useEffect } from "react";
import { View, Text, StyleSheet, Platform, TouchableOpacity } from "react-native";
import { Image } from "expo-image";
import { MediaImage } from "../MediaImage";
import { LinearGradient } from "expo-linear-gradient";
import Animated, {
useSharedValue,
@@ -324,8 +324,8 @@ function AvatarButton({
]}>
<Animated.View style={[styles.avatarBorder, borderAnimatedStyle]}>
{avatarUrl ? (
<Image
source={{ uri: avatarUrl }}
<MediaImage
reference={avatarUrl}
style={styles.avatarImage}
contentFit="cover"
cachePolicy="memory-disk"