278 lines
7.9 KiB
TypeScript
278 lines
7.9 KiB
TypeScript
import {
|
|
BackendApiError,
|
|
BackendErrorCode,
|
|
BillingSummary,
|
|
HealthCheckResponse,
|
|
PurchaseProductId,
|
|
ScanPlantResponse,
|
|
SemanticSearchResponse,
|
|
ServiceHealthResponse,
|
|
SimulatedWebhookEvent,
|
|
SimulatePurchaseResponse,
|
|
SimulateWebhookResponse,
|
|
} from './contracts';
|
|
import { getAuthToken } from './userIdentityService';
|
|
import { mockBackendService } from './mockBackendService';
|
|
import { CareInfo, Language } from '../../types';
|
|
|
|
const BACKEND_BASE_URL = (process.env.EXPO_PUBLIC_BACKEND_URL || process.env.EXPO_PUBLIC_PAYMENT_SERVER_URL || '').trim();
|
|
const REQUEST_TIMEOUT_MS = 15000;
|
|
|
|
const mapHttpStatusToErrorCode = (status: number): BackendErrorCode => {
|
|
if (status === 400) return 'BAD_REQUEST';
|
|
if (status === 401 || status === 403) return 'UNAUTHORIZED';
|
|
if (status === 402) return 'INSUFFICIENT_CREDITS';
|
|
if (status === 408 || status === 504) return 'TIMEOUT';
|
|
return 'PROVIDER_ERROR';
|
|
};
|
|
|
|
const buildBackendUrl = (path: string): string => {
|
|
const base = BACKEND_BASE_URL.replace(/\/$/, '');
|
|
return `${base}${path}`;
|
|
};
|
|
|
|
const parseMaybeJson = (value: string): Record<string, unknown> | null => {
|
|
if (!value) return null;
|
|
try {
|
|
return JSON.parse(value) as Record<string, unknown>;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const makeRequest = async <T,>(
|
|
path: string,
|
|
options: {
|
|
method: 'GET' | 'POST';
|
|
token: string;
|
|
idempotencyKey?: string;
|
|
body?: Record<string, unknown>;
|
|
},
|
|
): Promise<T> => {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
|
|
try {
|
|
const headers: Record<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${options.token}`,
|
|
};
|
|
if (options.idempotencyKey) {
|
|
headers['Idempotency-Key'] = options.idempotencyKey;
|
|
}
|
|
|
|
const response = await fetch(buildBackendUrl(path), {
|
|
method: options.method,
|
|
headers,
|
|
body: options.body ? JSON.stringify(options.body) : undefined,
|
|
signal: controller.signal,
|
|
});
|
|
|
|
const rawText = await response.text();
|
|
const jsonPayload = parseMaybeJson(rawText);
|
|
|
|
if (!response.ok) {
|
|
const payloadCode = jsonPayload?.code;
|
|
const code = typeof payloadCode === 'string'
|
|
? payloadCode as BackendErrorCode
|
|
: mapHttpStatusToErrorCode(response.status);
|
|
const payloadMessage = jsonPayload?.message;
|
|
const message = typeof payloadMessage === 'string'
|
|
? payloadMessage
|
|
: `Backend request failed with status ${response.status}.`;
|
|
throw new BackendApiError(code, message, response.status, jsonPayload || undefined);
|
|
}
|
|
|
|
if (jsonPayload == null) {
|
|
throw new BackendApiError('PROVIDER_ERROR', 'Backend returned invalid JSON.', 502);
|
|
}
|
|
|
|
return jsonPayload as T;
|
|
} catch (error) {
|
|
if (error instanceof BackendApiError) throw error;
|
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
throw new BackendApiError('TIMEOUT', 'Backend request timed out.', 408);
|
|
}
|
|
throw new BackendApiError('PROVIDER_ERROR', 'Backend request failed unexpectedly.', 500, {
|
|
detail: error instanceof Error ? error.message : String(error),
|
|
});
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
};
|
|
|
|
export const backendApiClient = {
|
|
getServiceHealth: async (): Promise<ServiceHealthResponse> => {
|
|
if (!BACKEND_BASE_URL) {
|
|
return {
|
|
ok: true,
|
|
uptimeSec: 0,
|
|
timestamp: new Date().toISOString(),
|
|
openAiConfigured: Boolean(process.env.EXPO_PUBLIC_OPENAI_API_KEY),
|
|
dbReady: true,
|
|
dbPath: 'in-app-mock-backend',
|
|
stripeConfigured: Boolean(process.env.EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY),
|
|
scanModel: (process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5').trim(),
|
|
healthModel: (process.env.EXPO_PUBLIC_OPENAI_HEALTH_MODEL || process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5').trim(),
|
|
};
|
|
}
|
|
|
|
const token = await getAuthToken();
|
|
return makeRequest<ServiceHealthResponse>('/health', {
|
|
method: 'GET',
|
|
token,
|
|
});
|
|
},
|
|
|
|
getBillingSummary: async (): Promise<BillingSummary> => {
|
|
const token = await getAuthToken();
|
|
if (!BACKEND_BASE_URL) {
|
|
return mockBackendService.getBillingSummary(token);
|
|
}
|
|
|
|
return makeRequest<BillingSummary>('/v1/billing/summary', {
|
|
method: 'GET',
|
|
token,
|
|
});
|
|
},
|
|
|
|
scanPlant: async (params: {
|
|
idempotencyKey: string;
|
|
imageUri: string;
|
|
language: Language;
|
|
}): Promise<ScanPlantResponse> => {
|
|
const token = await getAuthToken();
|
|
if (!BACKEND_BASE_URL) {
|
|
return mockBackendService.scanPlant({
|
|
userId: token,
|
|
idempotencyKey: params.idempotencyKey,
|
|
imageUri: params.imageUri,
|
|
language: params.language,
|
|
});
|
|
}
|
|
|
|
return makeRequest<ScanPlantResponse>('/v1/scan', {
|
|
method: 'POST',
|
|
token,
|
|
idempotencyKey: params.idempotencyKey,
|
|
body: {
|
|
imageUri: params.imageUri,
|
|
language: params.language,
|
|
},
|
|
});
|
|
},
|
|
|
|
semanticSearch: async (params: {
|
|
idempotencyKey: string;
|
|
query: string;
|
|
language: Language;
|
|
}): Promise<SemanticSearchResponse> => {
|
|
const token = await getAuthToken();
|
|
if (!BACKEND_BASE_URL) {
|
|
return mockBackendService.semanticSearch({
|
|
userId: token,
|
|
idempotencyKey: params.idempotencyKey,
|
|
query: params.query,
|
|
language: params.language,
|
|
});
|
|
}
|
|
|
|
return makeRequest<SemanticSearchResponse>('/v1/search/semantic', {
|
|
method: 'POST',
|
|
token,
|
|
idempotencyKey: params.idempotencyKey,
|
|
body: {
|
|
query: params.query,
|
|
language: params.language,
|
|
},
|
|
});
|
|
},
|
|
|
|
runHealthCheck: async (params: {
|
|
idempotencyKey: string;
|
|
imageUri: string;
|
|
language: Language;
|
|
plantContext?: {
|
|
name: string;
|
|
botanicalName: string;
|
|
careInfo: CareInfo;
|
|
description?: string;
|
|
};
|
|
}): Promise<HealthCheckResponse> => {
|
|
const token = await getAuthToken();
|
|
if (!BACKEND_BASE_URL) {
|
|
return mockBackendService.healthCheck({
|
|
userId: token,
|
|
idempotencyKey: params.idempotencyKey,
|
|
imageUri: params.imageUri,
|
|
language: params.language,
|
|
plantContext: params.plantContext,
|
|
});
|
|
}
|
|
|
|
return makeRequest<HealthCheckResponse>('/v1/health-check', {
|
|
method: 'POST',
|
|
token,
|
|
idempotencyKey: params.idempotencyKey,
|
|
body: {
|
|
imageUri: params.imageUri,
|
|
language: params.language,
|
|
plantContext: params.plantContext,
|
|
},
|
|
});
|
|
},
|
|
|
|
simulatePurchase: async (params: {
|
|
idempotencyKey: string;
|
|
productId: PurchaseProductId;
|
|
}): Promise<SimulatePurchaseResponse> => {
|
|
const token = await getAuthToken();
|
|
if (!BACKEND_BASE_URL) {
|
|
return mockBackendService.simulatePurchase({
|
|
userId: token,
|
|
idempotencyKey: params.idempotencyKey,
|
|
productId: params.productId,
|
|
});
|
|
}
|
|
|
|
return makeRequest<SimulatePurchaseResponse>('/v1/billing/simulate-purchase', {
|
|
method: 'POST',
|
|
token,
|
|
idempotencyKey: params.idempotencyKey,
|
|
body: {
|
|
productId: params.productId,
|
|
},
|
|
});
|
|
},
|
|
|
|
simulateWebhook: async (params: {
|
|
idempotencyKey: string;
|
|
event: SimulatedWebhookEvent;
|
|
payload?: { credits?: number };
|
|
}): Promise<SimulateWebhookResponse> => {
|
|
const token = await getAuthToken();
|
|
if (!BACKEND_BASE_URL) {
|
|
return mockBackendService.simulateWebhook({
|
|
userId: token,
|
|
idempotencyKey: params.idempotencyKey,
|
|
event: params.event,
|
|
payload: params.payload,
|
|
});
|
|
}
|
|
|
|
return makeRequest<SimulateWebhookResponse>('/v1/billing/simulate-webhook', {
|
|
method: 'POST',
|
|
token,
|
|
idempotencyKey: params.idempotencyKey,
|
|
body: {
|
|
event: params.event,
|
|
payload: params.payload || {},
|
|
},
|
|
});
|
|
},
|
|
};
|
|
|
|
export const isInsufficientCreditsError = (error: unknown): boolean => {
|
|
return error instanceof BackendApiError && error.code === 'INSUFFICIENT_CREDITS';
|
|
};
|