import * as SQLite from 'expo-sqlite'; import { Plant, CareInfo, Language, AppearanceMode, ColorPalette } from '../types'; // ─── DB-Instanz ──────────────────────────────────────────────────────────────── let _db: SQLite.SQLiteDatabase | null = null; let _isDatabaseInitialized = false; export const getDb = (): SQLite.SQLiteDatabase => { if (!_db) _db = SQLite.openDatabaseSync('greenlens.db'); return _db; }; // ─── Schema ──────────────────────────────────────────────────────────────────── export const initDatabase = (): void => { if (_isDatabaseInitialized) return; const db = getDb(); try { db.execSync(` PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, 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')) ); CREATE TABLE IF NOT EXISTS user_settings ( user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, language TEXT NOT NULL DEFAULT 'de', language_set INTEGER NOT NULL DEFAULT 0, appearance_mode TEXT NOT NULL DEFAULT 'system', color_palette TEXT NOT NULL DEFAULT 'forest', profile_image TEXT, onboarding_done INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS plants ( id TEXT PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, botanical_name TEXT NOT NULL DEFAULT '', image_uri TEXT NOT NULL DEFAULT '', date_added TEXT NOT NULL, care_info TEXT NOT NULL DEFAULT '{}', last_watered TEXT NOT NULL, watering_history TEXT NOT NULL DEFAULT '[]', gallery TEXT NOT NULL DEFAULT '[]', description TEXT, notifications_enabled INTEGER NOT NULL DEFAULT 0, health_checks TEXT NOT NULL DEFAULT '[]' ); CREATE TABLE IF NOT EXISTS lexicon_search_history ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, query TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS app_meta ( key TEXT PRIMARY KEY, value TEXT NOT NULL ); `); _isDatabaseInitialized = true; } catch (error) { console.error('Failed to initialize SQLite schema.', error); throw error; } // Migration: add language_set column to existing databases try { db.runSync('ALTER TABLE user_settings ADD COLUMN language_set INTEGER NOT NULL DEFAULT 0'); } catch (_) { /* column already exists */ } }; // ─── App Meta ───────────────────────────────────────────────────────────────── export const AppMetaDb = { get(key: string): string | null { const row = getDb().getFirstSync<{ value: string }>( 'SELECT value FROM app_meta WHERE key = ?', [key], ); return row?.value ?? null; }, set(key: string, value: string): void { getDb().runSync( 'INSERT INTO app_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', [key, value], ); }, }; // ─── Auth ────────────────────────────────────────────────────────────────────── // Credential management has moved to the server (server/lib/auth.js + JWT). // AuthDb only manages the local device user (id=1) used for plants/settings queries. export interface DbUser { id: number; email: string; name: string; } export const AuthDb = { // Ensures a local device user exists. Maps email to a unique local ID. ensureLocalUser(email: string, name: string): { id: number } { const db = getDb(); const normalizedEmail = email.trim().toLowerCase(); // Check if this specific email already has a local account const existing = db.getFirstSync<{ id: number }>('SELECT id FROM users WHERE email = ?', [normalizedEmail]); if (existing) { // Update name just in case it changed on server db.runSync('UPDATE users SET name = ? WHERE id = ?', [name.trim(), existing.id]); return { id: existing.id }; } // Create a new local user if it doesn't exist const result = db.runSync( 'INSERT INTO users (email, name, password_hash) VALUES (?, ?, ?)', [normalizedEmail, name.trim(), 'server-auth'], ); const newUserId = result.lastInsertRowId; db.runSync('INSERT OR IGNORE INTO user_settings (user_id) VALUES (?)', [newUserId]); return { id: newUserId }; }, getUserById(id: number): DbUser | null { const db = getDb(); const user = db.getFirstSync( 'SELECT id, email, name FROM users WHERE id = ?', [id], ); return user || null; }, }; // ─── Settings ────────────────────────────────────────────────────────────────── export const SettingsDb = { get(userId: number) { const db = getDb(); db.runSync('INSERT OR IGNORE INTO user_settings (user_id) VALUES (?)', [userId]); return db.getFirstSync<{ language: string; language_set: number; appearance_mode: string; color_palette: string; profile_image: string | null; onboarding_done: number; }>('SELECT * FROM user_settings WHERE user_id = ?', [userId])!; }, setLanguage(userId: number, lang: Language) { getDb().runSync('UPDATE user_settings SET language = ?, language_set = 1 WHERE user_id = ?', [lang, userId]); }, setAppearanceMode(userId: number, mode: AppearanceMode) { getDb().runSync('UPDATE user_settings SET appearance_mode = ? WHERE user_id = ?', [mode, userId]); }, setColorPalette(userId: number, palette: ColorPalette) { getDb().runSync('UPDATE user_settings SET color_palette = ? WHERE user_id = ?', [palette, userId]); }, setProfileImage(userId: number, uri: string | null) { getDb().runSync('UPDATE user_settings SET profile_image = ? WHERE user_id = ?', [uri, userId]); }, setOnboardingDone(userId: number, done: boolean) { getDb().runSync( 'UPDATE user_settings SET onboarding_done = ? WHERE user_id = ?', [done ? 1 : 0, userId], ); }, setName(userId: number, name: string) { getDb().runSync('UPDATE users SET name = ? WHERE id = ?', [name.trim(), userId]); }, }; // ─── Plants ──────────────────────────────────────────────────────────────────── const DEFAULT_CARE_INFO: CareInfo = { waterIntervalDays: 7, light: 'Bright indirect light', temp: '18-25 C', }; const safeJsonParse = (value: unknown, fallback: T, fieldName: string, plantId: string): T => { if (typeof value !== 'string' || !value.trim()) return fallback; try { return JSON.parse(value) as T; } catch (error) { console.warn('Failed to parse plant JSON field. Falling back to defaults.', { plantId, fieldName, error: error instanceof Error ? error.message : String(error), }); return fallback; } }; const parsePlant = (row: any): Plant => ({ id: row.id, name: row.name, botanicalName: row.botanical_name, imageUri: row.image_uri, dateAdded: row.date_added, careInfo: safeJsonParse(row.care_info, DEFAULT_CARE_INFO, 'care_info', row.id), lastWatered: row.last_watered, wateringHistory: safeJsonParse(row.watering_history, [], 'watering_history', row.id), gallery: safeJsonParse(row.gallery, [], 'gallery', row.id), description: row.description ?? undefined, notificationsEnabled: row.notifications_enabled === 1, healthChecks: safeJsonParse(row.health_checks, [], 'health_checks', row.id), }); export const PlantsDb = { getAll(userId: number): Plant[] { const rows = getDb().getAllSync( 'SELECT * FROM plants WHERE user_id = ? ORDER BY date_added DESC', [userId], ); return rows.map(parsePlant); }, insert(userId: number, plant: Plant): void { getDb().runSync( `INSERT INTO plants (id, user_id, name, botanical_name, image_uri, date_added, care_info, last_watered, watering_history, gallery, description, notifications_enabled, health_checks) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ plant.id, userId, plant.name, plant.botanicalName, plant.imageUri, plant.dateAdded, JSON.stringify(plant.careInfo), plant.lastWatered, JSON.stringify(plant.wateringHistory ?? []), JSON.stringify(plant.gallery ?? []), plant.description ?? null, plant.notificationsEnabled ? 1 : 0, JSON.stringify(plant.healthChecks ?? []), ], ); }, update(userId: number, plant: Plant): void { getDb().runSync( `UPDATE plants SET name = ?, botanical_name = ?, image_uri = ?, care_info = ?, last_watered = ?, watering_history = ?, gallery = ?, description = ?, notifications_enabled = ?, health_checks = ? WHERE id = ? AND user_id = ?`, [ plant.name, plant.botanicalName, plant.imageUri, JSON.stringify(plant.careInfo), plant.lastWatered, JSON.stringify(plant.wateringHistory ?? []), JSON.stringify(plant.gallery ?? []), plant.description ?? null, plant.notificationsEnabled ? 1 : 0, JSON.stringify(plant.healthChecks ?? []), plant.id, userId, ], ); }, delete(userId: number, plantId: string): void { getDb().runSync('DELETE FROM plants WHERE id = ? AND user_id = ?', [plantId, userId]); }, }; // ─── Lexicon Search History ──────────────────────────────────────────────────── const HISTORY_LIMIT = 10; const normalize = (v: string) => v.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').trim().replace(/\s+/g, ' '); export const LexiconHistoryDb = { getAll(userId: number): string[] { return getDb() .getAllSync<{ query: string }>( 'SELECT query FROM lexicon_search_history WHERE user_id = ? ORDER BY created_at DESC LIMIT ?', [userId, HISTORY_LIMIT], ) .map((r) => r.query); }, add(userId: number, query: string): void { const trimmed = query.trim(); if (!trimmed) return; const db = getDb(); // Duplikate entfernen const normalized = normalize(trimmed); const existing = db.getAllSync<{ id: number; query: string }>( 'SELECT id, query FROM lexicon_search_history WHERE user_id = ?', [userId], ); for (const row of existing) { if (normalize(row.query) === normalized) { db.runSync('DELETE FROM lexicon_search_history WHERE id = ?', [row.id]); } } db.runSync( 'INSERT INTO lexicon_search_history (user_id, query) VALUES (?, ?)', [userId, trimmed], ); // Limit halten const oldest = db.getAllSync<{ id: number }>( 'SELECT id FROM lexicon_search_history WHERE user_id = ? ORDER BY created_at DESC LIMIT -1 OFFSET ?', [userId, HISTORY_LIMIT], ); for (const row of oldest) { db.runSync('DELETE FROM lexicon_search_history WHERE id = ?', [row.id]); } }, clear(userId: number): void { getDb().runSync('DELETE FROM lexicon_search_history WHERE user_id = ?', [userId]); }, };