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
@@ -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'),
|
||||
};
|
||||
|
||||
@@ -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" }),
|
||||
};
|
||||
@@ -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 }),
|
||||
};
|
||||
Executable → Regular
+67
-77
@@ -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",
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`),
|
||||
};
|
||||
@@ -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" }),
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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() });
|
||||
};
|
||||
@@ -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}`);
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
Reference in new issue
Block a user