Greenlens/services/database.ts

345 lines
12 KiB
TypeScript

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<DbUser>(
'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 = <T,>(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<CareInfo>(row.care_info, DEFAULT_CARE_INFO, 'care_info', row.id),
lastWatered: row.last_watered,
wateringHistory: safeJsonParse<string[]>(row.watering_history, [], 'watering_history', row.id),
gallery: safeJsonParse<string[]>(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<any>(
'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]);
},
};