Initial commit for Greenlens
This commit is contained in:
344
services/database.ts
Normal file
344
services/database.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
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]);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user