345 lines
12 KiB
TypeScript
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]);
|
|
},
|
|
};
|