// db.js — sql.js (pure WASM SQLite) wrapper. // // Why sql.js and not better-sqlite3: better-sqlite3 is a native module that must be // compiled against the exact Electron ABI in use. On a small-practice machine without // build tools installed, that install step is a common source of breakage. sql.js has // no native dependency, so `npm install` + `npm start` works the same on any Windows PC. // The trade-off: the whole database is held in memory and written back to disk after // every change (see save()). For a solo/two-PC veterinary practice this file will stay // small (a few MB after years of records — attached photos/PDFs are NOT stored inside // it, only referenced by path), so this is not a performance concern. "use strict"; const fs = require("fs"); const path = require("path"); const crypto = require("crypto"); const initSqlJs = require("sql.js"); const SCHEMA = ` CREATE TABLE IF NOT EXISTS owners ( id TEXT PRIMARY KEY, name TEXT NOT NULL, phone TEXT, email TEXT, address TEXT, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS animals ( id TEXT PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES owners(id), name TEXT NOT NULL, species TEXT NOT NULL, species_label TEXT NOT NULL, breed TEXT, sex TEXT, birth_date TEXT, weight TEXT, chip_number TEXT, anamnese TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS notes ( id TEXT PRIMARY KEY, animal_id TEXT NOT NULL REFERENCES animals(id), date TEXT NOT NULL, text TEXT NOT NULL, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS history_entries ( id TEXT PRIMARY KEY, animal_id TEXT NOT NULL REFERENCES animals(id), date TEXT NOT NULL, title TEXT NOT NULL, text TEXT, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS lab_results ( id TEXT PRIMARY KEY, animal_id TEXT NOT NULL REFERENCES animals(id), name TEXT NOT NULL, date TEXT NOT NULL, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS lab_values ( id TEXT PRIMARY KEY, lab_result_id TEXT NOT NULL REFERENCES lab_results(id), parameter TEXT NOT NULL, value TEXT, reference TEXT ); CREATE TABLE IF NOT EXISTS documents ( id TEXT PRIMARY KEY, animal_id TEXT NOT NULL REFERENCES animals(id), kind TEXT NOT NULL, filename TEXT NOT NULL, relative_path TEXT NOT NULL, size_bytes INTEGER, uploaded_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS findings ( id TEXT PRIMARY KEY, animal_id TEXT NOT NULL REFERENCES animals(id), x REAL NOT NULL, y REAL NOT NULL, severity TEXT NOT NULL, text TEXT, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS exams ( id TEXT PRIMARY KEY, animal_id TEXT NOT NULL REFERENCES animals(id), date TEXT NOT NULL, puls TEXT, atmung TEXT, temperatur TEXT, herztoene TEXT, gewicht TEXT, schleimhaeute TEXT, maul_zaehne TEXT, augen_ohren TEXT, hydration TEXT, lymphknoten TEXT, haut_fell TEXT, gangbild TEXT, verhalten TEXT, auffaelligkeiten TEXT, empfehlung TEXT, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS consent ( animal_id TEXT PRIMARY KEY REFERENCES animals(id), granted INTEGER NOT NULL DEFAULT 0, signed_at TEXT, method TEXT, signature_path TEXT, marketing_ok INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS treatment_contract ( animal_id TEXT PRIMARY KEY REFERENCES animals(id), granted INTEGER NOT NULL DEFAULT 0, signed_at TEXT, method TEXT, signature_path TEXT, briefing_confirmed INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS settings ( key TEXT PRIMARY KEY, value TEXT ); CREATE TABLE IF NOT EXISTS remedies ( id TEXT PRIMARY KEY, name TEXT NOT NULL, category TEXT, keywords TEXT, notes TEXT, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS appointments ( id TEXT PRIMARY KEY, animal_id TEXT REFERENCES animals(id), title TEXT NOT NULL, date TEXT NOT NULL, start_time TEXT, duration_minutes INTEGER, location TEXT, notes TEXT, created_at TEXT NOT NULL ); `; let SQL = null; let db = null; let dbFilePath = null; function uid() { return crypto.randomUUID(); } async function init(dataFolder) { SQL = await initSqlJs({ locateFile: (file) => path.join(path.dirname(require.resolve("sql.js")), file), }); dbFilePath = path.join(dataFolder, "fellakte.db"); fs.mkdirSync(path.join(dataFolder, "files"), { recursive: true }); if (fs.existsSync(dbFilePath)) { const buf = fs.readFileSync(dbFilePath); db = new SQL.Database(buf); } else { db = new SQL.Database(); } db.run(SCHEMA); migrate(); save(); return db; } // Additive, idempotent column migrations — safe to run on every start. // (sql.js has no "ADD COLUMN IF NOT EXISTS"; SQLite errors if the column // already exists, so each attempt is wrapped and the error ignored.) function migrate() { const attempts = [ "ALTER TABLE animals ADD COLUMN anamnese_data TEXT NOT NULL DEFAULT '{}'", "ALTER TABLE consent ADD COLUMN items_json TEXT NOT NULL DEFAULT '{}'", "ALTER TABLE consent ADD COLUMN email_sent_at TEXT", "ALTER TABLE treatment_contract ADD COLUMN email_sent_at TEXT", "ALTER TABLE animals ADD COLUMN deceased_at TEXT", ]; for (const sql of attempts) { try { db.run(sql); } catch { // column already present from an earlier run — fine } } seedRemedyNames(); } // One-time seed: just the 38 standard Bach flower remedy names, as empty entries // ready to fill in. The actual descriptions (Anwendungsgebiete, Dosierung) are left // blank on purpose — the practice's real reference for those is a licensed book // ("Bach-Blüten für Tiere", Baumgart/Hand), whose content this app must not copy. function seedRemedyNames() { const already = get("SELECT COUNT(*) AS n FROM remedies"); if (already && already.n > 0) return; const names = [ "Agrimony", "Aspen", "Beech", "Centaury", "Cerato", "Cherry Plum", "Chestnut Bud", "Chicory", "Clematis", "Crab Apple", "Elm", "Gentian", "Gorse", "Heather", "Holly", "Honeysuckle", "Hornbeam", "Impatiens", "Larch", "Mimulus", "Mustard", "Oak", "Olive", "Pine", "Red Chestnut", "Rock Rose", "Rock Water", "Scleranthus", "Star of Bethlehem", "Sweet Chestnut", "Vervain", "Vine", "Walnut", "Water Violet", "White Chestnut", "Wild Oat", "Wild Rose", "Willow", ]; for (const name of names) { run("INSERT INTO remedies (id,name,category,keywords,notes,created_at) VALUES (?,?,?,?,?,?)", [ uid(), name, "Bachblüte", "", "", nowIso(), ]); } } function save() { if (!db || !dbFilePath) return; const data = db.export(); fs.writeFileSync(dbFilePath, Buffer.from(data)); } function all(sql, params = []) { const stmt = db.prepare(sql); stmt.bind(params); const rows = []; while (stmt.step()) rows.push(stmt.getAsObject()); stmt.free(); return rows; } function get(sql, params = []) { const rows = all(sql, params); return rows[0] || null; } function run(sql, params = []) { db.run(sql, params); save(); } const nowIso = () => new Date().toISOString(); // ---------- owners / animals ---------- function listOwnersWithAnimals() { const owners = all("SELECT * FROM owners ORDER BY name COLLATE NOCASE"); const animals = all("SELECT * FROM animals ORDER BY name COLLATE NOCASE"); return owners.map((o) => ({ ...o, animals: animals.filter((a) => a.owner_id === o.id) })); } function createOwner(data) { const id = uid(); run("INSERT INTO owners (id,name,phone,email,address,created_at) VALUES (?,?,?,?,?,?)", [ id, data.name, data.phone || "", data.email || "", data.address || "", nowIso(), ]); return get("SELECT * FROM owners WHERE id=?", [id]); } function createAnimal(ownerId, data) { const id = uid(); run( `INSERT INTO animals (id,owner_id,name,species,species_label,breed,sex,birth_date,weight,chip_number,anamnese,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, [ id, ownerId, data.name, data.species || "other", data.species_label || data.name, data.breed || "", data.sex || "", data.birth_date || "", data.weight || "", data.chip_number || "", "", nowIso(), ] ); run("INSERT INTO consent (animal_id,granted,marketing_ok) VALUES (?,0,0)", [id]); return id; } function updateAnimalField(animalId, field, value) { const allowed = ["name", "species", "species_label", "breed", "sex", "birth_date", "weight", "chip_number", "anamnese", "anamnese_data", "deceased_at"]; if (!allowed.includes(field)) throw new Error("Feld nicht erlaubt: " + field); run(`UPDATE animals SET ${field}=? WHERE id=?`, [value, animalId]); } function updateOwnerField(ownerId, field, value) { const allowed = ["name", "phone", "email", "address"]; if (!allowed.includes(field)) throw new Error("Feld nicht erlaubt: " + field); run(`UPDATE owners SET ${field}=? WHERE id=?`, [value, ownerId]); } // Deletes one animal and everything that hangs off it (rows only — the caller is // responsible for removing its files/ folder from disk). function deleteAnimal(animalId) { run("DELETE FROM findings WHERE animal_id=?", [animalId]); run("DELETE FROM consent WHERE animal_id=?", [animalId]); run("DELETE FROM treatment_contract WHERE animal_id=?", [animalId]); run("DELETE FROM exams WHERE animal_id=?", [animalId]); run("DELETE FROM notes WHERE animal_id=?", [animalId]); run("DELETE FROM history_entries WHERE animal_id=?", [animalId]); run("DELETE FROM documents WHERE animal_id=?", [animalId]); for (const lr of all("SELECT id FROM lab_results WHERE animal_id=?", [animalId])) { run("DELETE FROM lab_values WHERE lab_result_id=?", [lr.id]); } run("DELETE FROM lab_results WHERE animal_id=?", [animalId]); run("DELETE FROM animals WHERE id=?", [animalId]); } // Deletes an owner and every animal they have. Returns the removed animal ids so // the caller can also remove each one's files/ folder from disk. function deleteOwner(ownerId) { const animalIds = all("SELECT id FROM animals WHERE owner_id=?", [ownerId]).map((r) => r.id); for (const id of animalIds) deleteAnimal(id); run("DELETE FROM owners WHERE id=?", [ownerId]); return animalIds; } function getAnimalBundle(animalId) { const animal = get("SELECT * FROM animals WHERE id=?", [animalId]); if (!animal) return null; const owner = get("SELECT * FROM owners WHERE id=?", [animal.owner_id]); const notes = all("SELECT * FROM notes WHERE animal_id=? ORDER BY created_at DESC", [animalId]); const history = all("SELECT * FROM history_entries WHERE animal_id=? ORDER BY date DESC, created_at DESC", [animalId]); const labResults = all("SELECT * FROM lab_results WHERE animal_id=? ORDER BY date DESC", [animalId]).map((lr) => ({ ...lr, values: all("SELECT * FROM lab_values WHERE lab_result_id=?", [lr.id]), })); const documents = all("SELECT * FROM documents WHERE animal_id=? AND kind='document' ORDER BY uploaded_at DESC", [animalId]); const photos = all("SELECT * FROM documents WHERE animal_id=? AND kind='photo' ORDER BY uploaded_at DESC", [animalId]); const findings = all("SELECT * FROM findings WHERE animal_id=? ORDER BY created_at ASC", [animalId]); const exams = all("SELECT * FROM exams WHERE animal_id=? ORDER BY date DESC, created_at DESC", [animalId]); const consent = get("SELECT * FROM consent WHERE animal_id=?", [animalId]); const contract = get("SELECT * FROM treatment_contract WHERE animal_id=?", [animalId]); return { animal, owner, notes, history, labResults, documents, photos, findings, exams, consent, contract }; } // ---------- exams (vitals & Untersuchungsbefund — mirrors the paper Patientenakte) ---------- const EXAM_FIELDS = [ "puls", "atmung", "temperatur", "herztoene", "gewicht", "schleimhaeute", "maul_zaehne", "augen_ohren", "hydration", "lymphknoten", "haut_fell", "gangbild", "verhalten", "auffaelligkeiten", "empfehlung", ]; function addExam(animalId, fields) { const id = uid(); const cols = ["id", "animal_id", "date", ...EXAM_FIELDS, "created_at"]; const values = [ id, animalId, new Date().toLocaleDateString("de-DE"), ...EXAM_FIELDS.map((f) => fields[f] || ""), nowIso(), ]; run(`INSERT INTO exams (${cols.join(",")}) VALUES (${cols.map(() => "?").join(",")})`, values); return id; } // ---------- notes ---------- function addNote(animalId, text) { run("INSERT INTO notes (id,animal_id,date,text,created_at) VALUES (?,?,?,?,?)", [ uid(), animalId, new Date().toLocaleDateString("de-DE"), text, nowIso(), ]); } // Returns the distinct animal ids whose Notizen (free-text notes) contain the query — // used to extend the directory search beyond owner/animal names. function searchAnimalIdsByNoteText(query) { if (!query || !query.trim()) return []; const like = `%${query.trim()}%`; return all("SELECT DISTINCT animal_id FROM notes WHERE text LIKE ?", [like]).map((r) => r.animal_id); } // ---------- history ---------- function addHistoryEntry(animalId, title, text) { run("INSERT INTO history_entries (id,animal_id,date,title,text,created_at) VALUES (?,?,?,?,?,?)", [ uid(), animalId, new Date().toLocaleDateString("de-DE"), title, text, nowIso(), ]); } // ---------- lab ---------- function addLabResult(animalId, name) { const id = uid(); run("INSERT INTO lab_results (id,animal_id,name,date,created_at) VALUES (?,?,?,?,?)", [ id, animalId, name, new Date().toLocaleDateString("de-DE"), nowIso(), ]); return id; } function addLabValue(labResultId, parameter, value, reference) { run("INSERT INTO lab_values (id,lab_result_id,parameter,value,reference) VALUES (?,?,?,?,?)", [ uid(), labResultId, parameter, value, reference, ]); } function deleteLabResult(labResultId) { run("DELETE FROM lab_values WHERE lab_result_id=?", [labResultId]); run("DELETE FROM lab_results WHERE id=?", [labResultId]); } // ---------- documents ---------- function addDocumentRow(animalId, kind, filename, relativePath, sizeBytes) { const id = uid(); run( "INSERT INTO documents (id,animal_id,kind,filename,relative_path,size_bytes,uploaded_at) VALUES (?,?,?,?,?,?,?)", [id, animalId, kind, filename, relativePath, sizeBytes, nowIso()] ); return id; } function getDocument(id) { return get("SELECT * FROM documents WHERE id=?", [id]); } // ---------- findings (body map pins) ---------- function addFinding(animalId, { x, y, severity, text }) { const id = uid(); run("INSERT INTO findings (id,animal_id,x,y,severity,text,created_at) VALUES (?,?,?,?,?,?,?)", [ id, animalId, x, y, severity, text, nowIso(), ]); return id; } function updateFinding(id, { severity, text }) { run("UPDATE findings SET severity=?, text=? WHERE id=?", [severity, text, id]); } function deleteFinding(id) { run("DELETE FROM findings WHERE id=?", [id]); } // ---------- consent ---------- function setConsent(animalId, { granted, method, signaturePath, marketingOk, itemsJson }) { const exists = get("SELECT * FROM consent WHERE animal_id=?", [animalId]); // Only stamp a fresh "signed_at" on an actual false→true transition (a real signing // event). Otherwise a call that leaves `granted` unchanged — e.g. toggling the // separate marketing checkbox, or ticking a checklist item — would silently bump // the treatment consent's "Erteilt am" date to today every time. const wasGranted = exists ? !!exists.granted : false; const signedAt = !granted ? null : !wasGranted ? nowIso() : exists.signed_at; const items = itemsJson !== undefined ? itemsJson : exists ? undefined : "{}"; if (exists) { if (items !== undefined) { run( "UPDATE consent SET granted=?, signed_at=?, method=?, signature_path=?, marketing_ok=?, items_json=? WHERE animal_id=?", [granted ? 1 : 0, signedAt, method || null, signaturePath || null, marketingOk ? 1 : 0, items, animalId] ); } else { run( "UPDATE consent SET granted=?, signed_at=?, method=?, signature_path=?, marketing_ok=? WHERE animal_id=?", [granted ? 1 : 0, signedAt, method || null, signaturePath || null, marketingOk ? 1 : 0, animalId] ); } } else { run( "INSERT INTO consent (animal_id,granted,signed_at,method,signature_path,marketing_ok,items_json) VALUES (?,?,?,?,?,?,?)", [animalId, granted ? 1 : 0, signedAt, method || null, signaturePath || null, marketingOk ? 1 : 0, items || "{}"] ); } } function markConsentEmailed(animalId) { run("UPDATE consent SET email_sent_at=? WHERE animal_id=?", [nowIso(), animalId]); } // ---------- treatment contract (Behandlungsvertrag) ---------- function setContract(animalId, { granted, method, signaturePath, briefingConfirmed }) { const exists = get("SELECT * FROM treatment_contract WHERE animal_id=?", [animalId]); // Same fix as setConsent: only stamp a fresh signed_at on an actual false→true // transition, so toggling the unrelated "briefing confirmed" checkbox afterwards // doesn't silently bump the contract's "Erteilt am" date. const wasGranted = exists ? !!exists.granted : false; const signedAt = !granted ? null : !wasGranted ? nowIso() : exists.signed_at; if (exists) { run( "UPDATE treatment_contract SET granted=?, signed_at=?, method=?, signature_path=?, briefing_confirmed=? WHERE animal_id=?", [granted ? 1 : 0, signedAt, method || null, signaturePath || null, briefingConfirmed ? 1 : 0, animalId] ); } else { run( "INSERT INTO treatment_contract (animal_id,granted,signed_at,method,signature_path,briefing_confirmed) VALUES (?,?,?,?,?,?)", [animalId, granted ? 1 : 0, signedAt, method || null, signaturePath || null, briefingConfirmed ? 1 : 0] ); } } function markContractEmailed(animalId) { run("UPDATE treatment_contract SET email_sent_at=? WHERE animal_id=?", [nowIso(), animalId]); } // ---------- remedies (Medikamenten-/Präparate-Verzeichnis) ---------- function listRemedies(query) { if (!query) return all("SELECT * FROM remedies ORDER BY name COLLATE NOCASE"); const like = `%${query}%`; return all( "SELECT * FROM remedies WHERE name LIKE ? OR category LIKE ? OR keywords LIKE ? OR notes LIKE ? ORDER BY name COLLATE NOCASE", [like, like, like, like] ); } function addRemedy(data) { const id = uid(); run("INSERT INTO remedies (id,name,category,keywords,notes,created_at) VALUES (?,?,?,?,?,?)", [ id, data.name, data.category || "", data.keywords || "", data.notes || "", nowIso(), ]); return id; } function updateRemedy(id, data) { run("UPDATE remedies SET name=?, category=?, keywords=?, notes=? WHERE id=?", [ data.name, data.category || "", data.keywords || "", data.notes || "", id, ]); } function deleteRemedy(id) { run("DELETE FROM remedies WHERE id=?", [id]); } // ---------- appointments (Kalender) ---------- function listAppointments(fromDate, toDate) { const rows = all("SELECT * FROM appointments WHERE date>=? AND date<=? ORDER BY date ASC, start_time ASC", [fromDate, toDate]); return rows.map((r) => { if (!r.animal_id) return { ...r, animalName: null, ownerName: null }; const animal = get("SELECT * FROM animals WHERE id=?", [r.animal_id]); const owner = animal ? get("SELECT name FROM owners WHERE id=?", [animal.owner_id]) : null; return { ...r, animalName: animal ? animal.name : null, ownerName: owner ? owner.name : null }; }); } function addAppointment(data) { const id = uid(); run( "INSERT INTO appointments (id,animal_id,title,date,start_time,duration_minutes,location,notes,created_at) VALUES (?,?,?,?,?,?,?,?,?)", [id, data.animalId || null, data.title, data.date, data.startTime || null, data.durationMinutes || null, data.location || null, data.notes || null, nowIso()] ); return id; } function updateAppointment(id, data) { run( "UPDATE appointments SET animal_id=?, title=?, date=?, start_time=?, duration_minutes=?, location=?, notes=? WHERE id=?", [data.animalId || null, data.title, data.date, data.startTime || null, data.durationMinutes || null, data.location || null, data.notes || null, id] ); } function deleteAppointment(id) { run("DELETE FROM appointments WHERE id=?", [id]); } // ---------- settings (key/value; SMTP config lives here, shared via the data folder) ---------- function getSetting(key) { const row = get("SELECT value FROM settings WHERE key=?", [key]); return row ? row.value : null; } function setSetting(key, value) { const exists = get("SELECT key FROM settings WHERE key=?", [key]); if (exists) run("UPDATE settings SET value=? WHERE key=?", [value, key]); else run("INSERT INTO settings (key,value) VALUES (?,?)", [key, value]); } module.exports = { init, save, uid, listOwnersWithAnimals, createOwner, createAnimal, updateAnimalField, updateOwnerField, deleteAnimal, deleteOwner, getAnimalBundle, addNote, searchAnimalIdsByNoteText, addHistoryEntry, addLabResult, addLabValue, deleteLabResult, addDocumentRow, getDocument, addFinding, updateFinding, deleteFinding, addExam, setConsent, markConsentEmailed, setContract, markContractEmailed, getSetting, setSetting, listAppointments, addAppointment, updateAppointment, deleteAppointment, listRemedies, addRemedy, updateRemedy, deleteRemedy, get dbFilePath() { return dbFilePath; }, };