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