Greenlens/services/backend/backendApiClient.ts

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';
};