fix: add auth endpoints to server, fix auth bypass and registration
- server/: commit server code for the first time (was untracked)
- POST /auth/signup and /auth/login endpoints now deployed
- GET /v1/billing/summary now verifies user exists in auth_users
(prevents stale JWTs from bypassing auth → fixes empty dashboard)
- app/_layout.tsx: dual-marker install check (SQLite + SecureStore)
to detect fresh installs reliably on Android
- app/auth/login.tsx, signup.tsx: replace Ionicons leaf logo with
actual app icon image (assets/icon.png)
- services/authService.ts: log HTTP status + server message on auth errors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
024eec6686
commit
98e5bfbafd
|
|
@ -11,6 +11,9 @@ node_modules
|
|||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
server/.env
|
||||
server/data/*.sqlite
|
||||
server/data/*.sqlite-*
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
|
|
@ -22,3 +25,10 @@ dist-ssr
|
|||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
|
||||
# Claude / Agents (symlinks incompatible with EAS Build on Windows)
|
||||
.agents/
|
||||
.claude/
|
||||
|
|
|
|||
|
|
@ -0,0 +1,118 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { Stack } from 'expo-router';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { StripeProvider } from '@stripe/stripe-react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { AppProvider, useApp } from '../context/AppContext';
|
||||
import { CoachMarksProvider } from '../context/CoachMarksContext';
|
||||
import { CoachMarksOverlay } from '../components/CoachMarksOverlay';
|
||||
import { useColors } from '../constants/Colors';
|
||||
import { AuthService } from '../services/authService';
|
||||
import { initDatabase, AppMetaDb } from '../services/database';
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
|
||||
type InitialRoute = 'onboarding' | 'auth/login' | '(tabs)';
|
||||
const STRIPE_PUBLISHABLE_KEY = (process.env.EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY || 'pk_test_mock_key').trim();
|
||||
const SECURE_INSTALL_MARKER = 'greenlens_install_v1';
|
||||
|
||||
const ensureInstallConsistency = async (): Promise<boolean> => {
|
||||
try {
|
||||
const [sqliteMarker, secureMarker] = await Promise.all([
|
||||
Promise.resolve(AppMetaDb.get('install_marker_v2')),
|
||||
SecureStore.getItemAsync(SECURE_INSTALL_MARKER).catch(() => null),
|
||||
]);
|
||||
|
||||
if (sqliteMarker && secureMarker) return false; // Kein Fresh Install
|
||||
|
||||
// Fresh Install: Alles zurücksetzen
|
||||
await AuthService.logout();
|
||||
await AsyncStorage.removeItem('greenlens_show_tour');
|
||||
AppMetaDb.set('install_marker_v2', '1');
|
||||
await SecureStore.setItemAsync(SECURE_INSTALL_MARKER, '1');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize install marker', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
function RootLayoutInner() {
|
||||
const { isDarkMode, colorPalette, signOut } = useApp();
|
||||
const colors = useColors(isDarkMode, colorPalette);
|
||||
const [initialRoute, setInitialRoute] = useState<InitialRoute | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const didResetSessionForFreshInstall = await ensureInstallConsistency();
|
||||
if (didResetSessionForFreshInstall) {
|
||||
await signOut();
|
||||
}
|
||||
const session = await AuthService.getSession();
|
||||
if (!session) {
|
||||
// Kein Benutzer → immer zum Onboarding (Landing + Register/Login)
|
||||
setInitialRoute('auth/login');
|
||||
return;
|
||||
}
|
||||
const validity = await AuthService.validateWithServer();
|
||||
if (validity === 'invalid') {
|
||||
await AuthService.logout();
|
||||
await signOut();
|
||||
setInitialRoute('auth/login');
|
||||
return;
|
||||
}
|
||||
// 'valid' or 'unreachable' (offline) → allow tab navigation
|
||||
setInitialRoute('(tabs)');
|
||||
})();
|
||||
}, [signOut]);
|
||||
|
||||
if (initialRoute === null) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusBar style={isDarkMode ? 'light' : 'dark'} />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: colors.background },
|
||||
}}
|
||||
initialRouteName={initialRoute}
|
||||
>
|
||||
<Stack.Screen name="onboarding" options={{ animation: 'none' }} />
|
||||
<Stack.Screen name="auth/login" options={{ animation: 'slide_from_right' }} />
|
||||
<Stack.Screen name="auth/signup" options={{ animation: 'slide_from_right' }} />
|
||||
<Stack.Screen name="(tabs)" options={{ animation: 'none' }} />
|
||||
<Stack.Screen
|
||||
name="scanner"
|
||||
options={{ presentation: 'fullScreenModal', animation: 'slide_from_bottom' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="plant/[id]"
|
||||
options={{ presentation: 'card', animation: 'slide_from_right' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="lexicon"
|
||||
options={{ presentation: 'fullScreenModal', animation: 'slide_from_bottom' }}
|
||||
/>
|
||||
</Stack>
|
||||
{/* Coach Marks rendern über allem */}
|
||||
<CoachMarksOverlay />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
initDatabase();
|
||||
|
||||
return (
|
||||
<StripeProvider
|
||||
publishableKey={STRIPE_PUBLISHABLE_KEY}
|
||||
merchantIdentifier="merchant.com.greenlens"
|
||||
>
|
||||
<AppProvider>
|
||||
<CoachMarksProvider>
|
||||
<RootLayoutInner />
|
||||
</CoachMarksProvider>
|
||||
</AppProvider>
|
||||
</StripeProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,288 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
Image,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { router } from 'expo-router';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import { useColors } from '../../constants/Colors';
|
||||
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
|
||||
import { AuthService } from '../../services/authService';
|
||||
|
||||
export default function LoginScreen() {
|
||||
const { isDarkMode, colorPalette, hydrateSession } = useApp();
|
||||
const colors = useColors(isDarkMode, colorPalette);
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!email.trim() || !password) {
|
||||
setError('Bitte alle Felder ausfüllen.');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const session = await AuthService.login(email, password);
|
||||
await hydrateSession(session);
|
||||
router.replace('/(tabs)');
|
||||
} catch (e: any) {
|
||||
if (e.message === 'USER_NOT_FOUND') {
|
||||
setError('Kein Konto mit dieser E-Mail gefunden.');
|
||||
} else if (e.message === 'WRONG_PASSWORD') {
|
||||
setError('Falsches Passwort.');
|
||||
} else if (e.message === 'BACKEND_URL_MISSING') {
|
||||
setError('Backend-URL fehlt. Bitte EXPO_PUBLIC_BACKEND_URL konfigurieren.');
|
||||
} else if (e.message === 'NETWORK_ERROR') {
|
||||
setError('Server nicht erreichbar. Bitte versuche es erneut.');
|
||||
} else {
|
||||
setError('Anmeldung fehlgeschlagen. Bitte versuche es erneut.');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={[styles.flex, { backgroundColor: colors.background }]}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<ThemeBackdrop colors={colors} />
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scroll}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Logo / Header */}
|
||||
<View style={styles.header}>
|
||||
<Image
|
||||
source={require('../../assets/icon.png')}
|
||||
style={styles.logoIcon}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
<Text style={[styles.appName, { color: colors.text }]}>GreenLens</Text>
|
||||
<Text style={[styles.subtitle, { color: colors.textSecondary }]}>
|
||||
Willkommen zurück
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Card */}
|
||||
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.cardBorder, shadowColor: colors.cardShadow }]}>
|
||||
{/* Email */}
|
||||
<View style={styles.fieldGroup}>
|
||||
<Text style={[styles.label, { color: colors.textSecondary }]}>E-Mail</Text>
|
||||
<View style={[styles.inputRow, { backgroundColor: colors.inputBg, borderColor: colors.inputBorder }]}>
|
||||
<Ionicons name="mail-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={[styles.input, { color: colors.text }]}
|
||||
placeholder="deine@email.de"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
autoComplete="email"
|
||||
returnKeyType="next"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Password */}
|
||||
<View style={styles.fieldGroup}>
|
||||
<Text style={[styles.label, { color: colors.textSecondary }]}>Passwort</Text>
|
||||
<View style={[styles.inputRow, { backgroundColor: colors.inputBg, borderColor: colors.inputBorder }]}>
|
||||
<Ionicons name="lock-closed-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={[styles.input, { color: colors.text }]}
|
||||
placeholder="••••••••"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry={!showPassword}
|
||||
autoComplete="password"
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleLogin}
|
||||
/>
|
||||
<TouchableOpacity onPress={() => setShowPassword((v) => !v)} style={styles.eyeBtn}>
|
||||
<Ionicons
|
||||
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
|
||||
size={18}
|
||||
color={colors.textMuted}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<View style={[styles.errorBox, { backgroundColor: colors.dangerSoft }]}>
|
||||
<Ionicons name="alert-circle-outline" size={15} color={colors.danger} />
|
||||
<Text style={[styles.errorText, { color: colors.danger }]}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Login Button */}
|
||||
<TouchableOpacity
|
||||
style={[styles.primaryBtn, { backgroundColor: colors.primary, opacity: loading ? 0.7 : 1 }]}
|
||||
onPress={handleLogin}
|
||||
activeOpacity={0.82}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color={colors.onPrimary} size="small" />
|
||||
) : (
|
||||
<Text style={[styles.primaryBtnText, { color: colors.onPrimary }]}>Anmelden</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Divider */}
|
||||
<View style={styles.dividerRow}>
|
||||
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
|
||||
<Text style={[styles.dividerText, { color: colors.textMuted }]}>oder</Text>
|
||||
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
|
||||
</View>
|
||||
|
||||
{/* Sign Up Link */}
|
||||
<TouchableOpacity
|
||||
style={[styles.secondaryBtn, { borderColor: colors.borderStrong, backgroundColor: colors.surface }]}
|
||||
onPress={() => router.push('/auth/signup')}
|
||||
activeOpacity={0.82}
|
||||
>
|
||||
<Text style={[styles.secondaryBtnText, { color: colors.text }]}>
|
||||
Noch kein Konto?{' '}
|
||||
<Text style={{ color: colors.primary, fontWeight: '600' }}>Registrieren</Text>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: { flex: 1 },
|
||||
scroll: {
|
||||
flexGrow: 1,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 48,
|
||||
},
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 32,
|
||||
},
|
||||
logoIcon: {
|
||||
width: 88,
|
||||
height: 88,
|
||||
borderRadius: 20,
|
||||
marginBottom: 16,
|
||||
},
|
||||
appName: {
|
||||
fontSize: 30,
|
||||
fontWeight: '700',
|
||||
letterSpacing: -0.5,
|
||||
marginBottom: 6,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '400',
|
||||
},
|
||||
card: {
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
padding: 24,
|
||||
gap: 16,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 1,
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
},
|
||||
fieldGroup: {
|
||||
gap: 6,
|
||||
},
|
||||
label: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
marginLeft: 2,
|
||||
},
|
||||
inputRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 14,
|
||||
height: 50,
|
||||
},
|
||||
inputIcon: {
|
||||
marginRight: 10,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
height: 50,
|
||||
},
|
||||
eyeBtn: {
|
||||
padding: 4,
|
||||
marginLeft: 6,
|
||||
},
|
||||
errorBox: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 13,
|
||||
flex: 1,
|
||||
},
|
||||
primaryBtn: {
|
||||
height: 52,
|
||||
borderRadius: 14,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginTop: 4,
|
||||
},
|
||||
primaryBtnText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
dividerRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginVertical: 20,
|
||||
gap: 12,
|
||||
},
|
||||
dividerLine: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
},
|
||||
dividerText: {
|
||||
fontSize: 13,
|
||||
},
|
||||
secondaryBtn: {
|
||||
height: 52,
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
secondaryBtnText: {
|
||||
fontSize: 15,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,394 @@
|
|||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
Image,
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { router } from 'expo-router';
|
||||
import { useApp } from '../../context/AppContext';
|
||||
import { useColors } from '../../constants/Colors';
|
||||
import { ThemeBackdrop } from '../../components/ThemeBackdrop';
|
||||
import { AuthService } from '../../services/authService';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
export default function SignupScreen() {
|
||||
const { isDarkMode, colorPalette, hydrateSession } = useApp();
|
||||
const colors = useColors(isDarkMode, colorPalette);
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [passwordConfirm, setPasswordConfirm] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showPasswordConfirm, setShowPasswordConfirm] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const validate = (): string | null => {
|
||||
if (!name.trim()) return 'Bitte gib deinen Namen ein.';
|
||||
if (!email.trim() || !email.includes('@')) return 'Bitte gib eine gültige E-Mail ein.';
|
||||
if (password.length < 6) return 'Das Passwort muss mindestens 6 Zeichen haben.';
|
||||
if (password !== passwordConfirm) return 'Die Passwörter stimmen nicht überein.';
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleSignup = async () => {
|
||||
const validationError = validate();
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const session = await AuthService.signUp(email, name, password);
|
||||
await hydrateSession(session);
|
||||
// Flag setzen: Tour beim nächsten App-Öffnen anzeigen
|
||||
await AsyncStorage.setItem('greenlens_show_tour', 'true');
|
||||
router.replace('/(tabs)');
|
||||
} catch (e: any) {
|
||||
if (e.message === 'EMAIL_TAKEN') {
|
||||
setError('Diese E-Mail ist bereits registriert.');
|
||||
} else if (e.message === 'BACKEND_URL_MISSING') {
|
||||
setError('Backend-URL fehlt. Bitte EXPO_PUBLIC_BACKEND_URL konfigurieren.');
|
||||
} else if (e.message === 'NETWORK_ERROR') {
|
||||
setError('Server nicht erreichbar. Bitte versuche es erneut.');
|
||||
} else if (e.message === 'SERVER_ERROR') {
|
||||
setError('Server-Fehler. Bitte versuche es später erneut.');
|
||||
} else if (e.message === 'AUTH_ERROR') {
|
||||
setError('Registrierung fehlgeschlagen. Bitte versuche es erneut.');
|
||||
} else {
|
||||
setError(`Fehler (${e.message}). Bitte versuche es erneut.`);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={[styles.flex, { backgroundColor: colors.background }]}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<ThemeBackdrop colors={colors} />
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scroll}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity
|
||||
style={[styles.backBtn, { backgroundColor: colors.surface, borderColor: colors.border }]}
|
||||
onPress={() => router.back()}
|
||||
>
|
||||
<Ionicons name="arrow-back" size={20} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
<Image
|
||||
source={require('../../assets/icon.png')}
|
||||
style={styles.logoIcon}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
<Text style={[styles.appName, { color: colors.text }]}>GreenLens</Text>
|
||||
<Text style={[styles.subtitle, { color: colors.textSecondary }]}>
|
||||
Konto erstellen
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Card */}
|
||||
<View style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.cardBorder, shadowColor: colors.cardShadow }]}>
|
||||
{/* Name */}
|
||||
<View style={styles.fieldGroup}>
|
||||
<Text style={[styles.label, { color: colors.textSecondary }]}>Name</Text>
|
||||
<View style={[styles.inputRow, { backgroundColor: colors.inputBg, borderColor: colors.inputBorder }]}>
|
||||
<Ionicons name="person-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={[styles.input, { color: colors.text }]}
|
||||
placeholder="Dein Name"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
autoCapitalize="words"
|
||||
autoComplete="name"
|
||||
returnKeyType="next"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Email */}
|
||||
<View style={styles.fieldGroup}>
|
||||
<Text style={[styles.label, { color: colors.textSecondary }]}>E-Mail</Text>
|
||||
<View style={[styles.inputRow, { backgroundColor: colors.inputBg, borderColor: colors.inputBorder }]}>
|
||||
<Ionicons name="mail-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={[styles.input, { color: colors.text }]}
|
||||
placeholder="deine@email.de"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
autoComplete="email"
|
||||
returnKeyType="next"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Password */}
|
||||
<View style={styles.fieldGroup}>
|
||||
<Text style={[styles.label, { color: colors.textSecondary }]}>Passwort</Text>
|
||||
<View style={[styles.inputRow, { backgroundColor: colors.inputBg, borderColor: colors.inputBorder }]}>
|
||||
<Ionicons name="lock-closed-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={[styles.input, { color: colors.text }]}
|
||||
placeholder="Mindestens 6 Zeichen"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry={!showPassword}
|
||||
autoComplete="new-password"
|
||||
returnKeyType="next"
|
||||
/>
|
||||
<TouchableOpacity onPress={() => setShowPassword((v) => !v)} style={styles.eyeBtn}>
|
||||
<Ionicons
|
||||
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
|
||||
size={18}
|
||||
color={colors.textMuted}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Password Confirm */}
|
||||
<View style={styles.fieldGroup}>
|
||||
<Text style={[styles.label, { color: colors.textSecondary }]}>Passwort bestätigen</Text>
|
||||
<View style={[
|
||||
styles.inputRow,
|
||||
{
|
||||
backgroundColor: colors.inputBg,
|
||||
borderColor: passwordConfirm && password !== passwordConfirm ? colors.danger : colors.inputBorder,
|
||||
},
|
||||
]}>
|
||||
<Ionicons name="lock-closed-outline" size={18} color={colors.textMuted} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={[styles.input, { color: colors.text }]}
|
||||
placeholder="Passwort wiederholen"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
value={passwordConfirm}
|
||||
onChangeText={setPasswordConfirm}
|
||||
secureTextEntry={!showPasswordConfirm}
|
||||
autoComplete="new-password"
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleSignup}
|
||||
/>
|
||||
<TouchableOpacity onPress={() => setShowPasswordConfirm((v) => !v)} style={styles.eyeBtn}>
|
||||
<Ionicons
|
||||
name={showPasswordConfirm ? 'eye-off-outline' : 'eye-outline'}
|
||||
size={18}
|
||||
color={colors.textMuted}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Password strength hint */}
|
||||
{password.length > 0 && (
|
||||
<View style={styles.strengthRow}>
|
||||
{[1, 2, 3, 4].map((level) => (
|
||||
<View
|
||||
key={level}
|
||||
style={[
|
||||
styles.strengthBar,
|
||||
{
|
||||
backgroundColor:
|
||||
password.length >= level * 3
|
||||
? level <= 1
|
||||
? colors.danger
|
||||
: level === 2
|
||||
? colors.warning
|
||||
: colors.success
|
||||
: colors.border,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
<Text style={[styles.strengthText, { color: colors.textMuted }]}>
|
||||
{password.length < 4
|
||||
? 'Zu kurz'
|
||||
: password.length < 7
|
||||
? 'Schwach'
|
||||
: password.length < 10
|
||||
? 'Mittel'
|
||||
: 'Stark'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<View style={[styles.errorBox, { backgroundColor: colors.dangerSoft }]}>
|
||||
<Ionicons name="alert-circle-outline" size={15} color={colors.danger} />
|
||||
<Text style={[styles.errorText, { color: colors.danger }]}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Signup Button */}
|
||||
<TouchableOpacity
|
||||
style={[styles.primaryBtn, { backgroundColor: colors.primary, opacity: loading ? 0.7 : 1 }]}
|
||||
onPress={handleSignup}
|
||||
activeOpacity={0.82}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color={colors.onPrimary} size="small" />
|
||||
) : (
|
||||
<Text style={[styles.primaryBtnText, { color: colors.onPrimary }]}>Registrieren</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Login link */}
|
||||
<TouchableOpacity style={styles.loginLink} onPress={() => router.back()}>
|
||||
<Text style={[styles.loginLinkText, { color: colors.textSecondary }]}>
|
||||
Bereits ein Konto?{' '}
|
||||
<Text style={{ color: colors.primary, fontWeight: '600' }}>Anmelden</Text>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: { flex: 1 },
|
||||
scroll: {
|
||||
flexGrow: 1,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 48,
|
||||
},
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 32,
|
||||
},
|
||||
backBtn: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
logoIcon: {
|
||||
width: 88,
|
||||
height: 88,
|
||||
borderRadius: 20,
|
||||
marginBottom: 16,
|
||||
},
|
||||
appName: {
|
||||
fontSize: 30,
|
||||
fontWeight: '700',
|
||||
letterSpacing: -0.5,
|
||||
marginBottom: 6,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '400',
|
||||
},
|
||||
card: {
|
||||
borderRadius: 20,
|
||||
borderWidth: 1,
|
||||
padding: 24,
|
||||
gap: 14,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 1,
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
},
|
||||
fieldGroup: {
|
||||
gap: 6,
|
||||
},
|
||||
label: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
marginLeft: 2,
|
||||
},
|
||||
inputRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 14,
|
||||
height: 50,
|
||||
},
|
||||
inputIcon: {
|
||||
marginRight: 10,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: 15,
|
||||
height: 50,
|
||||
},
|
||||
eyeBtn: {
|
||||
padding: 4,
|
||||
marginLeft: 6,
|
||||
},
|
||||
strengthRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
marginTop: -4,
|
||||
},
|
||||
strengthBar: {
|
||||
flex: 1,
|
||||
height: 3,
|
||||
borderRadius: 2,
|
||||
},
|
||||
strengthText: {
|
||||
fontSize: 11,
|
||||
marginLeft: 4,
|
||||
width: 40,
|
||||
},
|
||||
errorBox: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 13,
|
||||
flex: 1,
|
||||
},
|
||||
primaryBtn: {
|
||||
height: 52,
|
||||
borderRadius: 14,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginTop: 4,
|
||||
},
|
||||
primaryBtnText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
loginLink: {
|
||||
alignItems: 'center',
|
||||
marginTop: 24,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
loginLinkText: {
|
||||
fontSize: 15,
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"expo": {
|
||||
"extra": {
|
||||
"eas": {
|
||||
"projectId": "4fb7372e-83ce-4f18-9b48-e735f00d7243"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,863 @@
|
|||
const path = require('path');
|
||||
const dotenv = require('dotenv');
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const Stripe = require('stripe');
|
||||
|
||||
// override: true ensures .env always wins over existing shell env vars
|
||||
dotenv.config({ path: path.join(__dirname, '.env'), override: true });
|
||||
dotenv.config({ path: path.join(__dirname, '.env.local'), override: true });
|
||||
dotenv.config({ path: path.join(__dirname, '..', '.env') });
|
||||
dotenv.config({ path: path.join(__dirname, '..', '.env.local') });
|
||||
|
||||
const { closeDatabase, getDefaultDbPath, openDatabase } = require('./lib/sqlite');
|
||||
const { ensureAuthSchema, signUp: authSignUp, login: authLogin, issueToken, verifyJwt } = require('./lib/auth');
|
||||
const {
|
||||
PlantImportValidationError,
|
||||
ensurePlantSchema,
|
||||
getPlantDiagnostics,
|
||||
getPlants,
|
||||
rebuildPlantsCatalog,
|
||||
} = require('./lib/plants');
|
||||
const {
|
||||
chargeKey,
|
||||
consumeCreditsWithIdempotency,
|
||||
endpointKey,
|
||||
ensureBillingSchema,
|
||||
getAccountSnapshot,
|
||||
getBillingSummary,
|
||||
getEndpointResponse,
|
||||
isInsufficientCreditsError,
|
||||
simulatePurchase,
|
||||
simulateWebhook,
|
||||
storeEndpointResponse,
|
||||
} = require('./lib/billing');
|
||||
const {
|
||||
analyzePlantHealth,
|
||||
getHealthModel,
|
||||
getScanModel,
|
||||
identifyPlant,
|
||||
isConfigured: isOpenAiConfigured,
|
||||
} = require('./lib/openai');
|
||||
|
||||
const app = express();
|
||||
const port = Number(process.env.PORT || 3000);
|
||||
const stripe = new Stripe((process.env.STRIPE_SECRET_KEY || '').trim() || 'sk_test_mock_key');
|
||||
|
||||
const resolveStripeModeFromKey = (key, livePrefix, testPrefix) => {
|
||||
const normalized = String(key || '').trim();
|
||||
if (normalized.startsWith(livePrefix)) return 'LIVE';
|
||||
if (normalized.startsWith(testPrefix)) return 'TEST';
|
||||
return 'MOCK';
|
||||
};
|
||||
|
||||
const getStripeSecretMode = () =>
|
||||
resolveStripeModeFromKey(process.env.STRIPE_SECRET_KEY, 'sk_live_', 'sk_test_');
|
||||
|
||||
const getStripePublishableMode = () =>
|
||||
resolveStripeModeFromKey(
|
||||
process.env.STRIPE_PUBLISHABLE_KEY || process.env.EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY,
|
||||
'pk_live_',
|
||||
'pk_test_',
|
||||
);
|
||||
|
||||
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 DEFAULT_BOOTSTRAP_PLANTS = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Monstera Deliciosa',
|
||||
botanicalName: 'Monstera deliciosa',
|
||||
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/2/2e/Monstera_deliciosa2.jpg/330px-Monstera_deliciosa2.jpg',
|
||||
description: 'A popular houseplant with large, holey leaves.',
|
||||
categories: ['easy', 'large', 'air_purifier'],
|
||||
confidence: 1,
|
||||
careInfo: {
|
||||
waterIntervalDays: 7,
|
||||
temp: '18-27C',
|
||||
light: 'Indirect bright light',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Snake Plant',
|
||||
botanicalName: 'Sansevieria trifasciata',
|
||||
imageUri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/f/fb/Snake_Plant_%28Sansevieria_trifasciata_%27Laurentii%27%29.jpg/330px-Snake_Plant_%28Sansevieria_trifasciata_%27Laurentii%27%29.jpg',
|
||||
description: 'A hardy indoor plant known for its upright, sword-like leaves.',
|
||||
categories: ['succulent', 'easy', 'low_light', 'air_purifier'],
|
||||
confidence: 1,
|
||||
careInfo: {
|
||||
waterIntervalDays: 21,
|
||||
temp: '15-30C',
|
||||
light: 'Low to full light',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
let db;
|
||||
|
||||
const parseBoolean = (value, fallbackValue) => {
|
||||
if (typeof value !== 'string') return fallbackValue;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized === 'true' || normalized === '1') return true;
|
||||
if (normalized === 'false' || normalized === '0') return false;
|
||||
return fallbackValue;
|
||||
};
|
||||
|
||||
const normalizeText = (value) => {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
};
|
||||
|
||||
const hashString = (value) => {
|
||||
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 clamp = (value, min, max) => {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
};
|
||||
|
||||
const nowIso = () => new Date().toISOString();
|
||||
|
||||
const hasImportAdminKey = Boolean(process.env.PLANT_IMPORT_ADMIN_KEY);
|
||||
const isAuthorizedImport = (request) => {
|
||||
if (!hasImportAdminKey) return true;
|
||||
const provided = request.header('x-admin-key');
|
||||
return provided === process.env.PLANT_IMPORT_ADMIN_KEY;
|
||||
};
|
||||
|
||||
const normalizeLanguage = (value) => {
|
||||
return value === 'de' || value === 'en' || value === 'es' ? value : 'en';
|
||||
};
|
||||
|
||||
const resolveUserId = (request) => {
|
||||
// 1. Bearer JWT (preferred — server-side auth)
|
||||
const authHeader = request.header('authorization');
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
const token = authHeader.slice(7);
|
||||
const payload = verifyJwt(token);
|
||||
if (payload && payload.sub) return String(payload.sub);
|
||||
}
|
||||
// 2. Legacy X-User-Id header (kept for backward compat)
|
||||
const headerUserId = request.header('x-user-id');
|
||||
if (typeof headerUserId === 'string' && headerUserId.trim()) return headerUserId.trim();
|
||||
const bodyUserId = typeof request.body?.userId === 'string' ? request.body.userId.trim() : '';
|
||||
if (bodyUserId) return bodyUserId;
|
||||
return '';
|
||||
};
|
||||
|
||||
const resolveIdempotencyKey = (request) => {
|
||||
const header = request.header('idempotency-key');
|
||||
if (typeof header === 'string' && header.trim()) return header.trim();
|
||||
return '';
|
||||
};
|
||||
|
||||
const toPlantResult = (entry, confidence) => {
|
||||
return {
|
||||
name: entry.name,
|
||||
botanicalName: entry.botanicalName,
|
||||
confidence: clamp(confidence, 0.05, 0.99),
|
||||
description: entry.description || `${entry.name} identified from the plant catalog.`,
|
||||
careInfo: {
|
||||
waterIntervalDays: Math.max(1, Number(entry.careInfo?.waterIntervalDays) || 7),
|
||||
light: entry.careInfo?.light || 'Unknown',
|
||||
temp: entry.careInfo?.temp || 'Unknown',
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const pickCatalogFallback = (entries, imageUri, preferHighConfidence = false) => {
|
||||
if (!Array.isArray(entries) || entries.length === 0) return null;
|
||||
const baseHash = hashString(`${imageUri || ''}|${entries.length}`);
|
||||
const index = baseHash % entries.length;
|
||||
const confidence = preferHighConfidence
|
||||
? 0.84 + ((baseHash % 7) / 100)
|
||||
: 0.62 + ((baseHash % 18) / 100);
|
||||
return toPlantResult(entries[index], confidence);
|
||||
};
|
||||
|
||||
const findCatalogMatch = (aiResult, entries) => {
|
||||
if (!aiResult || !Array.isArray(entries) || entries.length === 0) return null;
|
||||
const aiBotanical = normalizeText(aiResult.botanicalName);
|
||||
const aiName = normalizeText(aiResult.name);
|
||||
if (!aiBotanical && !aiName) return null;
|
||||
|
||||
const byExactBotanical = entries.find((entry) => normalizeText(entry.botanicalName) === aiBotanical);
|
||||
if (byExactBotanical) return byExactBotanical;
|
||||
|
||||
const byExactName = entries.find((entry) => normalizeText(entry.name) === aiName);
|
||||
if (byExactName) return byExactName;
|
||||
|
||||
if (aiBotanical) {
|
||||
const aiGenus = aiBotanical.split(' ')[0];
|
||||
if (aiGenus) {
|
||||
const byGenus = entries.find((entry) => normalizeText(entry.botanicalName).startsWith(`${aiGenus} `));
|
||||
if (byGenus) return byGenus;
|
||||
}
|
||||
}
|
||||
|
||||
const byContains = entries.find((entry) => {
|
||||
const plantName = normalizeText(entry.name);
|
||||
const botanical = normalizeText(entry.botanicalName);
|
||||
return (aiName && (plantName.includes(aiName) || aiName.includes(plantName)))
|
||||
|| (aiBotanical && (botanical.includes(aiBotanical) || aiBotanical.includes(botanical)));
|
||||
});
|
||||
if (byContains) return byContains;
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const applyCatalogGrounding = (aiResult, catalogEntries) => {
|
||||
const matchedEntry = findCatalogMatch(aiResult, catalogEntries);
|
||||
if (!matchedEntry) {
|
||||
return { grounded: false, result: aiResult };
|
||||
}
|
||||
|
||||
return {
|
||||
grounded: true,
|
||||
result: {
|
||||
name: matchedEntry.name || aiResult.name,
|
||||
botanicalName: matchedEntry.botanicalName || aiResult.botanicalName,
|
||||
confidence: clamp(Math.max(aiResult.confidence || 0.6, 0.78), 0.05, 0.99),
|
||||
description: aiResult.description || matchedEntry.description || '',
|
||||
careInfo: {
|
||||
waterIntervalDays: Math.max(1, Number(matchedEntry.careInfo?.waterIntervalDays) || Number(aiResult.careInfo?.waterIntervalDays) || 7),
|
||||
light: matchedEntry.careInfo?.light || aiResult.careInfo?.light || 'Unknown',
|
||||
temp: matchedEntry.careInfo?.temp || aiResult.careInfo?.temp || 'Unknown',
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const toImportErrorPayload = (error) => {
|
||||
if (error instanceof PlantImportValidationError) {
|
||||
return {
|
||||
status: 422,
|
||||
body: {
|
||||
code: 'IMPORT_VALIDATION_ERROR',
|
||||
message: error.message,
|
||||
details: error.details || [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 500,
|
||||
body: {
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const toApiErrorPayload = (error) => {
|
||||
if (error && typeof error === 'object' && error.code === 'BAD_REQUEST') {
|
||||
return {
|
||||
status: 400,
|
||||
body: { code: 'BAD_REQUEST', message: error.message || 'Invalid request.' },
|
||||
};
|
||||
}
|
||||
|
||||
if (error && typeof error === 'object' && error.code === 'UNAUTHORIZED') {
|
||||
return {
|
||||
status: 401,
|
||||
body: { code: 'UNAUTHORIZED', message: error.message || 'Unauthorized.' },
|
||||
};
|
||||
}
|
||||
|
||||
if (isInsufficientCreditsError(error)) {
|
||||
return {
|
||||
status: 402,
|
||||
body: {
|
||||
code: 'INSUFFICIENT_CREDITS',
|
||||
message: error.message || 'Insufficient credits.',
|
||||
details: error.metadata || undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (error && typeof error === 'object' && error.code === 'PROVIDER_ERROR') {
|
||||
return {
|
||||
status: 502,
|
||||
body: { code: 'PROVIDER_ERROR', message: error.message || 'Provider request failed.' },
|
||||
};
|
||||
}
|
||||
|
||||
if (error && typeof error === 'object' && error.code === 'TIMEOUT') {
|
||||
return {
|
||||
status: 504,
|
||||
body: { code: 'TIMEOUT', message: error.message || 'Provider timed out.' },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 500,
|
||||
body: {
|
||||
code: 'PROVIDER_ERROR',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const ensureRequestAuth = (request) => {
|
||||
const userId = resolveUserId(request);
|
||||
if (!userId) {
|
||||
const error = new Error('Missing X-User-Id header.');
|
||||
error.code = 'UNAUTHORIZED';
|
||||
throw error;
|
||||
}
|
||||
return userId;
|
||||
};
|
||||
|
||||
const ensureNonEmptyString = (value, fieldName) => {
|
||||
if (typeof value === 'string' && value.trim()) return value.trim();
|
||||
const error = new Error(`${fieldName} is required.`);
|
||||
error.code = 'BAD_REQUEST';
|
||||
throw error;
|
||||
};
|
||||
|
||||
const seedBootstrapCatalogIfNeeded = async () => {
|
||||
const existing = await getPlants(db, { limit: 1 });
|
||||
if (existing.length > 0) return;
|
||||
|
||||
await rebuildPlantsCatalog(db, DEFAULT_BOOTSTRAP_PLANTS, {
|
||||
source: 'bootstrap',
|
||||
preserveExistingIds: false,
|
||||
enforceUniqueImages: false,
|
||||
});
|
||||
};
|
||||
|
||||
app.use(cors());
|
||||
|
||||
// Webhook must be BEFORE express.json() to get the raw body
|
||||
app.post('/api/webhook', express.raw({ type: 'application/json' }), (request, response) => {
|
||||
const signature = request.headers['stripe-signature'];
|
||||
let event;
|
||||
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(
|
||||
request.body,
|
||||
signature,
|
||||
process.env.STRIPE_WEBHOOK_SECRET,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`Webhook Error: ${error.message}`);
|
||||
response.status(400).send(`Webhook Error: ${error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case 'payment_intent.succeeded':
|
||||
console.log('PaymentIntent succeeded.');
|
||||
break;
|
||||
default:
|
||||
console.log(`Unhandled event type: ${event.type}`);
|
||||
break;
|
||||
}
|
||||
|
||||
response.json({ received: true });
|
||||
});
|
||||
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
|
||||
app.get('/', (_request, response) => {
|
||||
response.status(200).json({
|
||||
service: 'greenlns-api',
|
||||
status: 'ok',
|
||||
endpoints: [
|
||||
'GET /health',
|
||||
'POST /api/payment-sheet',
|
||||
'GET /api/plants',
|
||||
'POST /api/plants/rebuild',
|
||||
'POST /auth/signup',
|
||||
'POST /auth/login',
|
||||
'GET /v1/billing/summary',
|
||||
'POST /v1/scan',
|
||||
'POST /v1/search/semantic',
|
||||
'POST /v1/health-check',
|
||||
'POST /v1/billing/simulate-purchase',
|
||||
'POST /v1/billing/simulate-webhook',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/health', (_request, response) => {
|
||||
const stripeSecret = (process.env.STRIPE_SECRET_KEY || '').trim();
|
||||
response.status(200).json({
|
||||
ok: true,
|
||||
uptimeSec: Math.round(process.uptime()),
|
||||
timestamp: new Date().toISOString(),
|
||||
openAiConfigured: isOpenAiConfigured(),
|
||||
dbReady: Boolean(db),
|
||||
dbPath: getDefaultDbPath(),
|
||||
stripeConfigured: Boolean(stripeSecret),
|
||||
stripeMode: getStripeSecretMode(),
|
||||
stripePublishableMode: getStripePublishableMode(),
|
||||
scanModel: getScanModel(),
|
||||
healthModel: getHealthModel(),
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/api/plants', async (request, response) => {
|
||||
try {
|
||||
const query = typeof request.query.q === 'string' ? request.query.q : '';
|
||||
const category = typeof request.query.category === 'string' ? request.query.category : '';
|
||||
const limit = request.query.limit;
|
||||
const results = await getPlants(db, {
|
||||
query,
|
||||
category,
|
||||
limit: typeof limit === 'string' ? Number(limit) : undefined,
|
||||
});
|
||||
response.json(results);
|
||||
} catch (error) {
|
||||
const payload = toImportErrorPayload(error);
|
||||
response.status(payload.status).json(payload.body);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/plants/diagnostics', async (_request, response) => {
|
||||
try {
|
||||
const diagnostics = await getPlantDiagnostics(db);
|
||||
response.json(diagnostics);
|
||||
} catch (error) {
|
||||
const payload = toImportErrorPayload(error);
|
||||
response.status(payload.status).json(payload.body);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/plants/rebuild', async (request, response) => {
|
||||
if (!isAuthorizedImport(request)) {
|
||||
response.status(401).json({
|
||||
code: 'UNAUTHORIZED',
|
||||
message: 'Invalid or missing x-admin-key.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const payloadEntries = Array.isArray(request.body)
|
||||
? request.body
|
||||
: request.body?.entries;
|
||||
const source = typeof request.body?.source === 'string' && request.body.source.trim()
|
||||
? request.body.source.trim()
|
||||
: 'api_rebuild';
|
||||
const preserveExistingIds = parseBoolean(request.body?.preserveExistingIds, true);
|
||||
const enforceUniqueImages = parseBoolean(request.body?.enforceUniqueImages, true);
|
||||
|
||||
try {
|
||||
const summary = await rebuildPlantsCatalog(db, payloadEntries, {
|
||||
source,
|
||||
preserveExistingIds,
|
||||
enforceUniqueImages,
|
||||
});
|
||||
response.status(200).json(summary);
|
||||
} catch (error) {
|
||||
const payload = toImportErrorPayload(error);
|
||||
response.status(payload.status).json(payload.body);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/payment-sheet', async (request, response) => {
|
||||
try {
|
||||
const amount = Number(request.body?.amount || 500);
|
||||
const currency = request.body?.currency || 'usd';
|
||||
|
||||
const paymentIntent = await stripe.paymentIntents.create({
|
||||
amount,
|
||||
currency,
|
||||
automatic_payment_methods: { enabled: true },
|
||||
});
|
||||
|
||||
const customer = await stripe.customers.create();
|
||||
const ephemeralKey = await stripe.ephemeralKeys.create(
|
||||
{ customer: customer.id },
|
||||
{ apiVersion: '2023-10-16' },
|
||||
);
|
||||
|
||||
response.json({
|
||||
paymentIntent: paymentIntent.client_secret,
|
||||
ephemeralKey: ephemeralKey.secret,
|
||||
customer: customer.id,
|
||||
publishableKey: process.env.STRIPE_PUBLISHABLE_KEY || process.env.EXPO_PUBLIC_STRIPE_PUBLISHABLE_KEY || 'pk_test_mock_key',
|
||||
});
|
||||
} catch (error) {
|
||||
response.status(400).json({
|
||||
code: 'PAYMENT_SHEET_ERROR',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/v1/billing/summary', async (request, response) => {
|
||||
try {
|
||||
const userId = ensureRequestAuth(request);
|
||||
const userExists = await get(db, 'SELECT id FROM auth_users WHERE id = ?', [userId]);
|
||||
if (!userExists) {
|
||||
return response.status(401).json({ code: 'UNAUTHORIZED', message: 'User not found.' });
|
||||
}
|
||||
const summary = await getBillingSummary(db, userId);
|
||||
response.status(200).json(summary);
|
||||
} catch (error) {
|
||||
const payload = toApiErrorPayload(error);
|
||||
response.status(payload.status).json(payload.body);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/v1/scan', async (request, response) => {
|
||||
let userId = 'unknown';
|
||||
try {
|
||||
userId = ensureRequestAuth(request);
|
||||
const idempotencyKey = ensureNonEmptyString(resolveIdempotencyKey(request), 'Idempotency-Key header');
|
||||
const imageUri = ensureNonEmptyString(request.body?.imageUri, 'imageUri');
|
||||
const language = normalizeLanguage(request.body?.language);
|
||||
const endpointId = endpointKey('scan', userId, idempotencyKey);
|
||||
|
||||
const cached = await getEndpointResponse(db, endpointId);
|
||||
if (cached) {
|
||||
response.status(200).json(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
let creditsCharged = 0;
|
||||
const modelPath = [];
|
||||
let modelUsed = null;
|
||||
let modelFallbackCount = 0;
|
||||
creditsCharged += await consumeCreditsWithIdempotency(
|
||||
db,
|
||||
userId,
|
||||
chargeKey('scan-primary', userId, idempotencyKey),
|
||||
SCAN_PRIMARY_COST,
|
||||
);
|
||||
|
||||
const catalogEntries = await getPlants(db, { limit: 500 });
|
||||
let result = pickCatalogFallback(catalogEntries, imageUri, false);
|
||||
let usedOpenAi = false;
|
||||
|
||||
if (isOpenAiConfigured()) {
|
||||
console.log(`Starting OpenAI identification for user ${userId} using model ${getScanModel()}`);
|
||||
const openAiPrimary = await identifyPlant({
|
||||
imageUri,
|
||||
language,
|
||||
mode: 'primary',
|
||||
});
|
||||
modelFallbackCount = Math.max(
|
||||
modelFallbackCount,
|
||||
Math.max((openAiPrimary?.attemptedModels?.length || 0) - 1, 0),
|
||||
);
|
||||
if (openAiPrimary?.result) {
|
||||
console.log(`OpenAI primary identification successful for user ${userId}: ${openAiPrimary.result.name} (${openAiPrimary.result.confidence}) using ${openAiPrimary.modelUsed}`);
|
||||
const grounded = applyCatalogGrounding(openAiPrimary.result, catalogEntries);
|
||||
result = grounded.result;
|
||||
if (!grounded.grounded) result = { ...result, confidence: clamp(Math.max(result.confidence || 0.6, 0.72), 0.05, 0.99) };
|
||||
usedOpenAi = true;
|
||||
modelUsed = openAiPrimary.modelUsed || modelUsed;
|
||||
modelPath.push('openai-primary');
|
||||
if (grounded.grounded) modelPath.push('catalog-grounded-primary');
|
||||
} else {
|
||||
console.warn(`OpenAI primary identification returned null for user ${userId}`);
|
||||
modelPath.push('openai-primary-failed');
|
||||
modelPath.push('catalog-primary-fallback');
|
||||
}
|
||||
} else {
|
||||
console.log(`OpenAI not configured, using catalog fallback for user ${userId}`);
|
||||
modelPath.push('openai-not-configured');
|
||||
modelPath.push('catalog-primary-fallback');
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
const error = new Error('Plant catalog is empty. Unable to produce identification fallback.');
|
||||
error.code = 'PROVIDER_ERROR';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const shouldReview = result.confidence < LOW_CONFIDENCE_REVIEW_THRESHOLD;
|
||||
const accountSnapshot = await getAccountSnapshot(db, userId);
|
||||
if (shouldReview && accountSnapshot.plan === 'pro') {
|
||||
console.log(`Starting AI review for user ${userId} (confidence ${result.confidence} < ${LOW_CONFIDENCE_REVIEW_THRESHOLD})`);
|
||||
try {
|
||||
creditsCharged += await consumeCreditsWithIdempotency(
|
||||
db,
|
||||
userId,
|
||||
chargeKey('scan-review', userId, idempotencyKey),
|
||||
SCAN_REVIEW_COST,
|
||||
);
|
||||
|
||||
if (usedOpenAi) {
|
||||
const openAiReview = await identifyPlant({
|
||||
imageUri,
|
||||
language,
|
||||
mode: 'review',
|
||||
});
|
||||
modelFallbackCount = Math.max(
|
||||
modelFallbackCount,
|
||||
Math.max((openAiReview?.attemptedModels?.length || 0) - 1, 0),
|
||||
);
|
||||
if (openAiReview?.result) {
|
||||
console.log(`OpenAI review identification successful for user ${userId}: ${openAiReview.result.name} (${openAiReview.result.confidence}) using ${openAiReview.modelUsed}`);
|
||||
const grounded = applyCatalogGrounding(openAiReview.result, catalogEntries);
|
||||
result = grounded.result;
|
||||
if (!grounded.grounded) result = { ...result, confidence: clamp(Math.max(result.confidence || 0.6, 0.72), 0.05, 0.99) };
|
||||
modelUsed = openAiReview.modelUsed || modelUsed;
|
||||
modelPath.push('openai-review');
|
||||
if (grounded.grounded) modelPath.push('catalog-grounded-review');
|
||||
} else {
|
||||
console.warn(`OpenAI review identification returned null for user ${userId}`);
|
||||
modelPath.push('openai-review-failed');
|
||||
}
|
||||
} else {
|
||||
const reviewFallback = pickCatalogFallback(catalogEntries, `${imageUri}|review`, true);
|
||||
if (reviewFallback) {
|
||||
result = reviewFallback;
|
||||
}
|
||||
modelPath.push('catalog-review-fallback');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isInsufficientCreditsError(error)) {
|
||||
console.log(`Review skipped for user ${userId} due to insufficient credits`);
|
||||
modelPath.push('review-skipped-insufficient-credits');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else if (shouldReview) {
|
||||
console.log(`Review skipped for user ${userId} (plan: ${accountSnapshot.plan})`);
|
||||
modelPath.push('review-skipped-free-plan');
|
||||
}
|
||||
|
||||
const payload = {
|
||||
result,
|
||||
creditsCharged,
|
||||
modelPath,
|
||||
modelUsed,
|
||||
modelFallbackCount,
|
||||
billing: await getBillingSummary(db, userId),
|
||||
};
|
||||
|
||||
await storeEndpointResponse(db, endpointId, payload);
|
||||
response.status(200).json(payload);
|
||||
} catch (error) {
|
||||
console.error(`Scan error for user ${userId}:`, error);
|
||||
const payload = toApiErrorPayload(error);
|
||||
response.status(payload.status).json(payload.body);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/v1/search/semantic', async (request, response) => {
|
||||
try {
|
||||
const userId = ensureRequestAuth(request);
|
||||
const idempotencyKey = ensureNonEmptyString(resolveIdempotencyKey(request), 'Idempotency-Key header');
|
||||
const query = typeof request.body?.query === 'string' ? request.body.query.trim() : '';
|
||||
const endpointId = endpointKey('semantic-search', userId, idempotencyKey);
|
||||
|
||||
const cached = await getEndpointResponse(db, endpointId);
|
||||
if (cached) {
|
||||
response.status(200).json(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
const payload = {
|
||||
status: 'no_results',
|
||||
results: [],
|
||||
creditsCharged: 0,
|
||||
billing: await getBillingSummary(db, userId),
|
||||
};
|
||||
await storeEndpointResponse(db, endpointId, payload);
|
||||
response.status(200).json(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
const creditsCharged = await consumeCreditsWithIdempotency(
|
||||
db,
|
||||
userId,
|
||||
chargeKey('semantic-search', userId, idempotencyKey),
|
||||
SEMANTIC_SEARCH_COST,
|
||||
);
|
||||
|
||||
const results = await getPlants(db, { query, limit: 18 });
|
||||
const payload = {
|
||||
status: results.length > 0 ? 'success' : 'no_results',
|
||||
results,
|
||||
creditsCharged,
|
||||
billing: await getBillingSummary(db, userId),
|
||||
};
|
||||
|
||||
await storeEndpointResponse(db, endpointId, payload);
|
||||
response.status(200).json(payload);
|
||||
} catch (error) {
|
||||
const payload = toApiErrorPayload(error);
|
||||
response.status(payload.status).json(payload.body);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/v1/health-check', async (request, response) => {
|
||||
try {
|
||||
const userId = ensureRequestAuth(request);
|
||||
const idempotencyKey = ensureNonEmptyString(resolveIdempotencyKey(request), 'Idempotency-Key header');
|
||||
const imageUri = ensureNonEmptyString(request.body?.imageUri, 'imageUri');
|
||||
const language = normalizeLanguage(request.body?.language);
|
||||
const endpointId = endpointKey('health-check', userId, idempotencyKey);
|
||||
|
||||
const cached = await getEndpointResponse(db, endpointId);
|
||||
if (cached) {
|
||||
response.status(200).json(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isOpenAiConfigured()) {
|
||||
const error = new Error('OpenAI health check is unavailable. Please configure OPENAI_API_KEY.');
|
||||
error.code = 'PROVIDER_ERROR';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const analysisResponse = await analyzePlantHealth({
|
||||
imageUri,
|
||||
language,
|
||||
plantContext: request.body?.plantContext,
|
||||
});
|
||||
const analysis = analysisResponse?.analysis;
|
||||
if (!analysis) {
|
||||
const error = new Error('OpenAI health check failed. Please verify API key, model, and network access.');
|
||||
error.code = 'PROVIDER_ERROR';
|
||||
throw error;
|
||||
}
|
||||
|
||||
const creditsCharged = await consumeCreditsWithIdempotency(
|
||||
db,
|
||||
userId,
|
||||
chargeKey('health-check', userId, idempotencyKey),
|
||||
HEALTH_CHECK_COST,
|
||||
);
|
||||
|
||||
const healthCheck = {
|
||||
generatedAt: nowIso(),
|
||||
overallHealthScore: analysis.overallHealthScore,
|
||||
status: analysis.status,
|
||||
likelyIssues: analysis.likelyIssues,
|
||||
actionsNow: analysis.actionsNow,
|
||||
plan7Days: analysis.plan7Days,
|
||||
creditsCharged,
|
||||
imageUri,
|
||||
};
|
||||
|
||||
const payload = {
|
||||
healthCheck,
|
||||
creditsCharged,
|
||||
modelUsed: analysisResponse?.modelUsed || null,
|
||||
modelFallbackCount: Math.max((analysisResponse?.attemptedModels?.length || 0) - 1, 0),
|
||||
billing: await getBillingSummary(db, userId),
|
||||
};
|
||||
|
||||
await storeEndpointResponse(db, endpointId, payload);
|
||||
response.status(200).json(payload);
|
||||
} catch (error) {
|
||||
const payload = toApiErrorPayload(error);
|
||||
response.status(payload.status).json(payload.body);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/v1/billing/simulate-purchase', async (request, response) => {
|
||||
try {
|
||||
const userId = ensureRequestAuth(request);
|
||||
const idempotencyKey = ensureNonEmptyString(resolveIdempotencyKey(request), 'Idempotency-Key header');
|
||||
const productId = ensureNonEmptyString(request.body?.productId, 'productId');
|
||||
const payload = await simulatePurchase(db, userId, idempotencyKey, productId);
|
||||
response.status(200).json(payload);
|
||||
} catch (error) {
|
||||
const payload = toApiErrorPayload(error);
|
||||
response.status(payload.status).json(payload.body);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/v1/billing/simulate-webhook', async (request, response) => {
|
||||
try {
|
||||
const userId = ensureRequestAuth(request);
|
||||
const idempotencyKey = ensureNonEmptyString(resolveIdempotencyKey(request), 'Idempotency-Key header');
|
||||
const event = ensureNonEmptyString(request.body?.event, 'event');
|
||||
const payload = await simulateWebhook(db, userId, idempotencyKey, event, request.body?.payload || {});
|
||||
response.status(200).json(payload);
|
||||
} catch (error) {
|
||||
const payload = toApiErrorPayload(error);
|
||||
response.status(payload.status).json(payload.body);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Auth endpoints ────────────────────────────────────────────────────────
|
||||
|
||||
app.post('/auth/signup', async (request, response) => {
|
||||
try {
|
||||
const { email, name, password } = request.body || {};
|
||||
if (!email || !name || !password) {
|
||||
return response.status(400).json({ code: 'BAD_REQUEST', message: 'email, name and password are required.' });
|
||||
}
|
||||
const user = await authSignUp(db, email, name, password);
|
||||
const token = issueToken(user.id, user.email, user.name);
|
||||
response.status(201).json({ userId: user.id, email: user.email, name: user.name, token });
|
||||
} catch (error) {
|
||||
const status = error.status || 500;
|
||||
response.status(status).json({ code: error.code || 'SERVER_ERROR', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/auth/login', async (request, response) => {
|
||||
try {
|
||||
const { email, password } = request.body || {};
|
||||
if (!email || !password) {
|
||||
return response.status(400).json({ code: 'BAD_REQUEST', message: 'email and password are required.' });
|
||||
}
|
||||
const user = await authLogin(db, email, password);
|
||||
const token = issueToken(user.id, user.email, user.name);
|
||||
response.status(200).json({ userId: user.id, email: user.email, name: user.name, token });
|
||||
} catch (error) {
|
||||
const status = error.status || 500;
|
||||
response.status(status).json({ code: error.code || 'SERVER_ERROR', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Startup ───────────────────────────────────────────────────────────────
|
||||
|
||||
const start = async () => {
|
||||
db = await openDatabase();
|
||||
await ensurePlantSchema(db);
|
||||
await ensureBillingSchema(db);
|
||||
await ensureAuthSchema(db);
|
||||
await seedBootstrapCatalogIfNeeded();
|
||||
|
||||
const stripeMode = getStripeSecretMode();
|
||||
const stripePublishableMode = getStripePublishableMode();
|
||||
console.log(`Stripe mode: ${stripeMode}`);
|
||||
console.log(`Stripe publishable mode: ${stripePublishableMode}`);
|
||||
|
||||
const server = app.listen(port, () => {
|
||||
console.log(`GreenLns server listening at http://localhost:${port}`);
|
||||
});
|
||||
|
||||
const gracefulShutdown = async () => {
|
||||
try {
|
||||
await closeDatabase(db);
|
||||
} catch (error) {
|
||||
console.error('Failed to close database', error);
|
||||
} finally {
|
||||
server.close(() => process.exit(0));
|
||||
}
|
||||
};
|
||||
|
||||
process.on('SIGINT', gracefulShutdown);
|
||||
process.on('SIGTERM', gracefulShutdown);
|
||||
};
|
||||
|
||||
start().catch((error) => {
|
||||
console.error('Failed to start GreenLns server', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
const crypto = require('crypto');
|
||||
const { get, run } = require('./sqlite');
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'greenlens-dev-secret-change-in-prod';
|
||||
const TOKEN_EXPIRY_SECONDS = 365 * 24 * 3600; // 1 year
|
||||
|
||||
// ─── Minimal JWT (HS256, no external deps) ─────────────────────────────────
|
||||
|
||||
const b64url = (input) => {
|
||||
const str = typeof input === 'string' ? input : JSON.stringify(input);
|
||||
return Buffer.from(str).toString('base64url');
|
||||
};
|
||||
|
||||
const b64urlDecode = (str) => Buffer.from(str, 'base64url').toString();
|
||||
|
||||
const signJwt = (payload) => {
|
||||
const header = b64url({ alg: 'HS256', typ: 'JWT' });
|
||||
const body = b64url(payload);
|
||||
const sig = crypto.createHmac('sha256', JWT_SECRET).update(`${header}.${body}`).digest('base64url');
|
||||
return `${header}.${body}.${sig}`;
|
||||
};
|
||||
|
||||
const verifyJwt = (token) => {
|
||||
if (!token || typeof token !== 'string') return null;
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
const [header, body, sig] = parts;
|
||||
const expected = crypto.createHmac('sha256', JWT_SECRET).update(`${header}.${body}`).digest('base64url');
|
||||
if (sig !== expected) return null;
|
||||
try {
|
||||
const payload = JSON.parse(b64urlDecode(body));
|
||||
if (payload.exp && Math.floor(Date.now() / 1000) > payload.exp) return null;
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const issueToken = (userId, email, name) =>
|
||||
signJwt({
|
||||
sub: userId,
|
||||
email,
|
||||
name,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
exp: Math.floor(Date.now() / 1000) + TOKEN_EXPIRY_SECONDS,
|
||||
});
|
||||
|
||||
// ─── Password hashing ──────────────────────────────────────────────────────
|
||||
|
||||
const hashPassword = (password) =>
|
||||
crypto.createHmac('sha256', JWT_SECRET).update(password).digest('hex');
|
||||
|
||||
// ─── Schema ────────────────────────────────────────────────────────────────
|
||||
|
||||
const ensureAuthSchema = async (db) => {
|
||||
await run(
|
||||
db,
|
||||
`CREATE TABLE IF NOT EXISTS auth_users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)`,
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Operations ───────────────────────────────────────────────────────────
|
||||
|
||||
const signUp = async (db, email, name, password) => {
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
const existing = await get(db, 'SELECT id FROM auth_users WHERE email = ?', [normalizedEmail]);
|
||||
if (existing) {
|
||||
const err = new Error('Email already in use.');
|
||||
err.code = 'EMAIL_TAKEN';
|
||||
err.status = 409;
|
||||
throw err;
|
||||
}
|
||||
const id = `usr_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
await run(db, 'INSERT INTO auth_users (id, email, name, password_hash) VALUES (?, ?, ?, ?)', [
|
||||
id,
|
||||
normalizedEmail,
|
||||
name.trim(),
|
||||
hashPassword(password),
|
||||
]);
|
||||
return { id, email: normalizedEmail, name: name.trim() };
|
||||
};
|
||||
|
||||
const login = async (db, email, password) => {
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
const user = await get(db, 'SELECT id, email, name, password_hash FROM auth_users WHERE email = ?', [normalizedEmail]);
|
||||
if (!user) {
|
||||
const err = new Error('No account found for this email.');
|
||||
err.code = 'USER_NOT_FOUND';
|
||||
err.status = 401;
|
||||
throw err;
|
||||
}
|
||||
if (user.password_hash !== hashPassword(password)) {
|
||||
const err = new Error('Wrong password.');
|
||||
err.code = 'WRONG_PASSWORD';
|
||||
err.status = 401;
|
||||
throw err;
|
||||
}
|
||||
return { id: user.id, email: user.email, name: user.name };
|
||||
};
|
||||
|
||||
module.exports = { ensureAuthSchema, signUp, login, issueToken, verifyJwt };
|
||||
|
|
@ -0,0 +1,490 @@
|
|||
const { get, run } = require('./sqlite');
|
||||
|
||||
const FREE_MONTHLY_CREDITS = 15;
|
||||
const PRO_MONTHLY_CREDITS = 50;
|
||||
const TOPUP_DEFAULT_CREDITS = 60;
|
||||
|
||||
const TOPUP_CREDITS_BY_PRODUCT = {
|
||||
pro_monthly: 0,
|
||||
topup_small: 50,
|
||||
topup_medium: 120,
|
||||
topup_large: 300,
|
||||
};
|
||||
|
||||
const AVAILABLE_PRODUCTS = ['pro_monthly', 'topup_small', 'topup_medium', 'topup_large'];
|
||||
|
||||
const nowIso = () => new Date().toISOString();
|
||||
|
||||
const startOfUtcMonth = (date) => {
|
||||
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1, 0, 0, 0, 0));
|
||||
};
|
||||
|
||||
const addUtcMonths = (date, months) => {
|
||||
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() + months, 1, 0, 0, 0, 0));
|
||||
};
|
||||
|
||||
const addDays = (date, days) => {
|
||||
const result = new Date(date.getTime());
|
||||
result.setUTCDate(result.getUTCDate() + days);
|
||||
return result;
|
||||
};
|
||||
|
||||
const getCycleBounds = (now) => {
|
||||
const cycleStartedAt = startOfUtcMonth(now);
|
||||
const cycleEndsAt = addUtcMonths(cycleStartedAt, 1);
|
||||
return { cycleStartedAt, cycleEndsAt };
|
||||
};
|
||||
|
||||
const getMonthlyAllowanceForPlan = (plan) => {
|
||||
return plan === 'pro' ? PRO_MONTHLY_CREDITS : FREE_MONTHLY_CREDITS;
|
||||
};
|
||||
|
||||
const createInsufficientCreditsError = (required, available) => {
|
||||
const error = new Error(`Insufficient credits. Required ${required}, available ${available}.`);
|
||||
error.code = 'INSUFFICIENT_CREDITS';
|
||||
error.status = 402;
|
||||
error.metadata = { required, available };
|
||||
return error;
|
||||
};
|
||||
|
||||
const runInTransaction = async (db, worker) => {
|
||||
await run(db, 'BEGIN IMMEDIATE TRANSACTION');
|
||||
try {
|
||||
const result = await worker();
|
||||
await run(db, 'COMMIT');
|
||||
return result;
|
||||
} catch (error) {
|
||||
try {
|
||||
await run(db, 'ROLLBACK');
|
||||
} catch (rollbackError) {
|
||||
console.error('Failed to rollback SQLite transaction.', rollbackError);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeAccountRow = (row) => {
|
||||
if (!row) return null;
|
||||
return {
|
||||
userId: String(row.userId),
|
||||
plan: row.plan === 'pro' ? 'pro' : 'free',
|
||||
provider: typeof row.provider === 'string' && row.provider ? row.provider : 'stripe',
|
||||
cycleStartedAt: String(row.cycleStartedAt),
|
||||
cycleEndsAt: String(row.cycleEndsAt),
|
||||
monthlyAllowance: Number(row.monthlyAllowance) || FREE_MONTHLY_CREDITS,
|
||||
usedThisCycle: Number(row.usedThisCycle) || 0,
|
||||
topupBalance: Number(row.topupBalance) || 0,
|
||||
renewsAt: row.renewsAt ? String(row.renewsAt) : null,
|
||||
updatedAt: row.updatedAt ? String(row.updatedAt) : nowIso(),
|
||||
};
|
||||
};
|
||||
|
||||
const buildDefaultAccount = (userId, now) => {
|
||||
const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now);
|
||||
return {
|
||||
userId,
|
||||
plan: 'free',
|
||||
provider: 'stripe',
|
||||
cycleStartedAt: cycleStartedAt.toISOString(),
|
||||
cycleEndsAt: cycleEndsAt.toISOString(),
|
||||
monthlyAllowance: FREE_MONTHLY_CREDITS,
|
||||
usedThisCycle: 0,
|
||||
topupBalance: 0,
|
||||
renewsAt: null,
|
||||
updatedAt: nowIso(),
|
||||
};
|
||||
};
|
||||
|
||||
const alignAccountToCurrentCycle = (account, now) => {
|
||||
const next = { ...account };
|
||||
const expectedMonthlyAllowance = getMonthlyAllowanceForPlan(next.plan);
|
||||
if (next.monthlyAllowance !== expectedMonthlyAllowance) {
|
||||
next.monthlyAllowance = expectedMonthlyAllowance;
|
||||
}
|
||||
|
||||
if (!next.renewsAt && next.plan === 'pro') {
|
||||
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 accountChanged = (a, b) => {
|
||||
return a.userId !== b.userId
|
||||
|| a.plan !== b.plan
|
||||
|| a.provider !== b.provider
|
||||
|| a.cycleStartedAt !== b.cycleStartedAt
|
||||
|| a.cycleEndsAt !== b.cycleEndsAt
|
||||
|| a.monthlyAllowance !== b.monthlyAllowance
|
||||
|| a.usedThisCycle !== b.usedThisCycle
|
||||
|| a.topupBalance !== b.topupBalance
|
||||
|| a.renewsAt !== b.renewsAt;
|
||||
};
|
||||
|
||||
const upsertAccount = async (db, account) => {
|
||||
await run(
|
||||
db,
|
||||
`INSERT INTO billing_accounts (
|
||||
userId,
|
||||
plan,
|
||||
provider,
|
||||
cycleStartedAt,
|
||||
cycleEndsAt,
|
||||
monthlyAllowance,
|
||||
usedThisCycle,
|
||||
topupBalance,
|
||||
renewsAt,
|
||||
updatedAt
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(userId) DO UPDATE SET
|
||||
plan = excluded.plan,
|
||||
provider = excluded.provider,
|
||||
cycleStartedAt = excluded.cycleStartedAt,
|
||||
cycleEndsAt = excluded.cycleEndsAt,
|
||||
monthlyAllowance = excluded.monthlyAllowance,
|
||||
usedThisCycle = excluded.usedThisCycle,
|
||||
topupBalance = excluded.topupBalance,
|
||||
renewsAt = excluded.renewsAt,
|
||||
updatedAt = excluded.updatedAt`,
|
||||
[
|
||||
account.userId,
|
||||
account.plan,
|
||||
account.provider,
|
||||
account.cycleStartedAt,
|
||||
account.cycleEndsAt,
|
||||
account.monthlyAllowance,
|
||||
account.usedThisCycle,
|
||||
account.topupBalance,
|
||||
account.renewsAt,
|
||||
account.updatedAt,
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
const getOrCreateAccount = async (db, userId) => {
|
||||
const row = await get(
|
||||
db,
|
||||
`SELECT
|
||||
userId,
|
||||
plan,
|
||||
provider,
|
||||
cycleStartedAt,
|
||||
cycleEndsAt,
|
||||
monthlyAllowance,
|
||||
usedThisCycle,
|
||||
topupBalance,
|
||||
renewsAt,
|
||||
updatedAt
|
||||
FROM billing_accounts
|
||||
WHERE userId = ?`,
|
||||
[userId],
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
if (!row) {
|
||||
const created = buildDefaultAccount(userId, now);
|
||||
await upsertAccount(db, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
const existing = normalizeAccountRow(row);
|
||||
const aligned = alignAccountToCurrentCycle(existing, now);
|
||||
if (accountChanged(existing, aligned)) {
|
||||
aligned.updatedAt = nowIso();
|
||||
await upsertAccount(db, aligned);
|
||||
}
|
||||
return aligned;
|
||||
};
|
||||
|
||||
const getAvailableCredits = (account) => {
|
||||
const monthlyRemaining = Math.max(0, account.monthlyAllowance - account.usedThisCycle);
|
||||
return monthlyRemaining + Math.max(0, account.topupBalance);
|
||||
};
|
||||
|
||||
const buildBillingSummary = (account) => {
|
||||
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: AVAILABLE_PRODUCTS,
|
||||
};
|
||||
};
|
||||
|
||||
const consumeCredits = (account, cost) => {
|
||||
if (cost <= 0) return 0;
|
||||
|
||||
const available = getAvailableCredits(account);
|
||||
if (available < cost) {
|
||||
throw createInsufficientCreditsError(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 parseStoredJson = (raw) => {
|
||||
if (!raw || typeof raw !== 'string') return null;
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const readIdempotentValue = async (db, key) => {
|
||||
const row = await get(db, 'SELECT responseJson FROM billing_idempotency WHERE id = ?', [key]);
|
||||
if (!row || typeof row.responseJson !== 'string') return null;
|
||||
return parseStoredJson(row.responseJson);
|
||||
};
|
||||
|
||||
const writeIdempotentValue = async (db, key, value) => {
|
||||
await run(
|
||||
db,
|
||||
`INSERT INTO billing_idempotency (id, responseJson, createdAt)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
responseJson = excluded.responseJson,
|
||||
createdAt = excluded.createdAt`,
|
||||
[key, JSON.stringify(value), nowIso()],
|
||||
);
|
||||
};
|
||||
|
||||
const endpointKey = (scope, userId, idempotencyKey) => {
|
||||
return `endpoint:${scope}:${userId}:${idempotencyKey}`;
|
||||
};
|
||||
|
||||
const chargeKey = (scope, userId, idempotencyKey) => {
|
||||
return `charge:${scope}:${userId}:${idempotencyKey}`;
|
||||
};
|
||||
|
||||
const consumeCreditsWithIdempotency = async (db, userId, key, cost) => {
|
||||
return runInTransaction(db, async () => {
|
||||
const existing = await readIdempotentValue(db, key);
|
||||
if (existing && typeof existing.charged === 'number') return existing.charged;
|
||||
|
||||
const account = await getOrCreateAccount(db, userId);
|
||||
const charged = consumeCredits(account, cost);
|
||||
account.updatedAt = nowIso();
|
||||
await upsertAccount(db, account);
|
||||
await writeIdempotentValue(db, key, { charged });
|
||||
return charged;
|
||||
});
|
||||
};
|
||||
|
||||
const getBillingSummary = async (db, userId) => {
|
||||
return runInTransaction(db, async () => {
|
||||
const account = await getOrCreateAccount(db, userId);
|
||||
account.updatedAt = nowIso();
|
||||
await upsertAccount(db, account);
|
||||
return buildBillingSummary(account);
|
||||
});
|
||||
};
|
||||
|
||||
const getAccountSnapshot = async (db, userId) => {
|
||||
return runInTransaction(db, async () => {
|
||||
const account = await getOrCreateAccount(db, userId);
|
||||
account.updatedAt = nowIso();
|
||||
await upsertAccount(db, account);
|
||||
return account;
|
||||
});
|
||||
};
|
||||
|
||||
const getEndpointResponse = async (db, key) => {
|
||||
const cached = await readIdempotentValue(db, key);
|
||||
return cached || null;
|
||||
};
|
||||
|
||||
const storeEndpointResponse = async (db, key, response) => {
|
||||
await writeIdempotentValue(db, key, response);
|
||||
};
|
||||
|
||||
const simulatePurchase = async (db, userId, idempotencyKey, productId) => {
|
||||
const endpointId = endpointKey('simulate-purchase', userId, idempotencyKey);
|
||||
const cached = await getEndpointResponse(db, endpointId);
|
||||
if (cached) return cached;
|
||||
|
||||
const response = await runInTransaction(db, async () => {
|
||||
const existingInsideTx = await readIdempotentValue(db, endpointId);
|
||||
if (existingInsideTx) return existingInsideTx;
|
||||
|
||||
const account = await getOrCreateAccount(db, userId);
|
||||
|
||||
if (productId === 'pro_monthly') {
|
||||
const now = new Date();
|
||||
const { cycleStartedAt, cycleEndsAt } = getCycleBounds(now);
|
||||
account.plan = 'pro';
|
||||
account.provider = 'stripe';
|
||||
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[productId];
|
||||
if (typeof credits !== 'number') {
|
||||
const error = new Error(`Unsupported product: ${productId}`);
|
||||
error.code = 'BAD_REQUEST';
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
account.topupBalance += credits;
|
||||
}
|
||||
|
||||
account.updatedAt = nowIso();
|
||||
await upsertAccount(db, account);
|
||||
|
||||
const payload = {
|
||||
appliedProduct: productId,
|
||||
billing: buildBillingSummary(account),
|
||||
};
|
||||
await storeEndpointResponse(db, endpointId, payload);
|
||||
return payload;
|
||||
});
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
const simulateWebhook = async (db, userId, idempotencyKey, event, payload = {}) => {
|
||||
const endpointId = endpointKey('simulate-webhook', userId, idempotencyKey);
|
||||
const cached = await getEndpointResponse(db, endpointId);
|
||||
if (cached) return cached;
|
||||
|
||||
const response = await runInTransaction(db, async () => {
|
||||
const existingInsideTx = await readIdempotentValue(db, endpointId);
|
||||
if (existingInsideTx) return existingInsideTx;
|
||||
|
||||
const account = await getOrCreateAccount(db, userId);
|
||||
|
||||
if (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();
|
||||
} else if (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;
|
||||
} else if (event === 'topup_granted') {
|
||||
const credits = Math.max(1, Number(payload.credits) || TOPUP_DEFAULT_CREDITS);
|
||||
account.topupBalance += credits;
|
||||
} else if (event === 'credits_depleted') {
|
||||
account.usedThisCycle = account.monthlyAllowance;
|
||||
account.topupBalance = 0;
|
||||
} else {
|
||||
const error = new Error(`Unsupported webhook event: ${event}`);
|
||||
error.code = 'BAD_REQUEST';
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
account.updatedAt = nowIso();
|
||||
await upsertAccount(db, account);
|
||||
|
||||
const payloadResponse = {
|
||||
event,
|
||||
billing: buildBillingSummary(account),
|
||||
};
|
||||
await storeEndpointResponse(db, endpointId, payloadResponse);
|
||||
return payloadResponse;
|
||||
});
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
const ensureBillingSchema = async (db) => {
|
||||
await run(
|
||||
db,
|
||||
`CREATE TABLE IF NOT EXISTS billing_accounts (
|
||||
userId TEXT PRIMARY KEY,
|
||||
plan TEXT NOT NULL DEFAULT 'free',
|
||||
provider TEXT NOT NULL DEFAULT 'stripe',
|
||||
cycleStartedAt TEXT NOT NULL,
|
||||
cycleEndsAt TEXT NOT NULL,
|
||||
monthlyAllowance INTEGER NOT NULL DEFAULT 15,
|
||||
usedThisCycle INTEGER NOT NULL DEFAULT 0,
|
||||
topupBalance INTEGER NOT NULL DEFAULT 0,
|
||||
renewsAt TEXT,
|
||||
updatedAt TEXT NOT NULL
|
||||
)`,
|
||||
);
|
||||
|
||||
await run(
|
||||
db,
|
||||
`CREATE TABLE IF NOT EXISTS billing_idempotency (
|
||||
id TEXT PRIMARY KEY,
|
||||
responseJson TEXT NOT NULL,
|
||||
createdAt TEXT NOT NULL
|
||||
)`,
|
||||
);
|
||||
|
||||
await run(
|
||||
db,
|
||||
`CREATE INDEX IF NOT EXISTS idx_billing_idempotency_created_at
|
||||
ON billing_idempotency(createdAt DESC)`,
|
||||
);
|
||||
};
|
||||
|
||||
const isInsufficientCreditsError = (error) => {
|
||||
return Boolean(error && typeof error === 'object' && error.code === 'INSUFFICIENT_CREDITS');
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
AVAILABLE_PRODUCTS,
|
||||
chargeKey,
|
||||
consumeCreditsWithIdempotency,
|
||||
endpointKey,
|
||||
ensureBillingSchema,
|
||||
getAccountSnapshot,
|
||||
getBillingSummary,
|
||||
getEndpointResponse,
|
||||
getMonthlyAllowanceForPlan,
|
||||
isInsufficientCreditsError,
|
||||
runInTransaction,
|
||||
simulatePurchase,
|
||||
simulateWebhook,
|
||||
storeEndpointResponse,
|
||||
};
|
||||
|
|
@ -0,0 +1,446 @@
|
|||
const OPENAI_API_KEY = (process.env.OPENAI_API_KEY || process.env.EXPO_PUBLIC_OPENAI_API_KEY || '').trim();
|
||||
const OPENAI_SCAN_MODEL = (process.env.OPENAI_SCAN_MODEL || process.env.EXPO_PUBLIC_OPENAI_SCAN_MODEL || 'gpt-5').trim();
|
||||
const OPENAI_HEALTH_MODEL = (process.env.OPENAI_HEALTH_MODEL || process.env.EXPO_PUBLIC_OPENAI_HEALTH_MODEL || OPENAI_SCAN_MODEL).trim();
|
||||
const OPENAI_SCAN_FALLBACK_MODELS = (process.env.OPENAI_SCAN_FALLBACK_MODELS || process.env.EXPO_PUBLIC_OPENAI_SCAN_FALLBACK_MODELS || 'gpt-5-mini,gpt-4o-mini').trim();
|
||||
const OPENAI_HEALTH_FALLBACK_MODELS = (process.env.OPENAI_HEALTH_FALLBACK_MODELS || process.env.EXPO_PUBLIC_OPENAI_HEALTH_FALLBACK_MODELS || OPENAI_SCAN_FALLBACK_MODELS).trim();
|
||||
const OPENAI_CHAT_COMPLETIONS_URL = (process.env.OPENAI_CHAT_COMPLETIONS_URL || 'https://api.openai.com/v1/chat/completions').trim();
|
||||
const OPENAI_TIMEOUT_MS = (() => {
|
||||
const raw = (process.env.OPENAI_TIMEOUT_MS || process.env.EXPO_PUBLIC_OPENAI_TIMEOUT_MS || '45000').trim();
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
if (Number.isFinite(parsed) && parsed >= 10000) return parsed;
|
||||
return 45000;
|
||||
})();
|
||||
|
||||
const parseModelChain = (primaryModel, fallbackModels) => {
|
||||
const models = [primaryModel];
|
||||
for (const model of String(fallbackModels || '').split(',')) {
|
||||
const normalized = model.trim();
|
||||
if (!normalized) continue;
|
||||
models.push(normalized);
|
||||
}
|
||||
return [...new Set(models.filter(Boolean))];
|
||||
};
|
||||
|
||||
const OPENAI_SCAN_MODEL_CHAIN = parseModelChain(OPENAI_SCAN_MODEL, OPENAI_SCAN_FALLBACK_MODELS);
|
||||
const OPENAI_HEALTH_MODEL_CHAIN = parseModelChain(OPENAI_HEALTH_MODEL, OPENAI_HEALTH_FALLBACK_MODELS);
|
||||
|
||||
const clamp = (value, min, max) => {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
};
|
||||
|
||||
const toErrorMessage = (error) => {
|
||||
if (error instanceof Error) return error.message;
|
||||
return String(error);
|
||||
};
|
||||
|
||||
const summarizeImageUri = (imageUri) => {
|
||||
const trimmed = typeof imageUri === 'string' ? imageUri.trim() : '';
|
||||
if (!trimmed) return 'empty';
|
||||
if (trimmed.startsWith('data:image')) return `data-uri(${Math.round(trimmed.length / 1024)}kb)`;
|
||||
return trimmed.length > 120 ? `${trimmed.slice(0, 120)}...` : trimmed;
|
||||
};
|
||||
|
||||
const toJsonString = (content) => {
|
||||
const trimmed = typeof content === 'string' ? content.trim() : '';
|
||||
if (!trimmed) return '';
|
||||
const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
|
||||
if (fenced && fenced[1]) return fenced[1].trim();
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const parseContentToJson = (content) => {
|
||||
try {
|
||||
const parsed = JSON.parse(toJsonString(content));
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getString = (value) => {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
};
|
||||
|
||||
const getNumber = (value) => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const getStringArray = (value) => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((item) => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter(Boolean);
|
||||
};
|
||||
|
||||
const getLanguageLabel = (language) => {
|
||||
if (language === 'de') return 'German';
|
||||
if (language === 'es') return 'Spanish';
|
||||
return 'English';
|
||||
};
|
||||
|
||||
const normalizeIdentifyResult = (raw, language) => {
|
||||
const name = getString(raw.name);
|
||||
const botanicalName = getString(raw.botanicalName);
|
||||
const description = getString(raw.description);
|
||||
const confidenceRaw = getNumber(raw.confidence);
|
||||
const careInfoRaw = raw.careInfo;
|
||||
|
||||
if (!name || !botanicalName || !careInfoRaw || typeof careInfoRaw !== 'object' || Array.isArray(careInfoRaw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const waterIntervalRaw = getNumber(careInfoRaw.waterIntervalDays);
|
||||
const light = getString(careInfoRaw.light);
|
||||
const temp = getString(careInfoRaw.temp);
|
||||
if (waterIntervalRaw == null || !light || !temp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fallbackDescription = language === 'de'
|
||||
? `${name} wurde per KI erkannt. Pflegehinweise sind unten aufgefuehrt.`
|
||||
: language === 'es'
|
||||
? `${name} se detecto con IA. Debajo veras recomendaciones de cuidado.`
|
||||
: `${name} was identified with AI. Care guidance is shown below.`;
|
||||
|
||||
return {
|
||||
name,
|
||||
botanicalName,
|
||||
confidence: clamp(confidenceRaw == null ? 0.72 : confidenceRaw, 0.05, 0.99),
|
||||
description: description || fallbackDescription,
|
||||
careInfo: {
|
||||
waterIntervalDays: Math.round(clamp(waterIntervalRaw, 1, 45)),
|
||||
light,
|
||||
temp,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeHealthAnalysis = (raw, language) => {
|
||||
const scoreRaw = getNumber(raw.overallHealthScore);
|
||||
const statusRaw = getString(raw.status);
|
||||
const issuesRaw = raw.likelyIssues;
|
||||
const actionsNowRaw = getStringArray(raw.actionsNow).slice(0, 8);
|
||||
const plan7DaysRaw = getStringArray(raw.plan7Days).slice(0, 10);
|
||||
|
||||
if (scoreRaw == null || !statusRaw || !Array.isArray(issuesRaw)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const status = statusRaw === 'healthy' || statusRaw === 'watch' || statusRaw === 'critical'
|
||||
? statusRaw
|
||||
: 'watch';
|
||||
|
||||
const likelyIssues = issuesRaw
|
||||
.map((entry) => {
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null;
|
||||
const title = getString(entry.title);
|
||||
const details = getString(entry.details);
|
||||
const confidenceRaw = getNumber(entry.confidence);
|
||||
if (!title || !details || confidenceRaw == null) return null;
|
||||
return {
|
||||
title,
|
||||
details,
|
||||
confidence: clamp(confidenceRaw, 0.05, 0.99),
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.slice(0, 4);
|
||||
|
||||
if (likelyIssues.length === 0 || actionsNowRaw.length < 2 || plan7DaysRaw.length < 2) {
|
||||
const fallbackIssue = language === 'de'
|
||||
? 'Die KI konnte keine stabilen Gesundheitsmerkmale extrahieren.'
|
||||
: language === 'es'
|
||||
? 'La IA no pudo extraer senales de salud estables.'
|
||||
: 'AI could not extract stable health signals.';
|
||||
return {
|
||||
overallHealthScore: Math.round(clamp(scoreRaw, 0, 100)),
|
||||
status,
|
||||
likelyIssues: [
|
||||
{
|
||||
title: language === 'de'
|
||||
? 'Analyse unsicher'
|
||||
: language === 'es'
|
||||
? 'Analisis incierto'
|
||||
: 'Uncertain analysis',
|
||||
confidence: 0.35,
|
||||
details: fallbackIssue,
|
||||
},
|
||||
],
|
||||
actionsNow: actionsNowRaw.length > 0
|
||||
? actionsNowRaw
|
||||
: [language === 'de' ? 'Neues, schaerferes Foto aufnehmen.' : language === 'es' ? 'Tomar una foto nueva y mas nitida.' : 'Capture a new, sharper photo.'],
|
||||
plan7Days: plan7DaysRaw.length > 0
|
||||
? plan7DaysRaw
|
||||
: [language === 'de' ? 'In 2 Tagen erneut pruefen.' : language === 'es' ? 'Volver a revisar en 2 dias.' : 'Re-check in 2 days.'],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
overallHealthScore: Math.round(clamp(scoreRaw, 0, 100)),
|
||||
status,
|
||||
likelyIssues,
|
||||
actionsNow: actionsNowRaw,
|
||||
plan7Days: plan7DaysRaw,
|
||||
};
|
||||
};
|
||||
|
||||
const buildIdentifyPrompt = (language, mode) => {
|
||||
const reviewInstruction = mode === 'review'
|
||||
? 'Re-check your first hypothesis with stricter botanical accuracy and correct any mismatch.'
|
||||
: 'Identify the most likely houseplant species from this image with conservative confidence.';
|
||||
|
||||
return [
|
||||
`${reviewInstruction}`,
|
||||
'Return strict JSON only in this shape:',
|
||||
'{"name":"...","botanicalName":"...","confidence":0.0,"description":"...","careInfo":{"waterIntervalDays":7,"light":"...","temp":"..."}}',
|
||||
'Rules:',
|
||||
`- "name", "description", and "careInfo.light" must be written in ${getLanguageLabel(language)}.`,
|
||||
'- "botanicalName" must use accepted Latin scientific naming and must not be invented or misspelled.',
|
||||
'- If species is uncertain, prefer genus-level naming (for example: "Calathea sp.").',
|
||||
'- "confidence" must be between 0 and 1.',
|
||||
'- Keep confidence <= 0.55 when the image is ambiguous, blurred, or partially visible.',
|
||||
'- "waterIntervalDays" must be an integer between 1 and 45.',
|
||||
'- Do not include markdown, explanations, or extra keys.',
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
const buildHealthPrompt = (language, plantContext) => {
|
||||
const contextLines = plantContext
|
||||
? [
|
||||
'Plant context:',
|
||||
`- name: ${plantContext.name || 'n/a'}`,
|
||||
`- botanicalName: ${plantContext.botanicalName || 'n/a'}`,
|
||||
`- care.light: ${plantContext.careInfo?.light || 'n/a'}`,
|
||||
`- care.temp: ${plantContext.careInfo?.temp || 'n/a'}`,
|
||||
`- care.waterIntervalDays: ${plantContext.careInfo?.waterIntervalDays || 'n/a'}`,
|
||||
`- description: ${plantContext.description || 'n/a'}`,
|
||||
]
|
||||
: ['Plant context: not provided'];
|
||||
|
||||
return [
|
||||
`You are an expert botanist and plant health diagnostician. Carefully examine every visible detail of this plant photo and produce a thorough, professional health assessment written in ${getLanguageLabel(language)}.`,
|
||||
'',
|
||||
'Inspect the following in detail: leaf color (yellowing, browning, bleaching, dark spots, necrosis), leaf texture (wilting, crispy edges, curling, drooping), stem condition (rot, soft spots, discoloration), soil surface (dry cracks, mold, pests, waterlogging signs), visible pests (spider mites, fungus gnats, scale insects, aphids, mealybugs), root health (if visible), pot size and drainage.',
|
||||
'',
|
||||
'Return strict JSON only in this exact shape:',
|
||||
'{"overallHealthScore":72,"status":"watch","likelyIssues":[{"title":"...","confidence":0.64,"details":"..."}],"actionsNow":["..."],"plan7Days":["..."]}',
|
||||
'',
|
||||
'Rules:',
|
||||
'- "overallHealthScore": integer 0–100. 100=perfect health, 80–99=minor cosmetic only, 60–79=noticeable issues needing attention, 40–59=significant stress, below 40=severe/critical.',
|
||||
'- "status": exactly one of "healthy" (score>=80, no active threats), "watch" (score 50–79, needs monitoring), "critical" (score<50, urgent action needed).',
|
||||
'- "likelyIssues": 2 to 4 items, sorted by confidence descending. Each item:',
|
||||
' - "title": concise issue name (e.g. "Overwatering / Root Rot Risk")',
|
||||
' - "confidence": float 0.05–0.99 reflecting visual certainty',
|
||||
' - "details": 2–4 sentence detailed explanation of what you observe visually, what causes it, and what happens if untreated. Be specific — mention leaf color, location, pattern.',
|
||||
`- "actionsNow": 5 to 8 specific, actionable steps for the next 24–48 hours. Each step must be a complete sentence with concrete instructions (e.g. amounts, durations, techniques). Written in ${getLanguageLabel(language)}.`,
|
||||
`- "plan7Days": 7 to 10 day-by-day or milestone care steps for the coming week. Each step should specify timing and expected outcome. Written in ${getLanguageLabel(language)}.`,
|
||||
'- All text fields must be written in the specified language. No markdown, no extra keys.',
|
||||
...contextLines,
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
const extractMessageContent = (payload) => {
|
||||
const content = payload?.choices?.[0]?.message?.content;
|
||||
if (typeof content === 'string') return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((chunk) => (chunk && chunk.type === 'text' ? chunk.text || '' : ''))
|
||||
.join('')
|
||||
.trim();
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const postChatCompletion = async ({ modelChain, messages, imageUri, temperature }) => {
|
||||
if (!OPENAI_API_KEY) return null;
|
||||
if (typeof fetch !== 'function') {
|
||||
throw new Error('Global fetch is not available in this Node runtime.');
|
||||
}
|
||||
|
||||
const attemptedModels = [];
|
||||
|
||||
for (const model of modelChain) {
|
||||
attemptedModels.push(model);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), OPENAI_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const body = {
|
||||
model,
|
||||
response_format: { type: 'json_object' },
|
||||
messages,
|
||||
};
|
||||
if (typeof temperature === 'number') body.temperature = temperature;
|
||||
|
||||
const response = await fetch(OPENAI_CHAT_COMPLETIONS_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${OPENAI_API_KEY}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
console.warn('OpenAI request HTTP error.', {
|
||||
status: response.status,
|
||||
model,
|
||||
endpoint: OPENAI_CHAT_COMPLETIONS_URL,
|
||||
image: summarizeImageUri(imageUri),
|
||||
bodyPreview: body.slice(0, 300),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
return { payload, modelUsed: model, attemptedModels };
|
||||
} catch (error) {
|
||||
const isTimeoutAbort = error instanceof Error && error.name === 'AbortError';
|
||||
console.warn('OpenAI request failed.', {
|
||||
model,
|
||||
endpoint: OPENAI_CHAT_COMPLETIONS_URL,
|
||||
timeoutMs: OPENAI_TIMEOUT_MS,
|
||||
aborted: isTimeoutAbort,
|
||||
error: toErrorMessage(error),
|
||||
image: summarizeImageUri(imageUri),
|
||||
});
|
||||
continue;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
return { payload: null, modelUsed: null, attemptedModels };
|
||||
};
|
||||
|
||||
const identifyPlant = async ({ imageUri, language, mode = 'primary' }) => {
|
||||
if (!OPENAI_API_KEY) return { result: null, modelUsed: null, attemptedModels: [] };
|
||||
const completion = await postChatCompletion({
|
||||
modelChain: OPENAI_SCAN_MODEL_CHAIN,
|
||||
imageUri,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'You are a plant identification assistant. Return strict JSON only.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: buildIdentifyPrompt(language, mode) },
|
||||
{ type: 'image_url', image_url: { url: imageUri } },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!completion?.payload) {
|
||||
return {
|
||||
result: null,
|
||||
modelUsed: completion?.modelUsed || null,
|
||||
attemptedModels: completion?.attemptedModels || [],
|
||||
};
|
||||
}
|
||||
|
||||
const content = extractMessageContent(completion.payload);
|
||||
if (!content) {
|
||||
console.warn('OpenAI identify returned empty content.', {
|
||||
model: completion.modelUsed || OPENAI_SCAN_MODEL_CHAIN[0],
|
||||
mode,
|
||||
image: summarizeImageUri(imageUri),
|
||||
});
|
||||
return { result: null, modelUsed: completion.modelUsed, attemptedModels: completion.attemptedModels };
|
||||
}
|
||||
|
||||
const parsed = parseContentToJson(content);
|
||||
if (!parsed) {
|
||||
console.warn('OpenAI identify returned non-JSON content.', {
|
||||
model: completion.modelUsed || OPENAI_SCAN_MODEL_CHAIN[0],
|
||||
mode,
|
||||
preview: content.slice(0, 220),
|
||||
});
|
||||
return { result: null, modelUsed: completion.modelUsed, attemptedModels: completion.attemptedModels };
|
||||
}
|
||||
|
||||
const normalized = normalizeIdentifyResult(parsed, language);
|
||||
if (!normalized) {
|
||||
console.warn('OpenAI identify JSON did not match schema.', {
|
||||
model: completion.modelUsed || OPENAI_SCAN_MODEL_CHAIN[0],
|
||||
mode,
|
||||
keys: Object.keys(parsed),
|
||||
});
|
||||
}
|
||||
return { result: normalized, modelUsed: completion.modelUsed, attemptedModels: completion.attemptedModels };
|
||||
};
|
||||
|
||||
const analyzePlantHealth = async ({ imageUri, language, plantContext }) => {
|
||||
if (!OPENAI_API_KEY) return { analysis: null, modelUsed: null, attemptedModels: [] };
|
||||
const completion = await postChatCompletion({
|
||||
modelChain: OPENAI_HEALTH_MODEL_CHAIN,
|
||||
imageUri,
|
||||
temperature: 0,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: 'You are a plant health diagnosis assistant. Return strict JSON only.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: buildHealthPrompt(language, plantContext) },
|
||||
{ type: 'image_url', image_url: { url: imageUri } },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!completion?.payload) {
|
||||
return {
|
||||
analysis: null,
|
||||
modelUsed: completion?.modelUsed || null,
|
||||
attemptedModels: completion?.attemptedModels || [],
|
||||
};
|
||||
}
|
||||
|
||||
const content = extractMessageContent(completion.payload);
|
||||
if (!content) {
|
||||
console.warn('OpenAI health returned empty content.', {
|
||||
model: completion.modelUsed || OPENAI_HEALTH_MODEL_CHAIN[0],
|
||||
image: summarizeImageUri(imageUri),
|
||||
});
|
||||
return { analysis: null, modelUsed: completion.modelUsed, attemptedModels: completion.attemptedModels };
|
||||
}
|
||||
|
||||
const parsed = parseContentToJson(content);
|
||||
if (!parsed) {
|
||||
console.warn('OpenAI health returned non-JSON content.', {
|
||||
model: completion.modelUsed || OPENAI_HEALTH_MODEL_CHAIN[0],
|
||||
preview: content.slice(0, 220),
|
||||
});
|
||||
return { analysis: null, modelUsed: completion.modelUsed, attemptedModels: completion.attemptedModels };
|
||||
}
|
||||
|
||||
return {
|
||||
analysis: normalizeHealthAnalysis(parsed, language),
|
||||
modelUsed: completion.modelUsed,
|
||||
attemptedModels: completion.attemptedModels,
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
analyzePlantHealth,
|
||||
getHealthModel: () => OPENAI_HEALTH_MODEL_CHAIN[0],
|
||||
getScanModel: () => OPENAI_SCAN_MODEL_CHAIN[0],
|
||||
identifyPlant,
|
||||
isConfigured: () => Boolean(OPENAI_API_KEY),
|
||||
};
|
||||
|
|
@ -0,0 +1,652 @@
|
|||
const crypto = require('crypto');
|
||||
const { all, get, run } = require('./sqlite');
|
||||
|
||||
const DEFAULT_LIMIT = 60;
|
||||
const MAX_LIMIT = 500;
|
||||
const MAX_AUDIT_DETAILS = 80;
|
||||
const WIKIMEDIA_FILEPATH_SEGMENT = 'Special:FilePath/';
|
||||
const WIKIMEDIA_REDIRECT_BASE = 'https://commons.wikimedia.org/wiki/Special:FilePath/';
|
||||
|
||||
class PlantImportValidationError extends Error {
|
||||
constructor(message, details) {
|
||||
super(message);
|
||||
this.name = 'PlantImportValidationError';
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeWhitespace = (value) => {
|
||||
return value.trim().replace(/\s+/g, ' ');
|
||||
};
|
||||
|
||||
const normalizeKey = (value) => {
|
||||
return normalizeWhitespace(value)
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '');
|
||||
};
|
||||
|
||||
const unwrapMarkdownLink = (value) => {
|
||||
const markdownMatch = value.match(/^\[[^\]]+]\((https?:\/\/[^)]+)\)(.*)$/i);
|
||||
if (!markdownMatch) return value;
|
||||
const [, url, suffix] = markdownMatch;
|
||||
return `${url}${suffix || ''}`;
|
||||
};
|
||||
|
||||
const tryDecode = (value) => {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
const convertWikimediaFilePathUrl = (value) => {
|
||||
const segmentIndex = value.indexOf(WIKIMEDIA_FILEPATH_SEGMENT);
|
||||
if (segmentIndex < 0) return null;
|
||||
|
||||
const fileNameStart = segmentIndex + WIKIMEDIA_FILEPATH_SEGMENT.length;
|
||||
const rawFileName = value.slice(fileNameStart).split(/[?#]/)[0].trim();
|
||||
if (!rawFileName) return null;
|
||||
|
||||
const decodedFileName = tryDecode(rawFileName).replace(/\s+/g, ' ').trim();
|
||||
if (!decodedFileName) return null;
|
||||
const encodedFileName = encodeURIComponent(decodedFileName).replace(/%2F/g, '/');
|
||||
return `${WIKIMEDIA_REDIRECT_BASE}${encodedFileName}`;
|
||||
};
|
||||
|
||||
const normalizeImageUri = (rawUri) => {
|
||||
if (typeof rawUri !== 'string') return null;
|
||||
|
||||
const trimmed = rawUri.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const normalized = unwrapMarkdownLink(trimmed);
|
||||
const converted = convertWikimediaFilePathUrl(normalized);
|
||||
const candidate = (converted || normalized).replace(/^http:\/\//i, 'https://');
|
||||
|
||||
let parsedUrl;
|
||||
try {
|
||||
parsedUrl = new URL(candidate);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const protocol = parsedUrl.protocol.toLowerCase();
|
||||
if (protocol !== 'https:' && protocol !== 'http:') return null;
|
||||
if (!parsedUrl.hostname) return null;
|
||||
|
||||
parsedUrl.protocol = 'https:';
|
||||
return parsedUrl.toString();
|
||||
};
|
||||
|
||||
const toArrayOfStrings = (value) => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
const normalized = value
|
||||
.map((item) => (typeof item === 'string' ? normalizeWhitespace(item) : ''))
|
||||
.filter(Boolean);
|
||||
return [...new Set(normalized)];
|
||||
};
|
||||
|
||||
const parseNumber = (value, fallback) => {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const buildStablePlantId = (botanicalName) => {
|
||||
const hash = crypto
|
||||
.createHash('sha1')
|
||||
.update(normalizeKey(botanicalName))
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
return `plant_${hash}`;
|
||||
};
|
||||
|
||||
const parseExistingIdMap = (rows) => {
|
||||
const botanicalToId = new Map();
|
||||
rows.forEach((row) => {
|
||||
if (!row || typeof row.botanicalName !== 'string' || typeof row.id !== 'string') return;
|
||||
botanicalToId.set(normalizeKey(row.botanicalName), row.id);
|
||||
});
|
||||
return botanicalToId;
|
||||
};
|
||||
|
||||
const prepareEntry = (rawEntry, index, existingIdMap, preserveExistingIds) => {
|
||||
const errors = [];
|
||||
|
||||
const name = typeof rawEntry?.name === 'string' ? normalizeWhitespace(rawEntry.name) : '';
|
||||
const botanicalName = typeof rawEntry?.botanicalName === 'string'
|
||||
? normalizeWhitespace(rawEntry.botanicalName)
|
||||
: '';
|
||||
|
||||
if (!name) {
|
||||
errors.push({ index, field: 'name', message: 'name is required.' });
|
||||
}
|
||||
if (!botanicalName) {
|
||||
errors.push({ index, field: 'botanicalName', message: 'botanicalName is required.' });
|
||||
}
|
||||
|
||||
const normalizedBotanicalKey = botanicalName ? normalizeKey(botanicalName) : '';
|
||||
const existingId = preserveExistingIds ? existingIdMap.get(normalizedBotanicalKey) : null;
|
||||
|
||||
const incomingId = typeof rawEntry?.id === 'string' ? normalizeWhitespace(rawEntry.id) : '';
|
||||
const id = incomingId || existingId || (botanicalName ? buildStablePlantId(botanicalName) : '');
|
||||
|
||||
if (!id) {
|
||||
errors.push({ index, field: 'id', message: 'Could not derive stable plant id.' });
|
||||
}
|
||||
|
||||
const imageUri = normalizeImageUri(rawEntry?.imageUri);
|
||||
if (!imageUri) {
|
||||
errors.push({
|
||||
index,
|
||||
field: 'imageUri',
|
||||
message: 'imageUri is missing or invalid. A valid http(s) URL is required.',
|
||||
value: rawEntry?.imageUri ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
const categories = toArrayOfStrings(rawEntry?.categories);
|
||||
const confidence = parseNumber(rawEntry?.confidence, 1);
|
||||
const clampedConfidence = Math.max(0, Math.min(1, Number(confidence.toFixed(4))));
|
||||
const description = typeof rawEntry?.description === 'string' ? rawEntry.description.trim() : '';
|
||||
const careInfoRaw = rawEntry?.careInfo || {};
|
||||
const careInfo = {
|
||||
waterIntervalDays: Math.max(1, Math.round(parseNumber(careInfoRaw.waterIntervalDays, 7))),
|
||||
light: typeof careInfoRaw.light === 'string' && careInfoRaw.light.trim()
|
||||
? normalizeWhitespace(careInfoRaw.light)
|
||||
: 'Unknown',
|
||||
temp: typeof careInfoRaw.temp === 'string' && careInfoRaw.temp.trim()
|
||||
? normalizeWhitespace(careInfoRaw.temp)
|
||||
: 'Unknown',
|
||||
};
|
||||
|
||||
return {
|
||||
entry: {
|
||||
id,
|
||||
name,
|
||||
botanicalName,
|
||||
imageUri,
|
||||
imageStatus: 'ok',
|
||||
description,
|
||||
categories,
|
||||
careInfo,
|
||||
confidence: clampedConfidence,
|
||||
},
|
||||
errors,
|
||||
};
|
||||
};
|
||||
|
||||
const collectDuplicateErrors = (entries, getKey, fieldName, message) => {
|
||||
const counts = new Map();
|
||||
entries.forEach((entry, index) => {
|
||||
const key = getKey(entry);
|
||||
if (!key) return;
|
||||
const existing = counts.get(key) || [];
|
||||
existing.push(index);
|
||||
counts.set(key, existing);
|
||||
});
|
||||
|
||||
const duplicateErrors = [];
|
||||
counts.forEach((indices, key) => {
|
||||
if (indices.length <= 1) return;
|
||||
indices.forEach((index) => {
|
||||
duplicateErrors.push({
|
||||
index,
|
||||
field: fieldName,
|
||||
message,
|
||||
value: key,
|
||||
});
|
||||
});
|
||||
});
|
||||
return duplicateErrors;
|
||||
};
|
||||
|
||||
const assertValidPreparedEntries = (entries, enforceUniqueImages) => {
|
||||
const duplicateErrors = [];
|
||||
duplicateErrors.push(
|
||||
...collectDuplicateErrors(
|
||||
entries,
|
||||
(entry) => entry.id,
|
||||
'id',
|
||||
'Duplicate plant id detected in import payload.',
|
||||
),
|
||||
);
|
||||
duplicateErrors.push(
|
||||
...collectDuplicateErrors(
|
||||
entries,
|
||||
(entry) => normalizeKey(entry.botanicalName),
|
||||
'botanicalName',
|
||||
'Duplicate botanicalName detected in import payload.',
|
||||
),
|
||||
);
|
||||
|
||||
if (enforceUniqueImages) {
|
||||
duplicateErrors.push(
|
||||
...collectDuplicateErrors(
|
||||
entries,
|
||||
(entry) => entry.imageUri,
|
||||
'imageUri',
|
||||
'Duplicate imageUri detected across multiple plants.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (duplicateErrors.length > 0) {
|
||||
throw new PlantImportValidationError(
|
||||
'Import payload contains duplicate keys.',
|
||||
duplicateErrors.slice(0, MAX_AUDIT_DETAILS),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const ensureColumn = async (db, tableName, columnName, definitionSql) => {
|
||||
const columns = await all(db, `PRAGMA table_info(${tableName})`);
|
||||
const hasColumn = columns.some((column) => column.name === columnName);
|
||||
if (hasColumn) return;
|
||||
await run(db, `ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${definitionSql}`);
|
||||
};
|
||||
|
||||
const ensurePlantSchema = async (db) => {
|
||||
await run(
|
||||
db,
|
||||
`CREATE TABLE IF NOT EXISTS plants (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
botanicalName TEXT NOT NULL,
|
||||
imageUri TEXT NOT NULL,
|
||||
description TEXT,
|
||||
categories TEXT NOT NULL,
|
||||
careInfo TEXT NOT NULL,
|
||||
confidence REAL NOT NULL
|
||||
)`,
|
||||
);
|
||||
|
||||
await ensureColumn(db, 'plants', 'imageStatus', `TEXT NOT NULL DEFAULT 'ok'`);
|
||||
await ensureColumn(db, 'plants', 'createdAt', `TEXT`);
|
||||
await ensureColumn(db, 'plants', 'updatedAt', `TEXT`);
|
||||
|
||||
await run(
|
||||
db,
|
||||
`CREATE TABLE IF NOT EXISTS plant_import_audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source TEXT NOT NULL,
|
||||
importedCount INTEGER NOT NULL DEFAULT 0,
|
||||
preservedIds INTEGER NOT NULL DEFAULT 0,
|
||||
duplicateImageCount INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL,
|
||||
details TEXT,
|
||||
backupTable TEXT,
|
||||
startedAt TEXT NOT NULL,
|
||||
completedAt TEXT NOT NULL
|
||||
)`,
|
||||
);
|
||||
|
||||
await run(
|
||||
db,
|
||||
`CREATE INDEX IF NOT EXISTS idx_plants_name ON plants(name COLLATE NOCASE)`,
|
||||
);
|
||||
await run(
|
||||
db,
|
||||
`CREATE INDEX IF NOT EXISTS idx_plants_botanical_name ON plants(botanicalName COLLATE NOCASE)`,
|
||||
);
|
||||
await run(
|
||||
db,
|
||||
`CREATE INDEX IF NOT EXISTS idx_plant_import_audit_started_at ON plant_import_audit(startedAt DESC)`,
|
||||
);
|
||||
|
||||
await run(
|
||||
db,
|
||||
`UPDATE plants SET imageStatus = COALESCE(NULLIF(imageStatus, ''), 'ok')`,
|
||||
);
|
||||
await run(
|
||||
db,
|
||||
`UPDATE plants SET createdAt = COALESCE(createdAt, datetime('now'))`,
|
||||
);
|
||||
await run(
|
||||
db,
|
||||
`UPDATE plants SET updatedAt = COALESCE(updatedAt, datetime('now'))`,
|
||||
);
|
||||
};
|
||||
|
||||
const parseJsonArray = (value) => {
|
||||
if (!value) return [];
|
||||
if (Array.isArray(value)) return value;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const parseJsonObject = (value) => {
|
||||
if (!value) return {};
|
||||
if (typeof value === 'object') return value;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const toApiPlant = (row) => {
|
||||
const categories = parseJsonArray(row.categories);
|
||||
const careInfo = parseJsonObject(row.careInfo);
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
botanicalName: row.botanicalName,
|
||||
imageUri: row.imageUri,
|
||||
imageStatus: row.imageStatus || 'ok',
|
||||
description: row.description || '',
|
||||
categories,
|
||||
careInfo,
|
||||
confidence: Number(row.confidence) || 0,
|
||||
};
|
||||
};
|
||||
|
||||
const getPlants = async (db, options = {}) => {
|
||||
const query = typeof options.query === 'string' ? options.query.trim().toLowerCase() : '';
|
||||
const category = typeof options.category === 'string' ? options.category.trim() : '';
|
||||
const limitRaw = Number(options.limit);
|
||||
const limit = Number.isFinite(limitRaw)
|
||||
? Math.max(1, Math.min(MAX_LIMIT, Math.round(limitRaw)))
|
||||
: DEFAULT_LIMIT;
|
||||
|
||||
let sql = `SELECT
|
||||
id,
|
||||
name,
|
||||
botanicalName,
|
||||
imageUri,
|
||||
imageStatus,
|
||||
description,
|
||||
categories,
|
||||
careInfo,
|
||||
confidence
|
||||
FROM plants`;
|
||||
const params = [];
|
||||
if (query) {
|
||||
sql += ` WHERE (
|
||||
LOWER(name) LIKE ?
|
||||
OR LOWER(botanicalName) LIKE ?
|
||||
OR LOWER(COALESCE(description, '')) LIKE ?
|
||||
)`;
|
||||
const likePattern = `%${query}%`;
|
||||
params.push(likePattern, likePattern, likePattern);
|
||||
}
|
||||
sql += ' ORDER BY name COLLATE NOCASE ASC';
|
||||
|
||||
const rows = await all(db, sql, params);
|
||||
let results = rows.map(toApiPlant);
|
||||
|
||||
if (category) {
|
||||
results = results.filter((plant) => plant.categories.includes(category));
|
||||
}
|
||||
|
||||
return results.slice(0, limit);
|
||||
};
|
||||
|
||||
const getPlantDiagnostics = async (db) => {
|
||||
const totals = await get(
|
||||
db,
|
||||
`SELECT
|
||||
COUNT(*) AS totalCount,
|
||||
SUM(CASE WHEN imageUri IS NULL OR TRIM(imageUri) = '' THEN 1 ELSE 0 END) AS missingImageCount,
|
||||
SUM(CASE WHEN COALESCE(imageStatus, 'ok') <> 'ok' THEN 1 ELSE 0 END) AS nonOkImageStatusCount
|
||||
FROM plants`,
|
||||
);
|
||||
|
||||
const duplicateImages = await all(
|
||||
db,
|
||||
`SELECT imageUri, COUNT(*) AS count
|
||||
FROM plants
|
||||
WHERE imageUri IS NOT NULL AND TRIM(imageUri) <> ''
|
||||
GROUP BY imageUri
|
||||
HAVING COUNT(*) > 1
|
||||
ORDER BY count DESC, imageUri ASC
|
||||
LIMIT 200`,
|
||||
);
|
||||
|
||||
const duplicateBotanicalNames = await all(
|
||||
db,
|
||||
`SELECT botanicalName, COUNT(*) AS count
|
||||
FROM plants
|
||||
WHERE botanicalName IS NOT NULL AND TRIM(botanicalName) <> ''
|
||||
GROUP BY LOWER(botanicalName)
|
||||
HAVING COUNT(*) > 1
|
||||
ORDER BY count DESC, botanicalName ASC
|
||||
LIMIT 200`,
|
||||
);
|
||||
|
||||
const recentAudits = await all(
|
||||
db,
|
||||
`SELECT
|
||||
id,
|
||||
source,
|
||||
importedCount,
|
||||
preservedIds,
|
||||
duplicateImageCount,
|
||||
status,
|
||||
details,
|
||||
backupTable,
|
||||
startedAt,
|
||||
completedAt
|
||||
FROM plant_import_audit
|
||||
ORDER BY startedAt DESC
|
||||
LIMIT 20`,
|
||||
);
|
||||
|
||||
return {
|
||||
totalCount: Number(totals?.totalCount || 0),
|
||||
missingImageCount: Number(totals?.missingImageCount || 0),
|
||||
nonOkImageStatusCount: Number(totals?.nonOkImageStatusCount || 0),
|
||||
duplicateImageCount: duplicateImages.length,
|
||||
duplicateImages,
|
||||
duplicateBotanicalNameCount: duplicateBotanicalNames.length,
|
||||
duplicateBotanicalNames,
|
||||
recentAudits: recentAudits.map((audit) => ({
|
||||
...audit,
|
||||
details: audit.details ? parseJsonObject(audit.details) : null,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
const writeAuditRow = async (db, audit) => {
|
||||
await run(
|
||||
db,
|
||||
`INSERT INTO plant_import_audit (
|
||||
source,
|
||||
importedCount,
|
||||
preservedIds,
|
||||
duplicateImageCount,
|
||||
status,
|
||||
details,
|
||||
backupTable,
|
||||
startedAt,
|
||||
completedAt
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
audit.source,
|
||||
audit.importedCount,
|
||||
audit.preservedIds,
|
||||
audit.duplicateImageCount,
|
||||
audit.status,
|
||||
JSON.stringify(audit.details || {}),
|
||||
audit.backupTable || null,
|
||||
audit.startedAt,
|
||||
audit.completedAt,
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
const sanitizeIdentifier = (value) => {
|
||||
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
|
||||
throw new Error(`Invalid SQL identifier: ${value}`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const rebuildPlantsCatalog = async (db, rawEntries, options = {}) => {
|
||||
if (!Array.isArray(rawEntries)) {
|
||||
throw new PlantImportValidationError('Import payload must be an array of entries.', [
|
||||
{ field: 'entries', message: 'Expected an array of plant objects.' },
|
||||
]);
|
||||
}
|
||||
|
||||
const source = typeof options.source === 'string' && options.source.trim()
|
||||
? options.source.trim()
|
||||
: 'manual';
|
||||
const preserveExistingIds = options.preserveExistingIds !== false;
|
||||
const enforceUniqueImages = options.enforceUniqueImages !== false;
|
||||
const startedAtIso = new Date().toISOString();
|
||||
|
||||
const existingRows = await all(db, 'SELECT id, botanicalName FROM plants');
|
||||
const existingIdMap = parseExistingIdMap(existingRows);
|
||||
|
||||
const validationErrors = [];
|
||||
const preparedEntries = rawEntries.map((rawEntry, index) => {
|
||||
const prepared = prepareEntry(rawEntry, index, existingIdMap, preserveExistingIds);
|
||||
if (prepared.errors.length > 0) {
|
||||
validationErrors.push(...prepared.errors);
|
||||
}
|
||||
return prepared.entry;
|
||||
});
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
throw new PlantImportValidationError(
|
||||
'Import payload failed validation checks.',
|
||||
validationErrors.slice(0, MAX_AUDIT_DETAILS),
|
||||
);
|
||||
}
|
||||
|
||||
assertValidPreparedEntries(preparedEntries, enforceUniqueImages);
|
||||
|
||||
const preservedIds = preparedEntries.reduce((count, entry) => {
|
||||
if (existingIdMap.get(normalizeKey(entry.botanicalName)) === entry.id) return count + 1;
|
||||
return count;
|
||||
}, 0);
|
||||
|
||||
const timestamp = startedAtIso.replace(/[-:.TZ]/g, '').slice(0, 14);
|
||||
const backupTable = sanitizeIdentifier(`plants_backup_${timestamp}`);
|
||||
const details = {
|
||||
enforceUniqueImages,
|
||||
preserveExistingIds,
|
||||
inputCount: rawEntries.length,
|
||||
preparedCount: preparedEntries.length,
|
||||
};
|
||||
|
||||
try {
|
||||
await run(db, 'BEGIN IMMEDIATE TRANSACTION');
|
||||
await run(db, `DROP TABLE IF EXISTS ${backupTable}`);
|
||||
await run(db, `CREATE TABLE ${backupTable} AS SELECT * FROM plants`);
|
||||
await run(db, 'DELETE FROM plants');
|
||||
|
||||
for (const entry of preparedEntries) {
|
||||
await run(
|
||||
db,
|
||||
`INSERT INTO plants (
|
||||
id,
|
||||
name,
|
||||
botanicalName,
|
||||
imageUri,
|
||||
imageStatus,
|
||||
description,
|
||||
categories,
|
||||
careInfo,
|
||||
confidence,
|
||||
createdAt,
|
||||
updatedAt
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
entry.id,
|
||||
entry.name,
|
||||
entry.botanicalName,
|
||||
entry.imageUri,
|
||||
'ok',
|
||||
entry.description,
|
||||
JSON.stringify(entry.categories),
|
||||
JSON.stringify(entry.careInfo),
|
||||
entry.confidence,
|
||||
startedAtIso,
|
||||
startedAtIso,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
await run(
|
||||
db,
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS idx_plants_botanical_name_unique ON plants(botanicalName)',
|
||||
);
|
||||
if (enforceUniqueImages) {
|
||||
await run(
|
||||
db,
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS idx_plants_image_uri_unique ON plants(imageUri)',
|
||||
);
|
||||
} else {
|
||||
await run(db, 'DROP INDEX IF EXISTS idx_plants_image_uri_unique');
|
||||
}
|
||||
|
||||
await run(db, 'COMMIT');
|
||||
} catch (error) {
|
||||
await run(db, 'ROLLBACK');
|
||||
const completedAtIso = new Date().toISOString();
|
||||
await writeAuditRow(db, {
|
||||
source,
|
||||
importedCount: 0,
|
||||
preservedIds: 0,
|
||||
duplicateImageCount: 0,
|
||||
status: 'failed',
|
||||
details: {
|
||||
...details,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
backupTable: null,
|
||||
startedAt: startedAtIso,
|
||||
completedAt: completedAtIso,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
const duplicateImages = await all(
|
||||
db,
|
||||
`SELECT imageUri, COUNT(*) AS count
|
||||
FROM plants
|
||||
GROUP BY imageUri
|
||||
HAVING COUNT(*) > 1`,
|
||||
);
|
||||
|
||||
const completedAtIso = new Date().toISOString();
|
||||
await writeAuditRow(db, {
|
||||
source,
|
||||
importedCount: preparedEntries.length,
|
||||
preservedIds,
|
||||
duplicateImageCount: duplicateImages.length,
|
||||
status: 'success',
|
||||
details,
|
||||
backupTable,
|
||||
startedAt: startedAtIso,
|
||||
completedAt: completedAtIso,
|
||||
});
|
||||
|
||||
return {
|
||||
source,
|
||||
importedCount: preparedEntries.length,
|
||||
preservedIds,
|
||||
duplicateImageCount: duplicateImages.length,
|
||||
backupTable,
|
||||
startedAt: startedAtIso,
|
||||
completedAt: completedAtIso,
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
PlantImportValidationError,
|
||||
ensurePlantSchema,
|
||||
getPlantDiagnostics,
|
||||
getPlants,
|
||||
normalizeImageUri,
|
||||
rebuildPlantsCatalog,
|
||||
};
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
|
||||
const getDefaultDbPath = () => {
|
||||
return process.env.PLANT_DB_PATH || path.join(__dirname, '..', 'data', 'greenlns.sqlite');
|
||||
};
|
||||
|
||||
const ensureDbDirectory = (dbPath) => {
|
||||
const directory = path.dirname(dbPath);
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
};
|
||||
|
||||
const openDatabase = (dbPath = getDefaultDbPath()) => {
|
||||
ensureDbDirectory(dbPath);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const db = new sqlite3.Database(dbPath, (error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(db);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const closeDatabase = (db) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const run = (db, sql, params = []) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.run(sql, params, function onRun(error) {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
lastId: this.lastID,
|
||||
changes: this.changes,
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const get = (db, sql, params = []) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.get(sql, params, (error, row) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(row || null);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const all = (db, sql, params = []) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
db.all(sql, params, (error, rows) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(rows || []);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
all,
|
||||
closeDatabase,
|
||||
get,
|
||||
getDefaultDbPath,
|
||||
openDatabase,
|
||||
run,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"name": "server",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"rebuild:batches": "node scripts/rebuild-from-batches.js",
|
||||
"diagnostics": "node scripts/plant-diagnostics.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cors": "^2.8.6",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^5.2.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"stripe": "^20.3.1"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
#!/usr/bin/env node
|
||||
/* eslint-disable no-console */
|
||||
require('dotenv').config();
|
||||
|
||||
const { closeDatabase, openDatabase } = require('../lib/sqlite');
|
||||
const { ensurePlantSchema, getPlantDiagnostics } = require('../lib/plants');
|
||||
|
||||
const main = async () => {
|
||||
const db = await openDatabase();
|
||||
try {
|
||||
await ensurePlantSchema(db);
|
||||
const diagnostics = await getPlantDiagnostics(db);
|
||||
console.log(JSON.stringify(diagnostics, null, 2));
|
||||
} finally {
|
||||
await closeDatabase(db);
|
||||
}
|
||||
};
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Failed to read plant diagnostics.');
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
#!/usr/bin/env node
|
||||
/* eslint-disable no-console */
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const vm = require('vm');
|
||||
require('dotenv').config();
|
||||
|
||||
const { closeDatabase, openDatabase } = require('../lib/sqlite');
|
||||
const { ensurePlantSchema, rebuildPlantsCatalog } = require('../lib/plants');
|
||||
|
||||
let ts;
|
||||
try {
|
||||
ts = require('typescript');
|
||||
} catch (error) {
|
||||
console.error('The rebuild script needs the "typescript" package in node_modules.');
|
||||
console.error('Install dependencies in the repository root before running this script.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const ROOT_DIR = path.resolve(__dirname, '..', '..');
|
||||
const BATCH_1_PATH = path.join(ROOT_DIR, 'constants', 'lexiconBatch1.ts');
|
||||
const BATCH_2_PATH = path.join(ROOT_DIR, 'constants', 'lexiconBatch2.ts');
|
||||
|
||||
const resolveTsFilePath = (fromFile, specifier) => {
|
||||
if (!specifier.startsWith('.')) return null;
|
||||
const fromDirectory = path.dirname(fromFile);
|
||||
const absoluteBase = path.resolve(fromDirectory, specifier);
|
||||
const candidates = [
|
||||
absoluteBase,
|
||||
`${absoluteBase}.ts`,
|
||||
`${absoluteBase}.tsx`,
|
||||
path.join(absoluteBase, 'index.ts'),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const loadTsModule = (absolutePath, cache = new Map()) => {
|
||||
if (cache.has(absolutePath)) return cache.get(absolutePath);
|
||||
|
||||
const source = fs.readFileSync(absolutePath, 'utf8');
|
||||
const transpiled = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2020,
|
||||
esModuleInterop: true,
|
||||
jsx: ts.JsxEmit.ReactJSX,
|
||||
},
|
||||
fileName: absolutePath,
|
||||
reportDiagnostics: false,
|
||||
}).outputText;
|
||||
|
||||
const module = { exports: {} };
|
||||
cache.set(absolutePath, module.exports);
|
||||
|
||||
const localRequire = (specifier) => {
|
||||
const resolvedTsPath = resolveTsFilePath(absolutePath, specifier);
|
||||
if (resolvedTsPath) {
|
||||
return loadTsModule(resolvedTsPath, cache);
|
||||
}
|
||||
return require(specifier);
|
||||
};
|
||||
|
||||
const sandbox = {
|
||||
module,
|
||||
exports: module.exports,
|
||||
require: localRequire,
|
||||
__dirname: path.dirname(absolutePath),
|
||||
__filename: absolutePath,
|
||||
console,
|
||||
process,
|
||||
Buffer,
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
};
|
||||
|
||||
vm.runInNewContext(transpiled, sandbox, { filename: absolutePath });
|
||||
cache.set(absolutePath, module.exports);
|
||||
return module.exports;
|
||||
};
|
||||
|
||||
const loadBatchEntries = () => {
|
||||
const batch1Module = loadTsModule(BATCH_1_PATH);
|
||||
const batch2Module = loadTsModule(BATCH_2_PATH);
|
||||
|
||||
const batch1Entries = batch1Module.LEXICON_BATCH_1_ENTRIES;
|
||||
const batch2Entries = batch2Module.LEXICON_BATCH_2_ENTRIES;
|
||||
|
||||
if (!Array.isArray(batch1Entries) || !Array.isArray(batch2Entries)) {
|
||||
throw new Error('Could not load lexicon batch entries from TypeScript constants.');
|
||||
}
|
||||
|
||||
return [...batch1Entries, ...batch2Entries];
|
||||
};
|
||||
|
||||
const main = async () => {
|
||||
const entries = loadBatchEntries();
|
||||
const db = await openDatabase();
|
||||
|
||||
try {
|
||||
await ensurePlantSchema(db);
|
||||
const summary = await rebuildPlantsCatalog(db, entries, {
|
||||
source: 'local_batch_script',
|
||||
preserveExistingIds: true,
|
||||
enforceUniqueImages: true,
|
||||
});
|
||||
|
||||
console.log('Rebuild finished successfully.');
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
} finally {
|
||||
await closeDatabase(db);
|
||||
}
|
||||
};
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Failed to rebuild plants from local batches.');
|
||||
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
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();
|
||||
|
||||
export interface AuthSession {
|
||||
userId: number; // local SQLite id (for plants/settings queries)
|
||||
serverUserId: string; // server-side user id (in JWT)
|
||||
email: string;
|
||||
name: string;
|
||||
token: string; // JWT from server
|
||||
loggedInAt: string;
|
||||
}
|
||||
|
||||
// ─── Internal helpers ──────────────────────────────────────────────────────
|
||||
|
||||
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;
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch (e) {
|
||||
if (!hasBackendUrl) {
|
||||
throw new Error('BACKEND_URL_MISSING');
|
||||
}
|
||||
throw new Error('NETWORK_ERROR');
|
||||
}
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const code = (data as any).code || 'AUTH_ERROR';
|
||||
const msg = (data as any).message || '';
|
||||
console.warn(`[Auth] ${path} failed:`, response.status, code, msg);
|
||||
throw new Error(code);
|
||||
}
|
||||
return data as any;
|
||||
};
|
||||
|
||||
const buildSession = (data: { userId: string; email: string; name: string; token: string }): AuthSession => {
|
||||
const localUser = AuthDb.ensureLocalUser(data.email, data.name);
|
||||
return {
|
||||
userId: localUser.id,
|
||||
serverUserId: data.userId,
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
token: data.token,
|
||||
loggedInAt: new Date().toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
// ─── AuthService ───────────────────────────────────────────────────────────
|
||||
|
||||
export const AuthService = {
|
||||
async getSession(): Promise<AuthSession | null> {
|
||||
try {
|
||||
const raw = await SecureStore.getItemAsync(SESSION_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<AuthSession>;
|
||||
if (!parsed.token || !parsed.serverUserId || !parsed.userId) {
|
||||
await clearStoredSession();
|
||||
return null;
|
||||
}
|
||||
return parsed as AuthSession;
|
||||
} catch {
|
||||
await clearStoredSession();
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async signUp(email: string, name: string, password: string): Promise<AuthSession> {
|
||||
const data = await authPost('/auth/signup', { email, name, password });
|
||||
const session = buildSession(data);
|
||||
await SecureStore.setItemAsync(SESSION_KEY, JSON.stringify(session));
|
||||
return session;
|
||||
},
|
||||
|
||||
async login(email: string, password: string): Promise<AuthSession> {
|
||||
const data = await authPost('/auth/login', { email, password });
|
||||
const session = buildSession(data);
|
||||
await SecureStore.setItemAsync(SESSION_KEY, JSON.stringify(session));
|
||||
return session;
|
||||
},
|
||||
|
||||
async logout(): Promise<void> {
|
||||
await clearStoredSession();
|
||||
},
|
||||
|
||||
async updateSessionName(name: string): Promise<void> {
|
||||
const session = await this.getSession();
|
||||
if (!session) return;
|
||||
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}` },
|
||||
});
|
||||
if (response.status === 401 || response.status === 403) return 'invalid';
|
||||
return 'valid';
|
||||
} catch {
|
||||
return 'unreachable';
|
||||
}
|
||||
},
|
||||
};
|
||||
Loading…
Reference in New Issue