976 lines
32 KiB
TypeScript
976 lines
32 KiB
TypeScript
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
import {
|
|
BackendApiError,
|
|
BillingProvider,
|
|
BillingSummary,
|
|
HealthCheckRequest,
|
|
HealthCheckResponse,
|
|
PlanId,
|
|
PurchaseProductId,
|
|
ScanPlantRequest,
|
|
ScanPlantResponse,
|
|
SemanticSearchRequest,
|
|
SemanticSearchResponse,
|
|
SimulatePurchaseRequest,
|
|
SimulatePurchaseResponse,
|
|
SimulateWebhookRequest,
|
|
SimulateWebhookResponse,
|
|
isBackendApiError,
|
|
} from './contracts';
|
|
import { getMockPlantByImage, searchMockCatalog } from './mockCatalog';
|
|
import { openAiScanService } from './openAiScanService';
|
|
import { IdentificationResult, PlantHealthCheck } from '../../types';
|
|
|
|
const MOCK_ACCOUNT_STORE_KEY = 'greenlens_mock_backend_accounts_v1';
|
|
const MOCK_IDEMPOTENCY_STORE_KEY = 'greenlens_mock_backend_idempotency_v1';
|
|
|
|
const FREE_MONTHLY_CREDITS = 15;
|
|
const GUEST_TRIAL_CREDITS = 5;
|
|
const PRO_MONTHLY_CREDITS = 250;
|
|
|
|
const SCAN_PRIMARY_COST = 1;
|
|
const SCAN_REVIEW_COST = 1;
|
|
const SEMANTIC_SEARCH_COST = 2;
|
|
const HEALTH_CHECK_COST = 2;
|
|
|
|
const LOW_CONFIDENCE_REVIEW_THRESHOLD = 0.8;
|
|
const FREE_SIMULATED_DELAY_MS = 1100;
|
|
const PRO_SIMULATED_DELAY_MS = 280;
|
|
|
|
const TOPUP_DEFAULT_CREDITS = 60;
|
|
|
|
const TOPUP_CREDITS_BY_PRODUCT: Record<PurchaseProductId, number> = {
|
|
monthly_pro: 0,
|
|
yearly_pro: 0,
|
|
topup_small: 25,
|
|
topup_medium: 75,
|
|
topup_large: 200,
|
|
};
|
|
|
|
interface MockAccountRecord {
|
|
userId: string;
|
|
plan: PlanId;
|
|
provider: BillingProvider;
|
|
cycleStartedAt: string;
|
|
cycleEndsAt: string;
|
|
monthlyAllowance: number;
|
|
usedThisCycle: number;
|
|
topupBalance: number;
|
|
renewsAt: string | null;
|
|
updatedAt: string;
|
|
}
|
|
|
|
interface IdempotencyRecord {
|
|
response: unknown;
|
|
createdAt: string;
|
|
}
|
|
|
|
type AccountStore = Record<string, MockAccountRecord>;
|
|
type IdempotencyStore = Record<string, IdempotencyRecord>;
|
|
|
|
const userLocks = new Map<string, Promise<void>>();
|
|
|
|
const nowIso = (): string => new Date().toISOString();
|
|
|
|
const startOfUtcMonth = (date: Date): Date => {
|
|
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1, 0, 0, 0, 0));
|
|
};
|
|
|
|
const addUtcMonths = (date: Date, months: number): Date => {
|
|
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + months, 1, 0, 0, 0, 0));
|
|
};
|
|
|
|
const addDays = (date: Date, days: number): Date => {
|
|
const result = new Date(date.getTime());
|
|
result.setUTCDate(result.getUTCDate() + days);
|
|
return result;
|
|
};
|
|
|
|
const getCycleBounds = (now: Date) => {
|
|
const cycleStartedAt = startOfUtcMonth(now);
|
|
const cycleEndsAt = addUtcMonths(cycleStartedAt, 1);
|
|
return { cycleStartedAt, cycleEndsAt };
|
|
};
|
|
|
|
const getMonthlyAllowanceForPlan = (plan: PlanId, userId?: string): number => {
|
|
if (userId === 'guest') return GUEST_TRIAL_CREDITS;
|
|
return plan === 'pro' ? PRO_MONTHLY_CREDITS : FREE_MONTHLY_CREDITS;
|
|
};
|
|
|
|
const getSimulatedDelay = (plan: PlanId): number => {
|
|
return plan === 'pro' ? PRO_SIMULATED_DELAY_MS : FREE_SIMULATED_DELAY_MS;
|
|
};
|
|
|
|
const sleep = async (ms: number): Promise<void> => {
|
|
if (ms <= 0) return;
|
|
await new Promise(resolve => setTimeout(resolve, ms));
|
|
};
|
|
|
|
const withUserLock = async <T>(userId: string, worker: () => Promise<T>): Promise<T> => {
|
|
const previousLock = userLocks.get(userId) || Promise.resolve();
|
|
let releaseLock: () => void = () => {};
|
|
const activeLock = new Promise<void>((resolve) => {
|
|
releaseLock = resolve;
|
|
});
|
|
userLocks.set(userId, activeLock);
|
|
|
|
await previousLock;
|
|
try {
|
|
return await worker();
|
|
} finally {
|
|
releaseLock();
|
|
if (userLocks.get(userId) === activeLock) {
|
|
userLocks.delete(userId);
|
|
}
|
|
}
|
|
};
|
|
|
|
const readJson = async <T,>(key: string, fallbackValue: T): Promise<T> => {
|
|
try {
|
|
const raw = await AsyncStorage.getItem(key);
|
|
if (!raw) return fallbackValue;
|
|
return JSON.parse(raw) as T;
|
|
} catch (error) {
|
|
console.error(`Failed to read mock backend key ${key}`, error);
|
|
return fallbackValue;
|
|
}
|
|
};
|
|
|
|
const writeJson = async <T,>(key: string, value: T): Promise<void> => {
|
|
try {
|
|
await AsyncStorage.setItem(key, JSON.stringify(value));
|
|
} catch (error) {
|
|
console.error(`Failed to write mock backend key ${key}`, error);
|
|
}
|
|
};
|
|
|
|
const loadStores = async (): Promise<{ accounts: AccountStore; idempotency: IdempotencyStore }> => {
|
|
const [accounts, idempotency] = await Promise.all([
|
|
readJson<AccountStore>(MOCK_ACCOUNT_STORE_KEY, {}),
|
|
readJson<IdempotencyStore>(MOCK_IDEMPOTENCY_STORE_KEY, {}),
|
|
]);
|
|
return { accounts, idempotency };
|
|
};
|
|
|
|
const persistStores = async (stores: { accounts: AccountStore; idempotency: IdempotencyStore }): Promise<void> => {
|
|
await Promise.all([
|
|
writeJson(MOCK_ACCOUNT_STORE_KEY, stores.accounts),
|
|
writeJson(MOCK_IDEMPOTENCY_STORE_KEY, stores.idempotency),
|
|
]);
|
|
};
|
|
|
|
const buildDefaultAccount = (userId: string, now: Date): MockAccountRecord => {
|
|
const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now);
|
|
const allowance = getMonthlyAllowanceForPlan('free', userId);
|
|
return {
|
|
userId,
|
|
plan: 'free',
|
|
provider: 'mock',
|
|
cycleStartedAt: cycleStartedAt.toISOString(),
|
|
cycleEndsAt: cycleEndsAt.toISOString(),
|
|
monthlyAllowance: allowance,
|
|
usedThisCycle: 0,
|
|
topupBalance: 0,
|
|
renewsAt: null,
|
|
updatedAt: nowIso(),
|
|
};
|
|
};
|
|
|
|
const alignAccountToCurrentCycle = (account: MockAccountRecord, now: Date): MockAccountRecord => {
|
|
const next = { ...account };
|
|
const expectedMonthlyAllowance = getMonthlyAllowanceForPlan(next.plan, next.userId);
|
|
if (next.monthlyAllowance !== expectedMonthlyAllowance) {
|
|
next.monthlyAllowance = expectedMonthlyAllowance;
|
|
}
|
|
|
|
if (!next.renewsAt && next.plan === 'pro' && next.provider === 'mock') {
|
|
next.renewsAt = addDays(now, 30).toISOString();
|
|
}
|
|
|
|
const cycleEndsAtMs = new Date(next.cycleEndsAt).getTime();
|
|
if (Number.isNaN(cycleEndsAtMs) || now.getTime() >= cycleEndsAtMs) {
|
|
const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now);
|
|
next.cycleStartedAt = cycleStartedAt.toISOString();
|
|
next.cycleEndsAt = cycleEndsAt.toISOString();
|
|
next.usedThisCycle = 0;
|
|
next.monthlyAllowance = expectedMonthlyAllowance;
|
|
}
|
|
|
|
return next;
|
|
};
|
|
|
|
const getOrCreateAccount = (stores: { accounts: AccountStore }, userId: string): MockAccountRecord => {
|
|
const now = new Date();
|
|
const existing = stores.accounts[userId] || buildDefaultAccount(userId, now);
|
|
const aligned = alignAccountToCurrentCycle(existing, now);
|
|
stores.accounts[userId] = aligned;
|
|
return aligned;
|
|
};
|
|
|
|
const getAvailableCredits = (account: MockAccountRecord): number => {
|
|
const monthlyRemaining = Math.max(0, account.monthlyAllowance - account.usedThisCycle);
|
|
return monthlyRemaining + Math.max(0, account.topupBalance);
|
|
};
|
|
|
|
const buildBillingSummary = (account: MockAccountRecord): BillingSummary => {
|
|
return {
|
|
entitlement: {
|
|
plan: account.plan,
|
|
provider: account.provider,
|
|
status: 'active',
|
|
renewsAt: account.renewsAt,
|
|
},
|
|
credits: {
|
|
monthlyAllowance: account.monthlyAllowance,
|
|
usedThisCycle: account.usedThisCycle,
|
|
topupBalance: account.topupBalance,
|
|
available: getAvailableCredits(account),
|
|
cycleStartedAt: account.cycleStartedAt,
|
|
cycleEndsAt: account.cycleEndsAt,
|
|
},
|
|
availableProducts: ['monthly_pro', 'yearly_pro', 'topup_small', 'topup_medium', 'topup_large'],
|
|
};
|
|
};
|
|
|
|
const readIdempotentResponse = <T,>(store: IdempotencyStore, key: string): T | null => {
|
|
const record = store[key];
|
|
if (!record) return null;
|
|
return record.response as T;
|
|
};
|
|
|
|
const writeIdempotentResponse = <T,>(store: IdempotencyStore, key: string, value: T): void => {
|
|
store[key] = {
|
|
response: value,
|
|
createdAt: nowIso(),
|
|
};
|
|
};
|
|
|
|
const consumeCredits = (account: MockAccountRecord, cost: number): number => {
|
|
if (cost <= 0) return 0;
|
|
|
|
const available = getAvailableCredits(account);
|
|
if (available < cost) {
|
|
throw new BackendApiError(
|
|
'INSUFFICIENT_CREDITS',
|
|
`Insufficient credits. Required ${cost}, available ${available}.`,
|
|
402,
|
|
{ required: cost, available },
|
|
);
|
|
}
|
|
|
|
let remaining = cost;
|
|
const monthlyRemaining = Math.max(0, account.monthlyAllowance - account.usedThisCycle);
|
|
if (monthlyRemaining > 0) {
|
|
const monthlyUsage = Math.min(monthlyRemaining, remaining);
|
|
account.usedThisCycle += monthlyUsage;
|
|
remaining -= monthlyUsage;
|
|
}
|
|
|
|
if (remaining > 0 && account.topupBalance > 0) {
|
|
const topupUsage = Math.min(account.topupBalance, remaining);
|
|
account.topupBalance -= topupUsage;
|
|
remaining -= topupUsage;
|
|
}
|
|
|
|
return cost;
|
|
};
|
|
|
|
const consumeCreditsWithIdempotency = (
|
|
account: MockAccountRecord,
|
|
idempotencyStore: IdempotencyStore,
|
|
key: string,
|
|
cost: number,
|
|
): number => {
|
|
const existing = readIdempotentResponse<{ charged: number }>(idempotencyStore, key);
|
|
if (existing) return existing.charged;
|
|
|
|
const charged = consumeCredits(account, cost);
|
|
writeIdempotentResponse(idempotencyStore, key, { charged });
|
|
return charged;
|
|
};
|
|
|
|
const endpointKey = (scope: string, userId: string, idempotencyKey: string): string => {
|
|
return `endpoint:${scope}:${userId}:${idempotencyKey}`;
|
|
};
|
|
|
|
const chargeKey = (scope: string, userId: string, idempotencyKey: string): string => {
|
|
return `charge:${scope}:${userId}:${idempotencyKey}`;
|
|
};
|
|
|
|
const hashString = (value: string): number => {
|
|
let hash = 0;
|
|
for (let i = 0; i < value.length; i += 1) {
|
|
hash = ((hash << 5) - hash + value.charCodeAt(i)) | 0;
|
|
}
|
|
return Math.abs(hash);
|
|
};
|
|
|
|
const clampConfidence = (value: number): number => {
|
|
return Math.max(0.05, Math.min(0.99, Number(value.toFixed(2))));
|
|
};
|
|
|
|
const buildMockHealthCheck = (request: HealthCheckRequest, creditsCharged: number): PlantHealthCheck => {
|
|
const source = [
|
|
request.imageUri,
|
|
request.plantContext?.name || '',
|
|
request.plantContext?.botanicalName || '',
|
|
request.plantContext?.careInfo?.light || '',
|
|
request.plantContext?.careInfo?.temp || '',
|
|
].join('|');
|
|
const hash = hashString(source);
|
|
const score = 40 + (hash % 56);
|
|
|
|
const status: PlantHealthCheck['status'] = score >= 75
|
|
? 'healthy'
|
|
: score >= 55
|
|
? 'watch'
|
|
: 'critical';
|
|
|
|
const confidenceBase = 0.45 + ((hash % 20) / 100);
|
|
const confidenceMid = 0.35 + (((hash >> 2) % 20) / 100);
|
|
const confidenceLow = 0.25 + (((hash >> 4) % 20) / 100);
|
|
|
|
if (request.language === 'de') {
|
|
const likelyIssues = status === 'critical'
|
|
? [
|
|
{
|
|
title: 'Moegliche Ueberwaesserung',
|
|
confidence: clampConfidence(confidenceBase + 0.22),
|
|
details: 'Gelbe, weiche Blaetter koennen auf zu nasse Erde hindeuten.',
|
|
},
|
|
{
|
|
title: 'Wurzelstress',
|
|
confidence: clampConfidence(confidenceMid + 0.15),
|
|
details: 'Pruefe, ob das Substrat verdichtet ist oder unangenehm riecht.',
|
|
},
|
|
{
|
|
title: 'Lichtmangel',
|
|
confidence: clampConfidence(confidenceLow + 0.1),
|
|
details: 'Zu wenig Licht kann zu Vergilbung und schwaecherem Wuchs fuehren.',
|
|
},
|
|
]
|
|
: status === 'watch'
|
|
? [
|
|
{
|
|
title: 'Leichter Naehrstoffmangel',
|
|
confidence: clampConfidence(confidenceBase),
|
|
details: 'Ein Teil der Vergilbung kann durch fehlende Naehrstoffe entstehen.',
|
|
},
|
|
{
|
|
title: 'Unregelmaessiges Giessen',
|
|
confidence: clampConfidence(confidenceMid),
|
|
details: 'Zu grosse Schwankungen zwischen trocken und sehr nass belasten die Pflanze.',
|
|
},
|
|
{
|
|
title: 'Standortstress',
|
|
confidence: clampConfidence(confidenceLow),
|
|
details: 'Zugluft oder haeufige Standortwechsel koennen Blattreaktionen ausloesen.',
|
|
},
|
|
]
|
|
: [
|
|
{
|
|
title: 'Leichte Lichtanpassung noetig',
|
|
confidence: clampConfidence(confidenceBase - 0.1),
|
|
details: 'Einige Blaetter zeigen milde Stressanzeichen, insgesamt wirkt die Pflanze stabil.',
|
|
},
|
|
{
|
|
title: 'Naechsten Duengertermin beobachten',
|
|
confidence: clampConfidence(confidenceMid - 0.1),
|
|
details: 'Bei weiterem Vergilben Duengung in kleiner Dosis einplanen.',
|
|
},
|
|
{
|
|
title: 'Normale Blattalterung',
|
|
confidence: clampConfidence(confidenceLow - 0.05),
|
|
details: 'Aeltere untere Blaetter duerfen gelegentlich gelb werden.',
|
|
},
|
|
];
|
|
|
|
const actionsNow = status === 'critical'
|
|
? [
|
|
'Giessen fuer 3-5 Tage pausieren und Feuchtigkeit tief im Topf pruefen.',
|
|
'Gelbe oder matschige Blaetter sauber entfernen.',
|
|
'Topf auf gute Drainage pruefen; stehendes Wasser sofort entfernen.',
|
|
]
|
|
: status === 'watch'
|
|
? [
|
|
'Giessrhythmus fuer die naechsten 7 Tage konsistent halten.',
|
|
'Pflanze heller, aber ohne harte Mittagssonne stellen.',
|
|
'Auf Schaedlinge an Blattunterseiten kontrollieren.',
|
|
]
|
|
: [
|
|
'Aktuellen Pflegeplan beibehalten.',
|
|
'Nur bei deutlich trockener Erde giessen.',
|
|
'Gelbe Altblaetter nach Bedarf entfernen.',
|
|
];
|
|
|
|
const plan7Days = status === 'critical'
|
|
? [
|
|
'Tag 1: Feuchtigkeit messen und Uebertopf entleeren.',
|
|
'Tag 3: Blattfarbe und Festigkeit erneut pruefen.',
|
|
'Tag 5: Bei nasser Erde Umtopfen mit luftiger Mischung erwaegen.',
|
|
'Tag 7: Neuen Foto-Health-Check ausfuehren.',
|
|
]
|
|
: [
|
|
'Tag 1: Standort und Lichtdauer notieren.',
|
|
'Tag 3: Leichte Drehung fuer gleichmaessigen Wuchs.',
|
|
'Tag 5: Bodenfeuchte vor Giessen kontrollieren.',
|
|
'Tag 7: Vergleichsfoto erstellen.',
|
|
];
|
|
|
|
return {
|
|
generatedAt: nowIso(),
|
|
overallHealthScore: score,
|
|
status,
|
|
likelyIssues,
|
|
actionsNow,
|
|
plan7Days,
|
|
creditsCharged,
|
|
imageUri: request.imageUri,
|
|
};
|
|
}
|
|
|
|
if (request.language === 'es') {
|
|
const likelyIssues = status === 'critical'
|
|
? [
|
|
{
|
|
title: 'Posible exceso de riego',
|
|
confidence: clampConfidence(confidenceBase + 0.22),
|
|
details: 'Hojas amarillas y blandas pueden indicar demasiada humedad.',
|
|
},
|
|
{
|
|
title: 'Estres de raiz',
|
|
confidence: clampConfidence(confidenceMid + 0.15),
|
|
details: 'Revisa si el sustrato esta compacto o con mal olor.',
|
|
},
|
|
{
|
|
title: 'Falta de luz',
|
|
confidence: clampConfidence(confidenceLow + 0.1),
|
|
details: 'La luz insuficiente puede causar amarilleo y crecimiento lento.',
|
|
},
|
|
]
|
|
: status === 'watch'
|
|
? [
|
|
{
|
|
title: 'Deficit leve de nutrientes',
|
|
confidence: clampConfidence(confidenceBase),
|
|
details: 'Parte del amarilleo puede venir de nutricion insuficiente.',
|
|
},
|
|
{
|
|
title: 'Riego irregular',
|
|
confidence: clampConfidence(confidenceMid),
|
|
details: 'Cambios bruscos entre seco y muy humedo estresan la planta.',
|
|
},
|
|
{
|
|
title: 'Estres de ubicacion',
|
|
confidence: clampConfidence(confidenceLow),
|
|
details: 'Corrientes de aire o cambios frecuentes pueden afectar las hojas.',
|
|
},
|
|
]
|
|
: [
|
|
{
|
|
title: 'Ajuste suave de luz',
|
|
confidence: clampConfidence(confidenceBase - 0.1),
|
|
details: 'Se observan signos leves, pero el estado general es bueno.',
|
|
},
|
|
{
|
|
title: 'Monitorear fertilizacion',
|
|
confidence: clampConfidence(confidenceMid - 0.1),
|
|
details: 'Si continua el amarilleo, aplicar dosis pequena de fertilizante.',
|
|
},
|
|
{
|
|
title: 'Envejecimiento normal',
|
|
confidence: clampConfidence(confidenceLow - 0.05),
|
|
details: 'Hojas inferiores viejas pueden amarillear de forma natural.',
|
|
},
|
|
];
|
|
|
|
const actionsNow = status === 'critical'
|
|
? [
|
|
'Pausar riego 3-5 dias y comprobar humedad profunda.',
|
|
'Retirar hojas amarillas o blandas con herramienta limpia.',
|
|
'Verificar drenaje y eliminar agua acumulada.',
|
|
]
|
|
: status === 'watch'
|
|
? [
|
|
'Mantener riego estable durante 7 dias.',
|
|
'Mover a una zona mas luminosa sin sol fuerte directo.',
|
|
'Revisar plagas en el reverso de las hojas.',
|
|
]
|
|
: [
|
|
'Mantener rutina actual de cuidado.',
|
|
'Regar solo cuando el sustrato este claramente seco.',
|
|
'Retirar hojas amarillas antiguas si hace falta.',
|
|
];
|
|
|
|
const plan7Days = status === 'critical'
|
|
? [
|
|
'Dia 1: Medir humedad y vaciar agua retenida.',
|
|
'Dia 3: Revisar color y firmeza de hojas.',
|
|
'Dia 5: Si sigue muy humedo, considerar trasplante con mezcla aireada.',
|
|
'Dia 7: Repetir health-check con foto nueva.',
|
|
]
|
|
: [
|
|
'Dia 1: Registrar ubicacion y horas de luz.',
|
|
'Dia 3: Girar planta ligeramente para crecimiento uniforme.',
|
|
'Dia 5: Comprobar humedad antes de regar.',
|
|
'Dia 7: Tomar foto de comparacion.',
|
|
];
|
|
|
|
return {
|
|
generatedAt: nowIso(),
|
|
overallHealthScore: score,
|
|
status,
|
|
likelyIssues,
|
|
actionsNow,
|
|
plan7Days,
|
|
creditsCharged,
|
|
imageUri: request.imageUri,
|
|
};
|
|
}
|
|
|
|
const likelyIssues = status === 'critical'
|
|
? [
|
|
{
|
|
title: 'Possible overwatering',
|
|
confidence: clampConfidence(confidenceBase + 0.22),
|
|
details: 'Yellow and soft leaves can indicate excess moisture.',
|
|
},
|
|
{
|
|
title: 'Root stress',
|
|
confidence: clampConfidence(confidenceMid + 0.15),
|
|
details: 'Check if the substrate is compacted or has a sour smell.',
|
|
},
|
|
{
|
|
title: 'Low light stress',
|
|
confidence: clampConfidence(confidenceLow + 0.1),
|
|
details: 'Insufficient light can trigger yellowing and slower growth.',
|
|
},
|
|
]
|
|
: status === 'watch'
|
|
? [
|
|
{
|
|
title: 'Mild nutrient deficiency',
|
|
confidence: clampConfidence(confidenceBase),
|
|
details: 'Part of the yellowing may come from missing nutrients.',
|
|
},
|
|
{
|
|
title: 'Inconsistent watering',
|
|
confidence: clampConfidence(confidenceMid),
|
|
details: 'Large swings between dry and wet can stress foliage.',
|
|
},
|
|
{
|
|
title: 'Placement stress',
|
|
confidence: clampConfidence(confidenceLow),
|
|
details: 'Drafts or frequent location changes can affect leaves.',
|
|
},
|
|
]
|
|
: [
|
|
{
|
|
title: 'Minor light adjustment',
|
|
confidence: clampConfidence(confidenceBase - 0.1),
|
|
details: 'Mild stress signs are present, but overall condition looks stable.',
|
|
},
|
|
{
|
|
title: 'Monitor next feeding',
|
|
confidence: clampConfidence(confidenceMid - 0.1),
|
|
details: 'If yellowing continues, apply a light fertilizer dose.',
|
|
},
|
|
{
|
|
title: 'Normal leaf aging',
|
|
confidence: clampConfidence(confidenceLow - 0.05),
|
|
details: 'Older lower leaves can yellow naturally over time.',
|
|
},
|
|
];
|
|
|
|
const actionsNow = status === 'critical'
|
|
? [
|
|
'Pause watering for 3-5 days and check deep soil moisture.',
|
|
'Remove yellow or mushy leaves with clean tools.',
|
|
'Ensure good drainage and empty standing water.',
|
|
]
|
|
: status === 'watch'
|
|
? [
|
|
'Keep watering schedule stable for 7 days.',
|
|
'Move to brighter indirect light.',
|
|
'Inspect leaf undersides for pests.',
|
|
]
|
|
: [
|
|
'Keep the current care routine.',
|
|
'Water only when soil is clearly dry.',
|
|
'Trim older yellow leaves if needed.',
|
|
];
|
|
|
|
const plan7Days = status === 'critical'
|
|
? [
|
|
'Day 1: Check moisture and remove excess water.',
|
|
'Day 3: Re-check leaf color and firmness.',
|
|
'Day 5: If still soggy, repot into an airy mix.',
|
|
'Day 7: Run another health-check photo.',
|
|
]
|
|
: [
|
|
'Day 1: Note light duration and placement.',
|
|
'Day 3: Rotate plant slightly for even growth.',
|
|
'Day 5: Check soil moisture before watering.',
|
|
'Day 7: Take a comparison photo.',
|
|
];
|
|
|
|
return {
|
|
generatedAt: nowIso(),
|
|
overallHealthScore: score,
|
|
status,
|
|
likelyIssues,
|
|
actionsNow,
|
|
plan7Days,
|
|
creditsCharged,
|
|
imageUri: request.imageUri,
|
|
};
|
|
};
|
|
|
|
export const mockBackendService = {
|
|
getBillingSummary: async (userId: string): Promise<BillingSummary> => {
|
|
return withUserLock(userId, async () => {
|
|
const stores = await loadStores();
|
|
const account = getOrCreateAccount(stores, userId);
|
|
account.updatedAt = nowIso();
|
|
await persistStores(stores);
|
|
return buildBillingSummary(account);
|
|
});
|
|
},
|
|
|
|
scanPlant: async (request: ScanPlantRequest): Promise<ScanPlantResponse> => {
|
|
const { response, simulatedDelayMs } = await withUserLock(request.userId, async () => {
|
|
const stores = await loadStores();
|
|
const account = getOrCreateAccount(stores, request.userId);
|
|
|
|
const idemEndpointKey = endpointKey('scan', request.userId, request.idempotencyKey);
|
|
const cachedResponse = readIdempotentResponse<ScanPlantResponse>(stores.idempotency, idemEndpointKey);
|
|
if (cachedResponse) {
|
|
return {
|
|
response: cachedResponse,
|
|
simulatedDelayMs: getSimulatedDelay(account.plan),
|
|
};
|
|
}
|
|
|
|
let creditsCharged = 0;
|
|
const modelPath: string[] = [];
|
|
|
|
creditsCharged += consumeCreditsWithIdempotency(
|
|
account,
|
|
stores.idempotency,
|
|
chargeKey('scan-primary', request.userId, request.idempotencyKey),
|
|
SCAN_PRIMARY_COST,
|
|
);
|
|
|
|
let usedOpenAi = false;
|
|
let result: IdentificationResult = getMockPlantByImage(request.imageUri, request.language, false);
|
|
|
|
if (openAiScanService.isConfigured()) {
|
|
const openAiPrimary = await openAiScanService.identifyPlant(
|
|
request.imageUri,
|
|
request.language,
|
|
'primary',
|
|
account.plan === 'pro' ? 'pro' : 'free',
|
|
);
|
|
if (openAiPrimary) {
|
|
result = openAiPrimary;
|
|
usedOpenAi = true;
|
|
modelPath.push('openai-primary');
|
|
} else {
|
|
result = getMockPlantByImage(request.imageUri, request.language, false);
|
|
modelPath.push('openai-primary-failed');
|
|
modelPath.push('mock-primary-fallback');
|
|
}
|
|
} else {
|
|
modelPath.push('mock-primary');
|
|
}
|
|
|
|
const shouldReview = result.confidence < LOW_CONFIDENCE_REVIEW_THRESHOLD;
|
|
if (shouldReview && account.plan === 'pro') {
|
|
try {
|
|
creditsCharged += consumeCreditsWithIdempotency(
|
|
account,
|
|
stores.idempotency,
|
|
chargeKey('scan-review', request.userId, request.idempotencyKey),
|
|
SCAN_REVIEW_COST,
|
|
);
|
|
if (usedOpenAi) {
|
|
const openAiReview = await openAiScanService.identifyPlant(
|
|
request.imageUri,
|
|
request.language,
|
|
'review',
|
|
account.plan === 'pro' ? 'pro' : 'free',
|
|
);
|
|
if (openAiReview) {
|
|
result = openAiReview;
|
|
modelPath.push('openai-review');
|
|
} else {
|
|
modelPath.push('openai-review-failed');
|
|
}
|
|
} else {
|
|
result = getMockPlantByImage(request.imageUri, request.language, true);
|
|
modelPath.push('mock-review');
|
|
}
|
|
} catch (error) {
|
|
if (isBackendApiError(error) && error.code === 'INSUFFICIENT_CREDITS') {
|
|
modelPath.push('review-skipped-insufficient-credits');
|
|
} else {
|
|
throw error;
|
|
}
|
|
}
|
|
} else if (shouldReview) {
|
|
modelPath.push('review-skipped-free-plan');
|
|
}
|
|
|
|
account.updatedAt = nowIso();
|
|
const response: ScanPlantResponse = {
|
|
result,
|
|
creditsCharged,
|
|
modelPath,
|
|
billing: buildBillingSummary(account),
|
|
};
|
|
|
|
writeIdempotentResponse(stores.idempotency, idemEndpointKey, response);
|
|
await persistStores(stores);
|
|
|
|
return {
|
|
response,
|
|
simulatedDelayMs: getSimulatedDelay(account.plan),
|
|
};
|
|
});
|
|
|
|
await sleep(simulatedDelayMs);
|
|
return response;
|
|
},
|
|
|
|
semanticSearch: async (request: SemanticSearchRequest): Promise<SemanticSearchResponse> => {
|
|
const { response, simulatedDelayMs } = await withUserLock(request.userId, async () => {
|
|
const stores = await loadStores();
|
|
const account = getOrCreateAccount(stores, request.userId);
|
|
|
|
const idemEndpointKey = endpointKey('semantic-search', request.userId, request.idempotencyKey);
|
|
const cachedResponse = readIdempotentResponse<SemanticSearchResponse>(stores.idempotency, idemEndpointKey);
|
|
if (cachedResponse) {
|
|
return {
|
|
response: cachedResponse,
|
|
simulatedDelayMs: getSimulatedDelay(account.plan),
|
|
};
|
|
}
|
|
|
|
const normalizedQuery = request.query.trim();
|
|
if (!normalizedQuery) {
|
|
const noResultResponse: SemanticSearchResponse = {
|
|
status: 'no_results',
|
|
results: [],
|
|
creditsCharged: 0,
|
|
billing: buildBillingSummary(account),
|
|
};
|
|
writeIdempotentResponse(stores.idempotency, idemEndpointKey, noResultResponse);
|
|
await persistStores(stores);
|
|
return {
|
|
response: noResultResponse,
|
|
simulatedDelayMs: getSimulatedDelay(account.plan),
|
|
};
|
|
}
|
|
|
|
const creditsCharged = consumeCreditsWithIdempotency(
|
|
account,
|
|
stores.idempotency,
|
|
chargeKey('semantic-search', request.userId, request.idempotencyKey),
|
|
SEMANTIC_SEARCH_COST,
|
|
);
|
|
|
|
const results = searchMockCatalog(request.query, request.language, 18);
|
|
account.updatedAt = nowIso();
|
|
|
|
const response: SemanticSearchResponse = {
|
|
status: results.length > 0 ? 'success' : 'no_results',
|
|
results,
|
|
creditsCharged,
|
|
billing: buildBillingSummary(account),
|
|
};
|
|
|
|
writeIdempotentResponse(stores.idempotency, idemEndpointKey, response);
|
|
await persistStores(stores);
|
|
|
|
return {
|
|
response,
|
|
simulatedDelayMs: getSimulatedDelay(account.plan),
|
|
};
|
|
});
|
|
|
|
await sleep(simulatedDelayMs);
|
|
return response;
|
|
},
|
|
|
|
healthCheck: async (request: HealthCheckRequest): Promise<HealthCheckResponse> => {
|
|
const { response, simulatedDelayMs } = await withUserLock(request.userId, async () => {
|
|
const stores = await loadStores();
|
|
const account = getOrCreateAccount(stores, request.userId);
|
|
|
|
const idemEndpointKey = endpointKey('health-check', request.userId, request.idempotencyKey);
|
|
const cachedResponse = readIdempotentResponse<HealthCheckResponse>(stores.idempotency, idemEndpointKey);
|
|
if (cachedResponse) {
|
|
return {
|
|
response: cachedResponse,
|
|
simulatedDelayMs: getSimulatedDelay(account.plan),
|
|
};
|
|
}
|
|
|
|
const normalizedImageUri = request.imageUri.trim();
|
|
if (!normalizedImageUri) {
|
|
throw new BackendApiError('BAD_REQUEST', 'Health check requires an image URI.', 400);
|
|
}
|
|
|
|
if (!openAiScanService.isConfigured()) {
|
|
throw new BackendApiError(
|
|
'PROVIDER_ERROR',
|
|
'OpenAI health check is unavailable. Please configure EXPO_PUBLIC_OPENAI_API_KEY.',
|
|
502,
|
|
);
|
|
}
|
|
|
|
const aiAnalysis = await openAiScanService.analyzePlantHealth(
|
|
normalizedImageUri,
|
|
request.language,
|
|
request.plantContext,
|
|
);
|
|
if (!aiAnalysis) {
|
|
throw new BackendApiError(
|
|
'PROVIDER_ERROR',
|
|
'OpenAI health check failed. Please verify API key, network access, and image quality.',
|
|
502,
|
|
);
|
|
}
|
|
|
|
const creditsCharged = consumeCreditsWithIdempotency(
|
|
account,
|
|
stores.idempotency,
|
|
chargeKey('health-check', request.userId, request.idempotencyKey),
|
|
HEALTH_CHECK_COST,
|
|
);
|
|
|
|
const healthCheck: PlantHealthCheck = {
|
|
generatedAt: nowIso(),
|
|
overallHealthScore: aiAnalysis.overallHealthScore,
|
|
status: aiAnalysis.status,
|
|
likelyIssues: aiAnalysis.likelyIssues,
|
|
actionsNow: aiAnalysis.actionsNow,
|
|
plan7Days: aiAnalysis.plan7Days,
|
|
creditsCharged,
|
|
imageUri: normalizedImageUri,
|
|
};
|
|
account.updatedAt = nowIso();
|
|
|
|
const response: HealthCheckResponse = {
|
|
healthCheck,
|
|
creditsCharged,
|
|
billing: buildBillingSummary(account),
|
|
};
|
|
|
|
writeIdempotentResponse(stores.idempotency, idemEndpointKey, response);
|
|
await persistStores(stores);
|
|
|
|
return {
|
|
response,
|
|
simulatedDelayMs: getSimulatedDelay(account.plan),
|
|
};
|
|
});
|
|
|
|
await sleep(simulatedDelayMs);
|
|
return response;
|
|
},
|
|
|
|
simulatePurchase: async (request: SimulatePurchaseRequest): Promise<SimulatePurchaseResponse> => {
|
|
return withUserLock(request.userId, async () => {
|
|
const stores = await loadStores();
|
|
const account = getOrCreateAccount(stores, request.userId);
|
|
|
|
const idemEndpointKey = endpointKey('simulate-purchase', request.userId, request.idempotencyKey);
|
|
const cachedResponse = readIdempotentResponse<SimulatePurchaseResponse>(stores.idempotency, idemEndpointKey);
|
|
if (cachedResponse) return cachedResponse;
|
|
|
|
if (request.productId === 'monthly_pro' || request.productId === 'yearly_pro') {
|
|
const now = new Date();
|
|
const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now);
|
|
account.plan = 'pro';
|
|
account.provider = 'mock';
|
|
account.monthlyAllowance = PRO_MONTHLY_CREDITS;
|
|
account.usedThisCycle = 0;
|
|
account.cycleStartedAt = cycleStartedAt.toISOString();
|
|
account.cycleEndsAt = cycleEndsAt.toISOString();
|
|
account.renewsAt = addDays(now, 30).toISOString();
|
|
} else {
|
|
const credits = TOPUP_CREDITS_BY_PRODUCT[request.productId];
|
|
account.topupBalance += credits;
|
|
}
|
|
|
|
account.updatedAt = nowIso();
|
|
|
|
const response: SimulatePurchaseResponse = {
|
|
appliedProduct: request.productId,
|
|
billing: buildBillingSummary(account),
|
|
};
|
|
|
|
writeIdempotentResponse(stores.idempotency, idemEndpointKey, response);
|
|
await persistStores(stores);
|
|
return response;
|
|
});
|
|
},
|
|
|
|
simulateWebhook: async (request: SimulateWebhookRequest): Promise<SimulateWebhookResponse> => {
|
|
return withUserLock(request.userId, async () => {
|
|
const stores = await loadStores();
|
|
const account = getOrCreateAccount(stores, request.userId);
|
|
|
|
const idemEndpointKey = endpointKey('simulate-webhook', request.userId, request.idempotencyKey);
|
|
const cachedResponse = readIdempotentResponse<SimulateWebhookResponse>(stores.idempotency, idemEndpointKey);
|
|
if (cachedResponse) return cachedResponse;
|
|
|
|
if (request.event === 'entitlement_granted') {
|
|
const now = new Date();
|
|
const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now);
|
|
account.plan = 'pro';
|
|
account.provider = 'revenuecat';
|
|
account.monthlyAllowance = PRO_MONTHLY_CREDITS;
|
|
account.usedThisCycle = 0;
|
|
account.cycleStartedAt = cycleStartedAt.toISOString();
|
|
account.cycleEndsAt = cycleEndsAt.toISOString();
|
|
account.renewsAt = addDays(now, 30).toISOString();
|
|
}
|
|
|
|
if (request.event === 'entitlement_revoked') {
|
|
const now = new Date();
|
|
const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now);
|
|
account.plan = 'free';
|
|
account.provider = 'revenuecat';
|
|
account.monthlyAllowance = FREE_MONTHLY_CREDITS;
|
|
account.usedThisCycle = 0;
|
|
account.cycleStartedAt = cycleStartedAt.toISOString();
|
|
account.cycleEndsAt = cycleEndsAt.toISOString();
|
|
account.renewsAt = null;
|
|
}
|
|
|
|
if (request.event === 'topup_granted') {
|
|
const credits = Math.max(1, request.payload?.credits || TOPUP_DEFAULT_CREDITS);
|
|
account.topupBalance += credits;
|
|
}
|
|
|
|
if (request.event === 'credits_depleted') {
|
|
account.usedThisCycle = account.monthlyAllowance;
|
|
account.topupBalance = 0;
|
|
}
|
|
|
|
account.updatedAt = nowIso();
|
|
|
|
const response: SimulateWebhookResponse = {
|
|
event: request.event,
|
|
billing: buildBillingSummary(account),
|
|
};
|
|
|
|
writeIdempotentResponse(stores.idempotency, idemEndpointKey, response);
|
|
await persistStores(stores);
|
|
return response;
|
|
});
|
|
},
|
|
};
|