Slefhostet und postgres

This commit is contained in:
2026-04-02 11:39:57 +02:00
parent b1c99893a6
commit 08483c7075
215 changed files with 4584 additions and 5190 deletions

View File

@@ -12,7 +12,14 @@ import {
import { ImageCacheService } from '../services/imageCacheService';
import { getTranslation } from '../utils/translations';
import { backendApiClient } from '../services/backend/backendApiClient';
import { BillingSummary, PurchaseProductId, RevenueCatCustomerInfo, SimulatedWebhookEvent } from '../services/backend/contracts';
import {
BillingSummary,
PurchaseProductId,
RevenueCatCustomerInfo,
RevenueCatEntitlementInfo,
RevenueCatSyncSource,
SimulatedWebhookEvent,
} from '../services/backend/contracts';
import { createIdempotencyKey } from '../utils/idempotency';
import { AuthService, AuthSession } from '../services/authService';
import { PlantsDb, SettingsDb, LexiconHistoryDb, AppMetaDb } from '../services/database';
@@ -43,7 +50,7 @@ interface AppState {
updatePlant: (plant: Plant) => void;
refreshPlants: () => void;
refreshBillingSummary: () => Promise<void>;
syncRevenueCatState: (customerInfo: RevenueCatCustomerInfo) => Promise<BillingSummary | null>;
syncRevenueCatState: (customerInfo: RevenueCatCustomerInfo, source?: RevenueCatSyncSource) => Promise<BillingSummary | null>;
simulatePurchase: (productId: PurchaseProductId) => Promise<void>;
simulateWebhookEvent: (event: SimulatedWebhookEvent, payload?: { credits?: number }) => Promise<void>;
getLexiconSearchHistory: () => string[];
@@ -70,11 +77,47 @@ export const useApp = () => {
return ctx;
};
const isAppearanceMode = (v: string): v is AppearanceMode =>
v === 'system' || v === 'light' || v === 'dark';
const isColorPalette = (v: string): v is ColorPalette =>
v === 'forest' || v === 'ocean' || v === 'sunset' || v === 'mono';
const isLanguage = (v: string): v is Language => v === 'de' || v === 'en' || v === 'es';
const isAppearanceMode = (v: string): v is AppearanceMode =>
v === 'system' || v === 'light' || v === 'dark';
const isColorPalette = (v: string): v is ColorPalette =>
v === 'forest' || v === 'ocean' || v === 'sunset' || v === 'mono';
const isLanguage = (v: string): v is Language => v === 'de' || v === 'en' || v === 'es';
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']);
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: Object.values(customerInfo?.nonSubscriptions || {}).flatMap((entries) =>
(Array.isArray(entries) ? entries : []).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] Ignoring unsupported RevenueCat pro entitlement during local sync', summarizeRevenueCatCustomerInfo(customerInfo));
return null;
};
const getDeviceLanguage = (): Language => {
try {
@@ -341,14 +384,25 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const applyRevenueCatCustomerInfoLocally = useCallback((customerInfo: RevenueCatCustomerInfo) => {
const entitlementId = (process.env.EXPO_PUBLIC_REVENUECAT_PRO_ENTITLEMENT_ID || 'pro').trim() || 'pro';
const applyRevenueCatCustomerInfoLocally = useCallback((
customerInfo: RevenueCatCustomerInfo,
source: RevenueCatSyncSource = 'app_init',
) => {
if (source === 'topup_purchase') {
return;
}
const activeEntitlements = customerInfo?.entitlements?.active || {};
const proEntitlement = activeEntitlements[entitlementId];
const rawProEntitlement = activeEntitlements[REVENUECAT_PRO_ENTITLEMENT_ID];
const proEntitlement = getValidProEntitlement(customerInfo);
const isPro = Boolean(proEntitlement);
setBillingSummary((prev) => {
if (!prev) return prev;
if (!proEntitlement && rawProEntitlement) {
return prev;
}
return {
...prev,
entitlement: {
@@ -362,10 +416,17 @@ export const AppProvider: React.FC<{ children: React.ReactNode }> = ({ children
});
}, []);
const syncRevenueCatState = useCallback(async (customerInfo: RevenueCatCustomerInfo) => {
applyRevenueCatCustomerInfoLocally(customerInfo);
const syncRevenueCatState = useCallback(async (
customerInfo: RevenueCatCustomerInfo,
source: RevenueCatSyncSource = 'app_init',
) => {
console.log('[Billing] Syncing RevenueCat customer info', {
source,
customerInfo: summarizeRevenueCatCustomerInfo(customerInfo),
});
applyRevenueCatCustomerInfoLocally(customerInfo, source);
try {
const response = await backendApiClient.syncRevenueCatState({ customerInfo });
const response = await backendApiClient.syncRevenueCatState({ customerInfo, source });
setBillingSummary(response.billing);
return response.billing;
} catch (error) {