feat: implement plant health scanning functionality with backend integration and UI support

This commit is contained in:
2026-04-08 19:34:43 +02:00
parent d0a13fa4f0
commit de8130686a
15 changed files with 232 additions and 128 deletions

View File

@@ -1,25 +1,25 @@
import {
BackendApiError,
BackendErrorCode,
BillingSummary,
HealthCheckResponse,
PurchaseProductId,
RevenueCatCustomerInfo,
RevenueCatSyncSource,
ScanPlantResponse,
SemanticSearchResponse,
ServiceHealthResponse,
SimulatedWebhookEvent,
SimulatePurchaseResponse,
SimulateWebhookResponse,
SyncRevenueCatStateResponse,
} from './contracts';
import { getAuthToken } from './userIdentityService';
import { mockBackendService } from './mockBackendService';
import { CareInfo, Language } from '../../types';
import { getConfiguredBackendRootUrl } from '../../utils/backendUrl';
const REQUEST_TIMEOUT_MS = 15000;
import {
BackendApiError,
BackendErrorCode,
BillingSummary,
HealthCheckResponse,
PurchaseProductId,
RevenueCatCustomerInfo,
RevenueCatSyncSource,
ScanPlantResponse,
SemanticSearchResponse,
ServiceHealthResponse,
SimulatedWebhookEvent,
SimulatePurchaseResponse,
SimulateWebhookResponse,
SyncRevenueCatStateResponse,
} from './contracts';
import { getAuthToken } from './userIdentityService';
import { mockBackendService } from './mockBackendService';
import { CareInfo, Language } from '../../types';
import { getConfiguredBackendRootUrl } from '../../utils/backendUrl';
const REQUEST_TIMEOUT_MS = 60000;
const mapHttpStatusToErrorCode = (status: number): BackendErrorCode => {
if (status === 400) return 'BAD_REQUEST';
@@ -29,12 +29,12 @@ const mapHttpStatusToErrorCode = (status: number): BackendErrorCode => {
return 'PROVIDER_ERROR';
};
const buildBackendUrl = (path: string): string => {
const backendBaseUrl = getConfiguredBackendRootUrl();
if (!backendBaseUrl) return path;
const base = backendBaseUrl.replace(/\/$/, '');
return `${base}${path}`;
};
const buildBackendUrl = (path: string): string => {
const backendBaseUrl = getConfiguredBackendRootUrl();
if (!backendBaseUrl) return path;
const base = backendBaseUrl.replace(/\/$/, '');
return `${base}${path}`;
};
const parseMaybeJson = (value: string): Record<string, unknown> | null => {
if (!value) return null;
@@ -107,18 +107,18 @@ const makeRequest = async <T,>(
};
export const backendApiClient = {
getServiceHealth: async (): Promise<ServiceHealthResponse> => {
if (!getConfiguredBackendRootUrl()) {
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',
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(),
};
getServiceHealth: async (): Promise<ServiceHealthResponse> => {
if (!getConfiguredBackendRootUrl()) {
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',
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();
@@ -128,49 +128,49 @@ export const backendApiClient = {
});
},
getBillingSummary: async (): Promise<BillingSummary> => {
const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) {
return mockBackendService.getBillingSummary(token);
}
getBillingSummary: async (): Promise<BillingSummary> => {
const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) {
return mockBackendService.getBillingSummary(token);
}
return makeRequest<BillingSummary>('/v1/billing/summary', {
method: 'GET',
token,
});
},
syncRevenueCatState: async (params: {
customerInfo: RevenueCatCustomerInfo;
source?: RevenueCatSyncSource;
}): Promise<SyncRevenueCatStateResponse> => {
const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) {
return mockBackendService.syncRevenueCatState({
userId: token,
customerInfo: params.customerInfo,
source: params.source,
});
}
return makeRequest<SyncRevenueCatStateResponse>('/v1/billing/sync-revenuecat', {
method: 'POST',
token,
body: {
customerInfo: params.customerInfo,
source: params.source,
},
});
},
scanPlant: async (params: {
return makeRequest<BillingSummary>('/v1/billing/summary', {
method: 'GET',
token,
});
},
syncRevenueCatState: async (params: {
customerInfo: RevenueCatCustomerInfo;
source?: RevenueCatSyncSource;
}): Promise<SyncRevenueCatStateResponse> => {
const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) {
return mockBackendService.syncRevenueCatState({
userId: token,
customerInfo: params.customerInfo,
source: params.source,
});
}
return makeRequest<SyncRevenueCatStateResponse>('/v1/billing/sync-revenuecat', {
method: 'POST',
token,
body: {
customerInfo: params.customerInfo,
source: params.source,
},
});
},
scanPlant: async (params: {
idempotencyKey: string;
imageUri: string;
language: Language;
}): Promise<ScanPlantResponse> => {
const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) {
return mockBackendService.scanPlant({
}): Promise<ScanPlantResponse> => {
const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) {
return mockBackendService.scanPlant({
userId: token,
idempotencyKey: params.idempotencyKey,
imageUri: params.imageUri,
@@ -193,10 +193,10 @@ export const backendApiClient = {
idempotencyKey: string;
query: string;
language: Language;
}): Promise<SemanticSearchResponse> => {
const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) {
return mockBackendService.semanticSearch({
}): Promise<SemanticSearchResponse> => {
const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) {
return mockBackendService.semanticSearch({
userId: token,
idempotencyKey: params.idempotencyKey,
query: params.query,
@@ -225,10 +225,10 @@ export const backendApiClient = {
careInfo: CareInfo;
description?: string;
};
}): Promise<HealthCheckResponse> => {
const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) {
return mockBackendService.healthCheck({
}): Promise<HealthCheckResponse> => {
const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) {
return mockBackendService.healthCheck({
userId: token,
idempotencyKey: params.idempotencyKey,
imageUri: params.imageUri,
@@ -252,10 +252,10 @@ export const backendApiClient = {
simulatePurchase: async (params: {
idempotencyKey: string;
productId: PurchaseProductId;
}): Promise<SimulatePurchaseResponse> => {
const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) {
return mockBackendService.simulatePurchase({
}): Promise<SimulatePurchaseResponse> => {
const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) {
return mockBackendService.simulatePurchase({
userId: token,
idempotencyKey: params.idempotencyKey,
productId: params.productId,
@@ -276,10 +276,10 @@ export const backendApiClient = {
idempotencyKey: string;
event: SimulatedWebhookEvent;
payload?: { credits?: number };
}): Promise<SimulateWebhookResponse> => {
const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) {
return mockBackendService.simulateWebhook({
}): Promise<SimulateWebhookResponse> => {
const token = await getAuthToken();
if (!getConfiguredBackendRootUrl()) {
return mockBackendService.simulateWebhook({
userId: token,
idempotencyKey: params.idempotencyKey,
event: params.event,
@@ -304,8 +304,9 @@ export const isInsufficientCreditsError = (error: unknown): boolean => {
};
export const isNetworkError = (error: unknown): boolean => {
return (
error instanceof BackendApiError &&
(error.code === 'NETWORK_ERROR' || error.code === 'TIMEOUT')
);
return error instanceof BackendApiError && error.code === 'NETWORK_ERROR';
};
export const isTimeoutError = (error: unknown): boolean => {
return error instanceof BackendApiError && error.code === 'TIMEOUT';
};