Slefhostet und postgres
This commit is contained in:
@@ -1,12 +1,8 @@
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { AuthDb } from './database';
|
||||
|
||||
const SESSION_KEY = 'greenlens_session_v3';
|
||||
const BACKEND_URL = (
|
||||
process.env.EXPO_PUBLIC_BACKEND_URL ||
|
||||
process.env.EXPO_PUBLIC_PAYMENT_SERVER_URL ||
|
||||
''
|
||||
).trim();
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { AuthDb } from './database';
|
||||
import { getConfiguredBackendRootUrl } from '../utils/backendUrl';
|
||||
|
||||
const SESSION_KEY = 'greenlens_session_v3';
|
||||
|
||||
export interface AuthSession {
|
||||
userId: number; // local SQLite id (for plants/settings queries)
|
||||
@@ -23,9 +19,10 @@ const clearStoredSession = async (): Promise<void> => {
|
||||
await SecureStore.deleteItemAsync(SESSION_KEY);
|
||||
};
|
||||
|
||||
const authPost = async (path: string, body: object): Promise<{ userId: string; email: string; name: string; token: string }> => {
|
||||
const hasBackendUrl = Boolean(BACKEND_URL);
|
||||
const url = hasBackendUrl ? `${BACKEND_URL}${path}` : path;
|
||||
const authPost = async (path: string, body: object): Promise<{ userId: string; email: string; name: string; token: string }> => {
|
||||
const backendUrl = getConfiguredBackendRootUrl();
|
||||
const hasBackendUrl = Boolean(backendUrl);
|
||||
const url = hasBackendUrl ? `${backendUrl}${path}` : path;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
@@ -104,14 +101,15 @@ export const AuthService = {
|
||||
await SecureStore.setItemAsync(SESSION_KEY, JSON.stringify({ ...session, name }));
|
||||
},
|
||||
|
||||
async validateWithServer(): Promise<'valid' | 'invalid' | 'unreachable'> {
|
||||
const session = await this.getSession();
|
||||
if (!session) return 'invalid';
|
||||
if (!BACKEND_URL) return 'unreachable';
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_URL}/v1/billing/summary`, {
|
||||
headers: { Authorization: `Bearer ${session.token}` },
|
||||
});
|
||||
async validateWithServer(): Promise<'valid' | 'invalid' | 'unreachable'> {
|
||||
const session = await this.getSession();
|
||||
if (!session) return 'invalid';
|
||||
const backendUrl = getConfiguredBackendRootUrl();
|
||||
if (!backendUrl) return 'unreachable';
|
||||
try {
|
||||
const response = await fetch(`${backendUrl}/v1/billing/summary`, {
|
||||
headers: { Authorization: `Bearer ${session.token}` },
|
||||
});
|
||||
if (response.status === 401 || response.status === 403) return 'invalid';
|
||||
return 'valid';
|
||||
} catch {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
HealthCheckResponse,
|
||||
PurchaseProductId,
|
||||
RevenueCatCustomerInfo,
|
||||
RevenueCatSyncSource,
|
||||
ScanPlantResponse,
|
||||
SemanticSearchResponse,
|
||||
ServiceHealthResponse,
|
||||
@@ -13,12 +14,12 @@ import {
|
||||
SimulateWebhookResponse,
|
||||
SyncRevenueCatStateResponse,
|
||||
} 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;
|
||||
import { getAuthToken } from './userIdentityService';
|
||||
import { mockBackendService } from './mockBackendService';
|
||||
import { CareInfo, Language } from '../../types';
|
||||
import { getConfiguredBackendRootUrl } from '../../utils/backendUrl';
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 15000;
|
||||
|
||||
const mapHttpStatusToErrorCode = (status: number): BackendErrorCode => {
|
||||
if (status === 400) return 'BAD_REQUEST';
|
||||
@@ -28,10 +29,12 @@ const mapHttpStatusToErrorCode = (status: number): BackendErrorCode => {
|
||||
return 'PROVIDER_ERROR';
|
||||
};
|
||||
|
||||
const buildBackendUrl = (path: string): string => {
|
||||
const base = BACKEND_BASE_URL.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;
|
||||
@@ -104,9 +107,9 @@ const makeRequest = async <T,>(
|
||||
};
|
||||
|
||||
export const backendApiClient = {
|
||||
getServiceHealth: async (): Promise<ServiceHealthResponse> => {
|
||||
if (!BACKEND_BASE_URL) {
|
||||
return {
|
||||
getServiceHealth: async (): Promise<ServiceHealthResponse> => {
|
||||
if (!getConfiguredBackendRootUrl()) {
|
||||
return {
|
||||
ok: true,
|
||||
uptimeSec: 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
@@ -127,10 +130,10 @@ export const backendApiClient = {
|
||||
},
|
||||
|
||||
getBillingSummary: async (): Promise<BillingSummary> => {
|
||||
const token = await getAuthToken();
|
||||
if (!BACKEND_BASE_URL) {
|
||||
return mockBackendService.getBillingSummary(token);
|
||||
}
|
||||
const token = await getAuthToken();
|
||||
if (!getConfiguredBackendRootUrl()) {
|
||||
return mockBackendService.getBillingSummary(token);
|
||||
}
|
||||
|
||||
return makeRequest<BillingSummary>('/v1/billing/summary', {
|
||||
method: 'GET',
|
||||
@@ -140,13 +143,15 @@ export const backendApiClient = {
|
||||
|
||||
syncRevenueCatState: async (params: {
|
||||
customerInfo: RevenueCatCustomerInfo;
|
||||
source?: RevenueCatSyncSource;
|
||||
}): Promise<SyncRevenueCatStateResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!BACKEND_BASE_URL) {
|
||||
return mockBackendService.syncRevenueCatState({
|
||||
userId: token,
|
||||
customerInfo: params.customerInfo,
|
||||
});
|
||||
if (!getConfiguredBackendRootUrl()) {
|
||||
return mockBackendService.syncRevenueCatState({
|
||||
userId: token,
|
||||
customerInfo: params.customerInfo,
|
||||
source: params.source,
|
||||
});
|
||||
}
|
||||
|
||||
return makeRequest<SyncRevenueCatStateResponse>('/v1/billing/sync-revenuecat', {
|
||||
@@ -154,6 +159,7 @@ export const backendApiClient = {
|
||||
token,
|
||||
body: {
|
||||
customerInfo: params.customerInfo,
|
||||
source: params.source,
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -162,10 +168,10 @@ export const backendApiClient = {
|
||||
idempotencyKey: string;
|
||||
imageUri: string;
|
||||
language: Language;
|
||||
}): Promise<ScanPlantResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!BACKEND_BASE_URL) {
|
||||
return mockBackendService.scanPlant({
|
||||
}): Promise<ScanPlantResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!getConfiguredBackendRootUrl()) {
|
||||
return mockBackendService.scanPlant({
|
||||
userId: token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
imageUri: params.imageUri,
|
||||
@@ -188,10 +194,10 @@ export const backendApiClient = {
|
||||
idempotencyKey: string;
|
||||
query: string;
|
||||
language: Language;
|
||||
}): Promise<SemanticSearchResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!BACKEND_BASE_URL) {
|
||||
return mockBackendService.semanticSearch({
|
||||
}): Promise<SemanticSearchResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!getConfiguredBackendRootUrl()) {
|
||||
return mockBackendService.semanticSearch({
|
||||
userId: token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
query: params.query,
|
||||
@@ -220,10 +226,10 @@ export const backendApiClient = {
|
||||
careInfo: CareInfo;
|
||||
description?: string;
|
||||
};
|
||||
}): Promise<HealthCheckResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!BACKEND_BASE_URL) {
|
||||
return mockBackendService.healthCheck({
|
||||
}): Promise<HealthCheckResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!getConfiguredBackendRootUrl()) {
|
||||
return mockBackendService.healthCheck({
|
||||
userId: token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
imageUri: params.imageUri,
|
||||
@@ -247,10 +253,10 @@ export const backendApiClient = {
|
||||
simulatePurchase: async (params: {
|
||||
idempotencyKey: string;
|
||||
productId: PurchaseProductId;
|
||||
}): Promise<SimulatePurchaseResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!BACKEND_BASE_URL) {
|
||||
return mockBackendService.simulatePurchase({
|
||||
}): Promise<SimulatePurchaseResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!getConfiguredBackendRootUrl()) {
|
||||
return mockBackendService.simulatePurchase({
|
||||
userId: token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
productId: params.productId,
|
||||
@@ -271,10 +277,10 @@ export const backendApiClient = {
|
||||
idempotencyKey: string;
|
||||
event: SimulatedWebhookEvent;
|
||||
payload?: { credits?: number };
|
||||
}): Promise<SimulateWebhookResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!BACKEND_BASE_URL) {
|
||||
return mockBackendService.simulateWebhook({
|
||||
}): Promise<SimulateWebhookResponse> => {
|
||||
const token = await getAuthToken();
|
||||
if (!getConfiguredBackendRootUrl()) {
|
||||
return mockBackendService.simulateWebhook({
|
||||
userId: token,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
event: params.event,
|
||||
|
||||
@@ -149,6 +149,12 @@ export interface SimulateWebhookResponse {
|
||||
billing: BillingSummary;
|
||||
}
|
||||
|
||||
export type RevenueCatSyncSource =
|
||||
| 'app_init'
|
||||
| 'subscription_purchase'
|
||||
| 'topup_purchase'
|
||||
| 'restore';
|
||||
|
||||
export interface SyncRevenueCatStateResponse {
|
||||
billing: BillingSummary;
|
||||
syncedAt: string;
|
||||
|
||||
@@ -2,13 +2,15 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import {
|
||||
BackendApiError,
|
||||
BillingProvider,
|
||||
BillingSummary,
|
||||
BillingSummary,
|
||||
HealthCheckRequest,
|
||||
HealthCheckResponse,
|
||||
PlanId,
|
||||
PurchaseProductId,
|
||||
RevenueCatCustomerInfo,
|
||||
RevenueCatEntitlementInfo,
|
||||
RevenueCatNonSubscriptionTransaction,
|
||||
RevenueCatSyncSource,
|
||||
ScanPlantRequest,
|
||||
ScanPlantResponse,
|
||||
SemanticSearchRequest,
|
||||
@@ -46,11 +48,12 @@ const TOPUP_CREDITS_BY_PRODUCT: Record<PurchaseProductId, number> = {
|
||||
monthly_pro: 0,
|
||||
yearly_pro: 0,
|
||||
topup_small: 25,
|
||||
topup_medium: 75,
|
||||
topup_large: 200,
|
||||
topup_medium: 120,
|
||||
topup_large: 300,
|
||||
};
|
||||
|
||||
const REVENUECAT_PRO_ENTITLEMENT_ID = (process.env.EXPO_PUBLIC_REVENUECAT_PRO_ENTITLEMENT_ID || 'pro').trim() || 'pro';
|
||||
const SUPPORTED_REVENUECAT_SUBSCRIPTION_PRODUCTS = new Set<PurchaseProductId>(['monthly_pro', 'yearly_pro']);
|
||||
|
||||
interface MockAccountRecord {
|
||||
userId: string;
|
||||
@@ -243,6 +246,42 @@ const normalizeRevenueCatTransactions = (
|
||||
const nonSubscriptions = customerInfo?.nonSubscriptions || {};
|
||||
return Object.values(nonSubscriptions).flatMap((entries) => Array.isArray(entries) ? entries : []);
|
||||
};
|
||||
|
||||
const summarizeRevenueCatCustomerInfo = (customerInfo: RevenueCatCustomerInfo) => {
|
||||
const activeEntitlements = customerInfo?.entitlements?.active || {};
|
||||
return {
|
||||
appUserId: customerInfo?.appUserId ?? null,
|
||||
originalAppUserId: customerInfo?.originalAppUserId ?? null,
|
||||
activeEntitlements: Object.entries(activeEntitlements).map(([id, entitlement]) => ({
|
||||
id,
|
||||
productIdentifier: entitlement?.productIdentifier ?? null,
|
||||
expirationDate: entitlement?.expirationDate || entitlement?.expiresDate || null,
|
||||
})),
|
||||
allPurchasedProductIdentifiers: customerInfo?.allPurchasedProductIdentifiers ?? [],
|
||||
nonSubscriptionTransactions: normalizeRevenueCatTransactions(customerInfo).map((transaction) => ({
|
||||
productIdentifier: transaction?.productIdentifier ?? null,
|
||||
transactionIdentifier: transaction?.transactionIdentifier || transaction?.transactionId || null,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
const getValidProEntitlement = (customerInfo: RevenueCatCustomerInfo): RevenueCatEntitlementInfo | null => {
|
||||
const activeEntitlements = customerInfo?.entitlements?.active || {};
|
||||
const proEntitlement = activeEntitlements[REVENUECAT_PRO_ENTITLEMENT_ID];
|
||||
if (!proEntitlement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
proEntitlement.productIdentifier
|
||||
&& SUPPORTED_REVENUECAT_SUBSCRIPTION_PRODUCTS.has(proEntitlement.productIdentifier as PurchaseProductId)
|
||||
) {
|
||||
return proEntitlement;
|
||||
}
|
||||
|
||||
console.warn('[Billing][Mock] Ignoring unsupported RevenueCat pro entitlement', summarizeRevenueCatCustomerInfo(customerInfo));
|
||||
return null;
|
||||
};
|
||||
|
||||
const readIdempotentResponse = <T,>(store: IdempotencyStore, key: string): T | null => {
|
||||
const record = store[key];
|
||||
@@ -652,17 +691,25 @@ export const mockBackendService = {
|
||||
syncRevenueCatState: async (request: {
|
||||
userId: string;
|
||||
customerInfo: RevenueCatCustomerInfo;
|
||||
source?: RevenueCatSyncSource;
|
||||
}): Promise<SyncRevenueCatStateResponse> => {
|
||||
return withUserLock(request.userId, async () => {
|
||||
const stores = await loadStores();
|
||||
const account = getOrCreateAccount(stores, request.userId);
|
||||
const activeEntitlements = request.customerInfo?.entitlements?.active || {};
|
||||
const proEntitlement = activeEntitlements[REVENUECAT_PRO_ENTITLEMENT_ID];
|
||||
const proEntitlement = getValidProEntitlement(request.customerInfo);
|
||||
const source = request.source || 'app_init';
|
||||
|
||||
account.plan = proEntitlement ? 'pro' : 'free';
|
||||
account.provider = 'revenuecat';
|
||||
account.monthlyAllowance = getMonthlyAllowanceForPlan(account.plan, account.userId);
|
||||
account.renewsAt = proEntitlement?.expirationDate || proEntitlement?.expiresDate || null;
|
||||
console.log('[Billing][Mock] Syncing RevenueCat customer info', {
|
||||
source,
|
||||
customerInfo: summarizeRevenueCatCustomerInfo(request.customerInfo),
|
||||
});
|
||||
|
||||
if (source !== 'topup_purchase') {
|
||||
account.plan = proEntitlement ? 'pro' : 'free';
|
||||
account.provider = 'revenuecat';
|
||||
account.monthlyAllowance = getMonthlyAllowanceForPlan(account.plan, account.userId);
|
||||
account.renewsAt = proEntitlement?.expirationDate || proEntitlement?.expiresDate || null;
|
||||
}
|
||||
|
||||
for (const transaction of normalizeRevenueCatTransactions(request.customerInfo)) {
|
||||
const productId = transaction.productIdentifier as PurchaseProductId | undefined;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { IdentificationResult, Language } from '../types';
|
||||
import { resolveImageUri, tryResolveImageUri } from '../utils/imageUri';
|
||||
import { getConfiguredApiBaseUrl } from '../utils/backendUrl';
|
||||
import { getConfiguredApiBaseUrl, getConfiguredBackendRootUrl } from '../utils/backendUrl';
|
||||
import { backendApiClient } from './backend/backendApiClient';
|
||||
import { BackendDatabaseEntry, isBackendApiError } from './backend/contracts';
|
||||
import { createIdempotencyKey } from '../utils/idempotency';
|
||||
@@ -26,14 +26,7 @@ export interface SemanticSearchResult {
|
||||
|
||||
const DEFAULT_SEARCH_LIMIT = 500;
|
||||
|
||||
const hasConfiguredPlantBackend = (): boolean => Boolean(
|
||||
String(
|
||||
process.env.EXPO_PUBLIC_API_URL
|
||||
|| process.env.EXPO_PUBLIC_BACKEND_URL
|
||||
|| process.env.EXPO_PUBLIC_PAYMENT_SERVER_URL
|
||||
|| '',
|
||||
).trim(),
|
||||
);
|
||||
const hasConfiguredPlantBackend = (): boolean => Boolean(getConfiguredBackendRootUrl());
|
||||
|
||||
const normalizeImageStatus = (status?: string, imageUri?: string): 'ok' | 'missing' | 'invalid' => {
|
||||
if (status === 'ok' || status === 'missing' || status === 'invalid') return status;
|
||||
|
||||
Reference in New Issue
Block a user